From 485ad219b058cde84044bada0db6378d08944c51 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Mon, 31 Aug 2026 03:35:55 +0800 Subject: [PATCH 1/2] Publish fallback keys in JWKS for rotation The documented three-step rotation relies on FallbackKeyFiles being advertised in /.well-known/jwks.json before promotion. Server construction only read SigningKey, so the JWKS always contained a single key and step 1 had no observable effect. Populate SigningJWKS from PublicKeys, keeping the primary key first and publishing fallbacks as public-only JWKs. Signing still uses only the primary key. This restores the overlap window and fixes the hard-cutover on promotion. Signed-off-by: lorenzozanee --- pkg/authserver/server/provider.go | 12 ++++- pkg/authserver/server/provider_test.go | 52 +++++++++++++++++++ pkg/authserver/server_impl.go | 23 +++++++++ pkg/authserver/server_test.go | 69 ++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) diff --git a/pkg/authserver/server/provider.go b/pkg/authserver/server/provider.go index e37edfbb7b..aace780bde 100644 --- a/pkg/authserver/server/provider.go +++ b/pkg/authserver/server/provider.go @@ -129,6 +129,11 @@ type AuthorizationServerParams struct { SigningKeyID string SigningKeyAlgorithm string SigningKey crypto.Signer + // AdditionalKeys are extra public keys published in the JWKS alongside the + // signing key. They enable zero-downtime rotation: a new key can be added + // to FallbackKeyFiles and advertised via JWKS before it becomes the + // SigningKey, and an old key can remain verifiable after promotion. + AdditionalKeys []jose.JSONWebKey // AllowedAudiences is the list of valid resource URIs that tokens can be issued for. // Per RFC 8707, the "resource" parameter in token requests is validated against this list. // Security: An empty list means NO audiences are permitted (secure default). @@ -296,6 +301,11 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio Use: "sig", } + // Build full JWKS: signing key first, then any additional rotation keys. + jwksKeys := make([]jose.JSONWebKey, 0, 1+len(cfg.AdditionalKeys)) + jwksKeys = append(jwksKeys, jwk) + jwksKeys = append(jwksKeys, cfg.AdditionalKeys...) + fositeConfig := &fosite.Config{ AccessTokenIssuer: cfg.Issuer, AccessTokenLifespan: cfg.AccessTokenLifespan, @@ -322,7 +332,7 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio return &AuthorizationServerConfig{ Config: fositeConfig, SigningKey: &jwk, - SigningJWKS: &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}, + SigningJWKS: &jose.JSONWebKeySet{Keys: jwksKeys}, AllowedAudiences: cfg.AllowedAudiences, ScopesSupported: cfg.ScopesSupported, BaselineClientScopes: cfg.BaselineClientScopes, diff --git a/pkg/authserver/server/provider_test.go b/pkg/authserver/server/provider_test.go index 052cae3000..bf77ee2312 100644 --- a/pkg/authserver/server/provider_test.go +++ b/pkg/authserver/server/provider_test.go @@ -16,11 +16,14 @@ package server import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "testing" "time" + "github.com/go-jose/go-jose/v4" "github.com/ory/fosite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -556,6 +559,55 @@ func TestAuthorizationServerConfig_PublicJWKS(t *testing.T) { assert.True(t, ok, "expected public key, got %T", publicJWKS.Keys[0].Key) } +func TestNewAuthorizationServerConfig_WithAdditionalKeys(t *testing.T) { + t.Parallel() + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + fallbackJWK := jose.JSONWebKey{ + Key: &ecKey.PublicKey, + KeyID: "fallback-ec", + Algorithm: "ES256", + Use: "sig", + } + + params := &AuthorizationServerParams{ + Issuer: "https://auth.example.com", + AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: time.Hour * 24, + AuthCodeLifespan: time.Minute * 10, + HMACSecrets: servercrypto.NewHMACSecrets([]byte("test-secret-with-32-bytes-long!!")), + SigningKeyID: "primary-rsa", + SigningKeyAlgorithm: "RS256", + SigningKey: rsaKey, + AdditionalKeys: []jose.JSONWebKey{fallbackJWK}, + } + + cfg, err := NewAuthorizationServerConfig(params) + require.NoError(t, err) + require.NotNil(t, cfg.SigningJWKS) + require.Len(t, cfg.SigningJWKS.Keys, 2) + // Primary must stay first + assert.Equal(t, "primary-rsa", cfg.SigningJWKS.Keys[0].KeyID) + assert.Equal(t, "fallback-ec", cfg.SigningJWKS.Keys[1].KeyID) + + // PublicJWKS must expose both as public keys and preserve order + pub := cfg.PublicJWKS() + require.Len(t, pub.Keys, 2) + assert.Equal(t, "primary-rsa", pub.Keys[0].KeyID) + assert.Equal(t, "fallback-ec", pub.Keys[1].KeyID) + _, ok := pub.Keys[0].Key.(*rsa.PublicKey) + assert.True(t, ok, "primary should be public RSA, got %T", pub.Keys[0].Key) + _, ok = pub.Keys[1].Key.(*ecdsa.PublicKey) + assert.True(t, ok, "fallback should be public EC, got %T", pub.Keys[1].Key) + + // SigningKey stays isolated for signing + assert.Equal(t, "primary-rsa", cfg.SigningKey.KeyID) +} + // mockStorage is a minimal fosite.Storage implementation for testing. type mockStorage struct{} diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index 94b32a74ac..f8d87ac50a 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -10,6 +10,7 @@ import ( "net/http" "time" + jose "github.com/go-jose/go-jose/v4" josev3 "github.com/go-jose/go-jose/v3" "github.com/ory/fosite" "github.com/ory/fosite/compose" @@ -134,6 +135,27 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se return nil, fmt.Errorf("failed to get signing key: %w", err) } + // Collect additional public keys for JWKS rotation. The signing key stays + // the sole signer; fallback keys are published as public-only JWKS entries + // so consumers can verify tokens signed before rotation. The primary is + // kept first to preserve kid priority. + var additionalKeys []jose.JSONWebKey + if pubKeys, pubErr := cfg.KeyProvider.PublicKeys(ctx); pubErr == nil { + for _, pk := range pubKeys { + if pk.KeyID == signingKey.KeyID { + continue + } + additionalKeys = append(additionalKeys, jose.JSONWebKey{ + Key: pk.PublicKey, + KeyID: pk.KeyID, + Algorithm: pk.Algorithm, + Use: "sig", + }) + } + } else { + slog.Warn("failed to get public keys for JWKS, serving signing key only", "error", pubErr) + } + // Create OAuth2 config from authserver.Config oauthParams := &oauthserver.AuthorizationServerParams{ Issuer: cfg.Issuer, @@ -144,6 +166,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se SigningKeyID: signingKey.KeyID, SigningKeyAlgorithm: signingKey.Algorithm, SigningKey: signingKey.Key, + AdditionalKeys: additionalKeys, ScopesSupported: cfg.ScopesSupported, BaselineClientScopes: cfg.BaselineClientScopes, AllowedAudiences: cfg.AllowedAudiences, diff --git a/pkg/authserver/server_test.go b/pkg/authserver/server_test.go index 6b4069c064..cae9647dbf 100644 --- a/pkg/authserver/server_test.go +++ b/pkg/authserver/server_test.go @@ -5,13 +5,23 @@ package authserver import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "sync" "testing" "time" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -494,3 +504,62 @@ func TestNewServer_RegistersDelegateClientsBeforeUpstreamConstruction(t *testing require.NoError(t, err) assert.False(t, registration.DCRIssued(client)) } + +func TestNewServer_JWKSIncludesFallbackKeys(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writePEM := func(key *ecdsa.PrivateKey, name string) string { + der, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + path := filepath.Join(dir, name) + data := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + require.NoError(t, os.WriteFile(path, data, 0600)) + return name + } + k1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + k2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + signingFile := writePEM(k1, "signing.pem") + fallbackFile := writePEM(k2, "fallback.pem") + + provider, err := keys.NewFileProvider(keys.Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + FallbackKeyFiles: []string{fallbackFile}, + }) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockUpstream := upstreammocks.NewMockOAuth2Provider(ctrl) + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + + cfg := Config{ + Issuer: "https://example.com", + KeyProvider: provider, + HMACSecrets: &servercrypto.HMACSecrets{Current: validHMACSecret()}, + Upstreams: []UpstreamConfig{{Name: "default", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstreamConfig()}}, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + factory := func(_ context.Context, _ *UpstreamConfig) (upstream.OAuth2Provider, error) { + return mockUpstream, nil + } + srv, err := newServer(context.Background(), cfg, stor, withUpstreamFactory(factory)) + require.NoError(t, err) + + // Hit the JWKS endpoint and verify both keys are published, primary first. + req := httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var jwks jose.JSONWebKeySet + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &jwks)) + require.Len(t, jwks.Keys, 2) + pubKeys, err := provider.PublicKeys(context.Background()) + require.NoError(t, err) + assert.Equal(t, pubKeys[0].KeyID, jwks.Keys[0].KeyID) + assert.Equal(t, pubKeys[1].KeyID, jwks.Keys[1].KeyID) +} From 098f7d1e3c8a839da72bdbeb78aadd04485133b6 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Mon, 31 Aug 2026 04:03:46 +0800 Subject: [PATCH 2/2] Publish fallback keys in JWKS for rotation The documented three-step rotation relies on FallbackKeyFiles being advertised in /.well-known/jwks.json before promotion. Server construction only read SigningKey, so the JWKS always contained a single key and step 1 had no observable effect. Populate SigningJWKS from PublicKeys, keeping the primary key first and publishing fallbacks as public-only JWKs. Signing still uses only the primary key. This restores the overlap window and fixes the hard-cutover on promotion. Signed-off-by: lorenzozanee --- pkg/authserver/server_impl.go | 49 ++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index f8d87ac50a..e194e3ba5b 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -10,13 +10,14 @@ import ( "net/http" "time" - jose "github.com/go-jose/go-jose/v4" josev3 "github.com/go-jose/go-jose/v3" + jose "github.com/go-jose/go-jose/v4" "github.com/ory/fosite" "github.com/ory/fosite/compose" oauthserver "github.com/stacklok/toolhive/pkg/authserver/server" "github.com/stacklok/toolhive/pkg/authserver/server/handlers" + "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" "github.com/stacklok/toolhive/pkg/authserver/storage" @@ -79,6 +80,31 @@ func withUpstreamFactory(factory upstreamProviderFactory) serverOption { } } +func getAdditionalKeys(ctx context.Context, kp keys.KeyProvider, signingKeyID string) []jose.JSONWebKey { + pubKeys, err := kp.PublicKeys(ctx) + if err != nil { + slog.Warn("failed to get public keys for JWKS, serving signing key only", "error", err) + return nil + } + return additionalJWKs(signingKeyID, pubKeys) +} + +func additionalJWKs(signingKeyID string, pubKeys []*keys.PublicKeyData) []jose.JSONWebKey { + var additional []jose.JSONWebKey + for _, pk := range pubKeys { + if pk.KeyID == signingKeyID { + continue + } + additional = append(additional, jose.JSONWebKey{ + Key: pk.PublicKey, + KeyID: pk.KeyID, + Algorithm: pk.Algorithm, + Use: "sig", + }) + } + return additional +} + // newServer creates a new OAuth authorization server. // The opts parameter allows injecting dependencies for testing. func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...serverOption) (*server, error) { @@ -135,26 +161,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se return nil, fmt.Errorf("failed to get signing key: %w", err) } - // Collect additional public keys for JWKS rotation. The signing key stays - // the sole signer; fallback keys are published as public-only JWKS entries - // so consumers can verify tokens signed before rotation. The primary is - // kept first to preserve kid priority. - var additionalKeys []jose.JSONWebKey - if pubKeys, pubErr := cfg.KeyProvider.PublicKeys(ctx); pubErr == nil { - for _, pk := range pubKeys { - if pk.KeyID == signingKey.KeyID { - continue - } - additionalKeys = append(additionalKeys, jose.JSONWebKey{ - Key: pk.PublicKey, - KeyID: pk.KeyID, - Algorithm: pk.Algorithm, - Use: "sig", - }) - } - } else { - slog.Warn("failed to get public keys for JWKS, serving signing key only", "error", pubErr) - } + additionalKeys := getAdditionalKeys(ctx, cfg.KeyProvider, signingKey.KeyID) // Create OAuth2 config from authserver.Config oauthParams := &oauthserver.AuthorizationServerParams{