Repository navigation
PAT-2167 Apps Proxy preview links (1/3): config and JWKS key set #2662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
898160f
PAT-2167 feat(apps-proxy): add preview link configuration
pepamartinec 7806426
PAT-2167 feat(apps-proxy): normalise preview link origins
pepamartinec d6ec8a7
PAT-2167 feat(apps-proxy): fetch and cache preview link keys from JWKS
pepamartinec 5b16a08
PAT-2167 fix(apps-proxy): keep the preview key set when the JWKS resp…
pepamartinec 875bea3
PAT-2167 refactor(apps-proxy): name the log sanitiser after the claim…
pepamartinec 549341f
PAT-2167 refactor(apps-proxy): move the claim log sanitiser to its ow…
pepamartinec 91671a1
PAT-2167 fix(apps-proxy): refetch an unknown preview key even when th…
pepamartinec 33a8120
PAT-2167 fix(apps-proxy): do not warn about a JWKS refresh cancelled …
pepamartinec d4259af
PAT-2167 refactor(apps-proxy): keep the preview key set in atomics an…
pepamartinec 699d7e5
PAT-2167 refactor(apps-proxy): parse preview JWKS entries with go-jose
pepamartinec c48f5a4
PAT-2167 refactor(apps-proxy): claim the JWKS refetch slot with a sin…
pepamartinec cac726b
PAT-2167 refactor(apps-proxy): use the shared log sanitiser for JWKS …
pepamartinec File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| } | ||
| } |
218 changes: 218 additions & 0 deletions
218
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/preview/jwks.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| 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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.