Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
Expand Down
48 changes: 48 additions & 0 deletions internal/pkg/service/appsproxy/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Config struct {
CsrfTokenSalt string `configKey:"csrfTokenSalt" configUsage:"Salt used for generating CSRF tokens" validate:"required" sensitive:"true"`
StorageAPIURL *url.URL `configKey:"storageApiUrl" configUsage:"Base URL of the Keboola Storage API for this stack, used for Storage token verification (kai-preview flow). Must match the stack the proxy fronts — e.g. https://connection.eu-central-1.keboola.com for an EU stack. No default; required." validate:"required"`
KaiPreview KaiPreview `configKey:"kaiPreview" configUsage:"kai-preview iframe-auth configuration."`
Preview Preview `configKey:"preview" configUsage:"Dev-mode app preview links minted by sandboxes-service."`
K8s K8s `configKey:"k8s" configUsage:"Kubernetes configuration."`
E2bWebhook E2BWebhook `configKey:"e2bWebhook"`
Sessions Sessions `configKey:"sessions" configUsage:"End-user session tracking for data apps."`
Expand Down Expand Up @@ -87,6 +88,53 @@ type KaiPreview struct {
AllowedOrigins []string `configKey:"allowedOrigins" configUsage:"Origins allowed to embed apps via kai-preview and mint handshake tokens (e.g. https://connection.keboola.com). Drives both the CORS allowlist and the bootstrap CSP frame-ancestors directive." validate:"required,min=1,dive,http_url"`
}

type Preview struct {
JWKSURL string `configKey:"jwksURL" configUsage:"In-cluster URL of the sandboxes-service JWKS. Empty disables preview links." validate:"omitempty,http_url"`
Issuer string `configKey:"issuer" configUsage:"Expected iss claim of preview links, e.g. https://apps.<suffix>. Required when jwksURL is set."`
SessionSigningKey string `configKey:"sessionSigningKey" configUsage:"HMAC key for the preview session cookie, at least 32 characters. Required when jwksURL is set. Generate with 'openssl rand -hex 32'." sensitive:"true"`
AllowedFrameAncestors []string `configKey:"allowedFrameAncestors" configUsage:"Origins allowed to frame the preview landing page (CSP frame-ancestors), e.g. https://connection.keboola.com."`
}

func (c Preview) Enabled() bool {
return c.JWKSURL != ""
}

func (c *Preview) Normalize() {
for i, o := range c.AllowedFrameAncestors {
c.AllowedFrameAncestors[i] = strings.TrimRight(strings.TrimSpace(o), "/")
}
}

func (c *Preview) Validate() error {
if !c.Enabled() {
return nil
}
errs := errors.NewMultiError()
if c.Issuer == "" {
errs.Append(errors.New("preview.issuer is required when preview.jwksURL is set"))
}
if len(c.SessionSigningKey) < 32 {
errs.Append(errors.New("preview.sessionSigningKey must have at least 32 characters when preview.jwksURL is set"))
}
for _, origin := range c.AllowedFrameAncestors {
if !isPlainOrigin(origin) {
errs.Append(errors.Errorf(`preview.allowedFrameAncestors: "%s" must be a scheme and host only`, origin))
}
}
return errs.ErrorOrNil()
}

func isPlainOrigin(origin string) bool {
if strings.ContainsAny(origin, " ;,'\"") {
return false
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.Path == "" && u.RawQuery == "" && u.Fragment == "" && u.User == nil
}

type API struct {
Listen string `configKey:"listen" configUsage:"Listen address of the configuration HTTP API." validate:"required,hostname_port"`
PublicURL *url.URL `configKey:"publicUrl" configUsage:"Public URL of the configuration HTTP API for link generation." validate:"required"`
Expand Down
95 changes: 95 additions & 0 deletions internal/pkg/service/appsproxy/config/preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package config_test

import (
"net/url"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/keboola/keboola-as-code/internal/pkg/env"
"github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config"
"github.com/keboola/keboola-as-code/internal/pkg/service/common/configmap"
)

func requiredConfig(t *testing.T) config.Config {
t.Helper()
cfg := config.New()
cfg.CookieSecretSalt = "x"
cfg.CsrfTokenSalt = "x"
cfg.SandboxesAPI.URL = "https://example"
cfg.K8s = config.K8s{AppsNamespace: "ns"}
storageURL, err := url.Parse("https://connection.keboola.com")
require.NoError(t, err)
cfg.StorageAPIURL = storageURL
cfg.KaiPreview = config.KaiPreview{
HandshakeSigningKey: "k1",
SessionSigningKey: "k2",
SessionTTL: 4 * time.Hour,
AllowedOrigins: []string{"https://connection.keboola.com"},
}
return cfg
}

func TestPreviewConfig_Defaults(t *testing.T) {
t.Parallel()
cfg := config.New()
assert.Empty(t, cfg.Preview.JWKSURL)
assert.False(t, cfg.Preview.Enabled())
}

func TestPreviewConfig_DisabledNeedsNothing(t *testing.T) {
t.Parallel()
cfg := requiredConfig(t)
require.NoError(t, configmap.ValidateAndNormalize(&cfg))
}

func TestPreviewConfig_EnvNames(t *testing.T) {
t.Parallel()
cfg := requiredConfig(t)

envs := env.Empty()
envs.Set("APPS_PROXY_PREVIEW_JWKS_URL", "http://sandboxes-service-api.default.svc.cluster.local/.well-known/jwks.json")
envs.Set("APPS_PROXY_PREVIEW_ISSUER", "https://apps.keboola.com")
envs.Set("APPS_PROXY_PREVIEW_SESSION_SIGNING_KEY", strings.Repeat("k", 64))
envs.Set("APPS_PROXY_PREVIEW_ALLOWED_FRAME_ANCESTORS", "https://connection.keboola.com,https://connection.north-europe.azure.keboola.com/")
require.NoError(t, configmap.GenerateAndBind(configmap.GenerateAndBindConfig{
EnvNaming: env.NewNamingConvention("APPS_PROXY_"),
Envs: envs,
}, &cfg))

assert.True(t, cfg.Preview.Enabled())
assert.Equal(t, "http://sandboxes-service-api.default.svc.cluster.local/.well-known/jwks.json", cfg.Preview.JWKSURL)
assert.Equal(t, "https://apps.keboola.com", cfg.Preview.Issuer)
assert.Equal(t, strings.Repeat("k", 64), cfg.Preview.SessionSigningKey)
assert.Equal(t, []string{"https://connection.keboola.com", "https://connection.north-europe.azure.keboola.com"}, cfg.Preview.AllowedFrameAncestors)
}

func TestPreviewConfig_EnabledValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mutate func(p *config.Preview)
wantErr string
}{
{name: "missing-issuer", mutate: func(p *config.Preview) { p.Issuer = "" }, wantErr: "preview.issuer"},
{name: "short-key", mutate: func(p *config.Preview) { p.SessionSigningKey = "short" }, wantErr: "preview.sessionSigningKey"},
{name: "ancestor-with-path", mutate: func(p *config.Preview) { p.AllowedFrameAncestors = []string{"https://connection.keboola.com/admin"} }, wantErr: "preview.allowedFrameAncestors"},
{name: "ancestor-with-semicolon", mutate: func(p *config.Preview) { p.AllowedFrameAncestors = []string{"https://a.com;script-src *"} }, wantErr: "preview.allowedFrameAncestors"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := requiredConfig(t)
cfg.Preview.JWKSURL = "http://sandboxes-service-api.default.svc.cluster.local/.well-known/jwks.json"
cfg.Preview.Issuer = "https://apps.keboola.com"
cfg.Preview.SessionSigningKey = strings.Repeat("k", 64)
tc.mutate(&cfg.Preview)
err := configmap.ValidateAndNormalize(&cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package preview

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"encoding/json"
"io"
"net/http"
"sort"
"sync/atomic"
"time"

"github.com/go-jose/go-jose/v4"
"github.com/jonboulle/clockwork"
"golang.org/x/sync/singleflight"

"github.com/keboola/keboola-as-code/internal/pkg/log"
"github.com/keboola/keboola-as-code/internal/pkg/utils/errors"
)

const (
unknownKidRefetchInterval = time.Minute
maxJWKSBodySize = 64 << 10
jwksFetchTimeout = 10 * time.Second
)

type KeySetConfig struct {
URL string
RefreshInterval time.Duration
MaxStaleness time.Duration
}

type KeySet struct {
cfg KeySetConfig
client *http.Client
clock clockwork.Clock
logger log.Logger

snapshot atomic.Pointer[keySnapshot]
lastAttempt atomic.Pointer[time.Time]
refetch singleflight.Group
}

type keySnapshot struct {
keys map[string]*ecdsa.PublicKey
fetchedAt time.Time
}

type jwksDocument struct {
Keys *[]json.RawMessage `json:"keys"`
}

func NewKeySet(cfg KeySetConfig, clock clockwork.Clock, logger log.Logger) *KeySet {
return &KeySet{
cfg: cfg,
clock: clock,
logger: logger,
client: &http.Client{
Timeout: jwksFetchTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}

func (s *KeySet) Run(ctx context.Context) {
s.refreshAndLog(ctx)
ticker := s.clock.NewTicker(s.cfg.RefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.Chan():
s.refreshAndLog(ctx)
}
}
}

func (s *KeySet) Refresh(ctx context.Context) error {
s.markAttempt()
keys, skipped, err := s.fetch(ctx)
if err != nil {
return err
}
for _, kid := range skipped {
s.logger.Warnf(ctx, `preview: JWKS key "%s" skipped: only kty=EC, crv=P-256, use=sig, alg ES256 or absent, and 32-byte x/y are accepted`, log.Sanitize(kid))
}
s.store(keys)
s.logger.Debugf(ctx, "preview: JWKS loaded, kids=%v", sortedKids(keys))
return nil
}

func (s *KeySet) Key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) {
if key, ok := s.lookup(kid); ok {
return key, nil
}
<-s.refetch.DoChan("jwks", func() (any, error) {
if s.claimRefetch() {
s.refreshAndLog(context.WithoutCancel(ctx))
}
return nil, nil
})
if key, ok := s.lookup(kid); ok {
return key, nil
}
return nil, errors.Errorf(`preview: no usable key for kid "%s"`, log.Sanitize(kid))
}

func (s *KeySet) refreshAndLog(ctx context.Context) {
err := s.Refresh(ctx)
if err == nil || ctx.Err() != nil {
return
}
s.logger.Warnf(ctx, "preview: JWKS refresh failed: %s", err)
}

func (s *KeySet) markAttempt() {
now := s.clock.Now()
s.lastAttempt.Store(&now)
}

func (s *KeySet) store(keys map[string]*ecdsa.PublicKey) {
s.snapshot.Store(&keySnapshot{keys: keys, fetchedAt: s.clock.Now()})
}

func (s *KeySet) lookup(kid string) (*ecdsa.PublicKey, bool) {
snap := s.snapshot.Load()
if snap == nil || s.clock.Since(snap.fetchedAt) > s.cfg.MaxStaleness {
return nil, false
}
key, ok := snap.keys[kid]
return key, ok
}

func (s *KeySet) claimRefetch() bool {
now := s.clock.Now()
last := s.lastAttempt.Load()
if last != nil && now.Sub(*last) < unknownKidRefetchInterval {
return false
}
return s.lastAttempt.CompareAndSwap(last, &now)
}

func (s *KeySet) fetch(ctx context.Context) (map[string]*ecdsa.PublicKey, []string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.URL, nil)
if err != nil {
return nil, nil, errors.Errorf("preview: JWKS request: %w", err)
}
resp, err := s.client.Do(req)
if err != nil {
return nil, nil, errors.Errorf("preview: JWKS fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, errors.Errorf("preview: JWKS answered HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSBodySize+1))
if err != nil {
return nil, nil, errors.Errorf("preview: JWKS read: %w", err)
}
if len(body) > maxJWKSBodySize {
return nil, nil, errors.New("preview: JWKS response is too large")
}
return parseJWKS(body)
}

func parseJWKS(body []byte) (map[string]*ecdsa.PublicKey, []string, error) {
Comment thread
Matovidlo marked this conversation as resolved.
var doc jwksDocument
if err := json.Unmarshal(body, &doc); err != nil {
return nil, nil, errors.Errorf("preview: invalid JWKS: %w", err)
}
if doc.Keys == nil {
return nil, nil, errors.New("preview: JWKS has no keys field")
}
keys := make(map[string]*ecdsa.PublicKey, len(*doc.Keys))
var skipped []string
for _, raw := range *doc.Keys {
kid, pub, ok := parseJWK(raw)
if !ok {
skipped = append(skipped, kid)
continue
}
keys[kid] = pub
}
return keys, skipped, nil
}

func parseJWK(raw json.RawMessage) (string, *ecdsa.PublicKey, bool) {
var ident struct {
Kid string `json:"kid"`
}
_ = json.Unmarshal(raw, &ident)

var k jose.JSONWebKey
if err := json.Unmarshal(raw, &k); err != nil {
return ident.Kid, nil, false
}
if k.KeyID == "" || k.Use != "sig" || (k.Algorithm != "" && k.Algorithm != "ES256") {
return ident.Kid, nil, false
}
pub, ok := k.Key.(*ecdsa.PublicKey)
if !ok || pub.Curve != elliptic.P256() {
return ident.Kid, nil, false
}
return k.KeyID, pub, true
}

func sortedKids(keys map[string]*ecdsa.PublicKey) []string {
kids := make([]string, 0, len(keys))
for kid := range keys {
kids = append(kids, log.Sanitize(kid))
}
sort.Strings(kids)
return kids
}
Loading
Loading