Skip to content
Open
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
12 changes: 11 additions & 1 deletion pkg/authserver/server/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions pkg/authserver/server/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{}

Expand Down
30 changes: 30 additions & 0 deletions pkg/authserver/server_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import (
"time"

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"
Expand Down Expand Up @@ -78,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) {
Expand Down Expand Up @@ -134,6 +161,8 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se
return nil, fmt.Errorf("failed to get signing key: %w", err)
}

additionalKeys := getAdditionalKeys(ctx, cfg.KeyProvider, signingKey.KeyID)

// Create OAuth2 config from authserver.Config
oauthParams := &oauthserver.AuthorizationServerParams{
Issuer: cfg.Issuer,
Expand All @@ -144,6 +173,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,
Expand Down
69 changes: 69 additions & 0 deletions pkg/authserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Loading