diff --git a/pkg/authserver/server/keys/config.go b/pkg/authserver/server/keys/config.go new file mode 100644 index 0000000000..15ec5bd86f --- /dev/null +++ b/pkg/authserver/server/keys/config.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package keys + +// Config holds configuration for creating a KeyProvider. +// The caller is responsible for populating this from their own config source +// (environment variables, YAML files, flags, etc.). +type Config struct { + // KeyDir is the directory containing PEM-encoded private key files. + // All key filenames are relative to this directory. + // + // In Kubernetes deployments, this is typically a mounted Secret volume: + // + // volumeMounts: + // - name: signing-keys + // mountPath: /etc/toolhive/keys + KeyDir string + + // SigningKeyFile is the filename of the primary signing key (relative to KeyDir). + // This key is used for signing new tokens. + // If empty with KeyDir set, NewProviderFromConfig returns an error. + // If both KeyDir and SigningKeyFile are empty, an ephemeral key is generated. + SigningKeyFile string + + // FallbackKeyFiles are filenames of additional keys for verification (relative to KeyDir). + // These keys are included in the JWKS endpoint for token verification but are NOT + // used for signing new tokens. + // + // Key rotation (single replica): update SigningKeyFile to the new key and move + // the old filename here. Tokens signed with old keys remain verifiable until + // they expire. + // + // Key rotation (multiple replicas): to avoid a window where one replica signs + // with a key not yet advertised by another replica's JWKS endpoint: + // 1. Add the new key to FallbackKeyFiles and roll out to all replicas. + // 2. Promote it to SigningKeyFile, move the old key to FallbackKeyFiles, roll out. + // 3. Remove the old key from FallbackKeyFiles after its tokens have expired. + FallbackKeyFiles []string +} + +// NewProviderFromConfig creates a KeyProvider based on the configuration. +// +// Behavior: +// - If KeyDir and SigningKeyFile are set: load keys from directory +// - If both are empty: return GeneratingProvider (ephemeral key for development) +// - If KeyDir is set but SigningKeyFile is empty: returns an error +func NewProviderFromConfig(cfg Config) (KeyProvider, error) { + if cfg.KeyDir != "" { + return NewFileProvider(cfg) + } + + // Generate ephemeral key (development only) + return NewGeneratingProvider(DefaultAlgorithm), nil +} diff --git a/pkg/authserver/server/keys/mocks/mock_provider.go b/pkg/authserver/server/keys/mocks/mock_provider.go new file mode 100644 index 0000000000..c4f8b7a7a7 --- /dev/null +++ b/pkg/authserver/server/keys/mocks/mock_provider.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: provider.go +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_provider.go -package=mocks -source=provider.go KeyProvider +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + keys "github.com/stacklok/toolhive/pkg/authserver/server/keys" + gomock "go.uber.org/mock/gomock" +) + +// MockKeyProvider is a mock of KeyProvider interface. +type MockKeyProvider struct { + ctrl *gomock.Controller + recorder *MockKeyProviderMockRecorder + isgomock struct{} +} + +// MockKeyProviderMockRecorder is the mock recorder for MockKeyProvider. +type MockKeyProviderMockRecorder struct { + mock *MockKeyProvider +} + +// NewMockKeyProvider creates a new mock instance. +func NewMockKeyProvider(ctrl *gomock.Controller) *MockKeyProvider { + mock := &MockKeyProvider{ctrl: ctrl} + mock.recorder = &MockKeyProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockKeyProvider) EXPECT() *MockKeyProviderMockRecorder { + return m.recorder +} + +// PublicKeys mocks base method. +func (m *MockKeyProvider) PublicKeys(ctx context.Context) ([]*keys.PublicKeyData, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublicKeys", ctx) + ret0, _ := ret[0].([]*keys.PublicKeyData) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublicKeys indicates an expected call of PublicKeys. +func (mr *MockKeyProviderMockRecorder) PublicKeys(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublicKeys", reflect.TypeOf((*MockKeyProvider)(nil).PublicKeys), ctx) +} + +// SigningKey mocks base method. +func (m *MockKeyProvider) SigningKey(ctx context.Context) (*keys.SigningKeyData, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SigningKey", ctx) + ret0, _ := ret[0].(*keys.SigningKeyData) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SigningKey indicates an expected call of SigningKey. +func (mr *MockKeyProviderMockRecorder) SigningKey(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SigningKey", reflect.TypeOf((*MockKeyProvider)(nil).SigningKey), ctx) +} diff --git a/pkg/authserver/server/keys/provider.go b/pkg/authserver/server/keys/provider.go new file mode 100644 index 0000000000..bec89e59a8 --- /dev/null +++ b/pkg/authserver/server/keys/provider.go @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + "path/filepath" + "sync" + "time" + + servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/logger" +) + +//go:generate mockgen -destination=mocks/mock_provider.go -package=mocks -source=provider.go KeyProvider + +// KeyProvider provides signing keys for JWT operations. +// Implementations handle key sourcing (file, memory, generation). +type KeyProvider interface { + // SigningKey returns the current signing key. + // Returns ErrNoSigningKey if no key is available. + SigningKey(ctx context.Context) (*SigningKeyData, error) + + // PublicKeys returns all public keys for the JWKS endpoint. + // May return multiple keys during rotation periods. + PublicKeys(ctx context.Context) ([]*PublicKeyData, error) +} + +// FileProvider loads signing keys from PEM files in a directory. +// The signing key is used for signing new tokens. +// All keys (signing + fallback) are exposed via PublicKeys() for JWKS. +// Keys are loaded once at construction time; changes require restart. +type FileProvider struct { + signingKey *SigningKeyData + allKeys []*SigningKeyData +} + +// NewFileProvider creates a provider that loads keys from a directory. +// Config.SigningKeyFile is the primary key used for signing new tokens. +// Config.FallbackKeyFiles are loaded for JWKS verification (for key rotation). +// All keys are loaded immediately and validated. +// Supports RSA (PKCS1/PKCS8), ECDSA (SEC1/PKCS8), and Ed25519 keys. +func NewFileProvider(cfg Config) (*FileProvider, error) { + if cfg.SigningKeyFile == "" { + return nil, fmt.Errorf("signing key file is required") + } + + // Load the primary signing key + signingKeyPath := filepath.Join(cfg.KeyDir, cfg.SigningKeyFile) + signingKey, err := loadKeyFromFile(signingKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to load signing key: %w", err) + } + + // Start with the signing key in allKeys + allKeys := []*SigningKeyData{signingKey} + + // Load fallback keys for JWKS verification + for _, filename := range cfg.FallbackKeyFiles { + keyPath := filepath.Join(cfg.KeyDir, filename) + key, err := loadKeyFromFile(keyPath) + if err != nil { + return nil, fmt.Errorf("failed to load fallback key %s: %w", filename, err) + } + allKeys = append(allKeys, key) + } + + return &FileProvider{ + signingKey: signingKey, + allKeys: allKeys, + }, nil +} + +// loadKeyFromFile loads a single key from a PEM file. +func loadKeyFromFile(keyPath string) (*SigningKeyData, error) { + signer, err := servercrypto.LoadSigningKey(keyPath) + if err != nil { + return nil, err + } + + params, err := servercrypto.DeriveSigningKeyParams(signer, "", "") + if err != nil { + return nil, fmt.Errorf("failed to derive key parameters: %w", err) + } + + return &SigningKeyData{ + KeyID: params.KeyID, + Algorithm: params.Algorithm, + Key: params.Key, + CreatedAt: time.Now(), + }, nil +} + +// SigningKey returns the primary signing key used for signing new tokens. +// Returns a copy to prevent external mutation of internal state. +func (p *FileProvider) SigningKey(_ context.Context) (*SigningKeyData, error) { + return &SigningKeyData{ + KeyID: p.signingKey.KeyID, + Algorithm: p.signingKey.Algorithm, + Key: p.signingKey.Key, + CreatedAt: p.signingKey.CreatedAt, + }, nil +} + +// PublicKeys returns public keys for all loaded keys (signing + additional). +// This enables verification of tokens signed with any of the loaded keys, +// supporting key rotation scenarios where old keys must remain valid. +func (p *FileProvider) PublicKeys(_ context.Context) ([]*PublicKeyData, error) { + pubKeys := make([]*PublicKeyData, 0, len(p.allKeys)) + for _, key := range p.allKeys { + pubKeys = append(pubKeys, &PublicKeyData{ + KeyID: key.KeyID, + Algorithm: key.Algorithm, + PublicKey: key.Key.Public(), + CreatedAt: key.CreatedAt, + }) + } + return pubKeys, nil +} + +// GeneratingProvider generates an ephemeral key on first access. +// Suitable for development but NOT recommended for production. +// Generated keys are lost on restart, invalidating all issued tokens. +type GeneratingProvider struct { + algorithm string + mu sync.Mutex + key *SigningKeyData +} + +// NewGeneratingProvider creates a provider that generates an ephemeral key. +// The key is generated lazily on first SigningKey() call. +// If algorithm is empty, DefaultAlgorithm (ES256) is used. +func NewGeneratingProvider(algorithm string) *GeneratingProvider { + if algorithm == "" { + algorithm = DefaultAlgorithm + } + return &GeneratingProvider{algorithm: algorithm} +} + +// SigningKey returns the signing key, generating one if needed. +// Thread-safe: uses mutex to ensure only one key is generated. +// Returns a copy to prevent external mutation of internal state. +func (p *GeneratingProvider) SigningKey(_ context.Context) (*SigningKeyData, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.key != nil { + return &SigningKeyData{ + KeyID: p.key.KeyID, + Algorithm: p.key.Algorithm, + Key: p.key.Key, + CreatedAt: p.key.CreatedAt, + }, nil + } + + key, err := p.generateKey() + if err != nil { + return nil, err + } + + logger.Warnw("generated ephemeral signing key - tokens will be invalid after restart", + "algorithm", key.Algorithm, + "keyID", key.KeyID, + ) + + p.key = key + return &SigningKeyData{ + KeyID: p.key.KeyID, + Algorithm: p.key.Algorithm, + Key: p.key.Key, + CreatedAt: p.key.CreatedAt, + }, nil +} + +// PublicKeys returns the public key for JWKS. +// Generates the signing key if it hasn't been generated yet. +func (p *GeneratingProvider) PublicKeys(ctx context.Context) ([]*PublicKeyData, error) { + key, err := p.SigningKey(ctx) + if err != nil { + return nil, err + } + return []*PublicKeyData{{ + KeyID: key.KeyID, + Algorithm: key.Algorithm, + PublicKey: key.Key.Public(), + CreatedAt: key.CreatedAt, + }}, nil +} + +func (p *GeneratingProvider) generateKey() (*SigningKeyData, error) { + privateKey, err := generatePrivateKey(p.algorithm) + if err != nil { + return nil, fmt.Errorf("failed to generate signing key: %w", err) + } + + keyID, err := servercrypto.DeriveKeyID(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to derive key ID: %w", err) + } + + return &SigningKeyData{ + KeyID: keyID, + Algorithm: p.algorithm, + Key: privateKey, + CreatedAt: time.Now(), + }, nil +} + +// generatePrivateKey creates a new private key for the specified algorithm. +func generatePrivateKey(algorithm string) (crypto.Signer, error) { + switch algorithm { + case "ES256": + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case "ES384": + return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case "ES512": + return ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + default: + return nil, fmt.Errorf("unsupported algorithm for key generation: %s", algorithm) + } +} + +// Compile-time interface checks. +var ( + _ KeyProvider = (*FileProvider)(nil) + _ KeyProvider = (*GeneratingProvider)(nil) +) diff --git a/pkg/authserver/server/keys/provider_test.go b/pkg/authserver/server/keys/provider_test.go new file mode 100644 index 0000000000..2d0d380a7d --- /dev/null +++ b/pkg/authserver/server/keys/provider_test.go @@ -0,0 +1,447 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writePEM writes a PEM-encoded EC key to a temp file and returns the filename. +func writePEM(t *testing.T, dir, filename string, der []byte) string { + t.Helper() + path := filepath.Join(dir, filename) + data := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + require.NoError(t, os.WriteFile(path, data, 0600)) + return filename +} + +// generateTestKey generates an ECDSA P-256 key for testing. +func generateTestKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + return key +} + +// TestFileProvider tests the FileProvider implementation. +func TestFileProvider(t *testing.T) { + t.Parallel() + + t.Run("loads valid EC key", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ecKey := generateTestKey(t) + der, err := x509.MarshalECPrivateKey(ecKey) + require.NoError(t, err) + keyFile := writePEM(t, dir, "signing.pem", der) + + provider, err := NewFileProvider(Config{ + KeyDir: dir, + SigningKeyFile: keyFile, + }) + require.NoError(t, err) + + key, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, key.KeyID) + assert.Equal(t, "ES256", key.Algorithm) + assert.NotNil(t, key.Key) + + pubKeys, err := provider.PublicKeys(context.Background()) + require.NoError(t, err) + require.Len(t, pubKeys, 1) + assert.Equal(t, key.KeyID, pubKeys[0].KeyID) + assert.Equal(t, key.Algorithm, pubKeys[0].Algorithm) + }) + + t.Run("fails for non-existent file", func(t *testing.T) { + t.Parallel() + _, err := NewFileProvider(Config{ + KeyDir: "/nonexistent", + SigningKeyFile: "key.pem", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load signing key") + }) + + t.Run("fails for invalid PEM", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "invalid.pem") + require.NoError(t, os.WriteFile(path, []byte("not a valid pem"), 0600)) + + _, err := NewFileProvider(Config{ + KeyDir: dir, + SigningKeyFile: "invalid.pem", + }) + require.Error(t, err) + }) + + t.Run("fails when signing key file is empty", func(t *testing.T) { + t.Parallel() + _, err := NewFileProvider(Config{ + KeyDir: "/some/dir", + SigningKeyFile: "", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "signing key file is required") + }) + + t.Run("loads multiple keys", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create three keys + key1 := generateTestKey(t) + der1, err := x509.MarshalECPrivateKey(key1) + require.NoError(t, err) + signingFile := writePEM(t, dir, "signing.pem", der1) + + key2 := generateTestKey(t) + der2, err := x509.MarshalECPrivateKey(key2) + require.NoError(t, err) + fallback1 := writePEM(t, dir, "old1.pem", der2) + + key3 := generateTestKey(t) + der3, err := x509.MarshalECPrivateKey(key3) + require.NoError(t, err) + fallback2 := writePEM(t, dir, "old2.pem", der3) + + provider, err := NewFileProvider(Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + FallbackKeyFiles: []string{fallback1, fallback2}, + }) + require.NoError(t, err) + + // SigningKey should return the first key + signingKey, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, signingKey.KeyID) + assert.Equal(t, "ES256", signingKey.Algorithm) + + // PublicKeys should return all three keys + pubKeys, err := provider.PublicKeys(context.Background()) + require.NoError(t, err) + require.Len(t, pubKeys, 3) + + // First public key should match the signing key + assert.Equal(t, signingKey.KeyID, pubKeys[0].KeyID) + + // All keys should have unique key IDs + keyIDs := make(map[string]bool) + for _, pk := range pubKeys { + assert.False(t, keyIDs[pk.KeyID], "duplicate key ID found") + keyIDs[pk.KeyID] = true + } + }) + + t.Run("signing key returns first key only", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + key1 := generateTestKey(t) + der1, err := x509.MarshalECPrivateKey(key1) + require.NoError(t, err) + signingFile := writePEM(t, dir, "signing.pem", der1) + + key2 := generateTestKey(t) + der2, err := x509.MarshalECPrivateKey(key2) + require.NoError(t, err) + fallbackFile := writePEM(t, dir, "old.pem", der2) + + provider, err := NewFileProvider(Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + FallbackKeyFiles: []string{fallbackFile}, + }) + require.NoError(t, err) + + signingKey, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + + pubKeys, err := provider.PublicKeys(context.Background()) + require.NoError(t, err) + require.Len(t, pubKeys, 2) + + // Verify signing key matches the first public key (same key ID) + assert.Equal(t, signingKey.KeyID, pubKeys[0].KeyID) + + // Verify the second public key is different + assert.NotEqual(t, signingKey.KeyID, pubKeys[1].KeyID) + }) + + t.Run("fails when fallback key file is invalid", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Valid signing key + key1 := generateTestKey(t) + der1, err := x509.MarshalECPrivateKey(key1) + require.NoError(t, err) + signingFile := writePEM(t, dir, "signing.pem", der1) + + // Invalid fallback key + require.NoError(t, os.WriteFile(filepath.Join(dir, "invalid.pem"), []byte("not a valid pem"), 0600)) + + _, err = NewFileProvider(Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + FallbackKeyFiles: []string{"invalid.pem"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load fallback key") + }) + + t.Run("fails when fallback key file does not exist", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + key1 := generateTestKey(t) + der1, err := x509.MarshalECPrivateKey(key1) + require.NoError(t, err) + signingFile := writePEM(t, dir, "signing.pem", der1) + + _, err = NewFileProvider(Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + FallbackKeyFiles: []string{"nonexistent.pem"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load fallback key") + }) + + t.Run("works with no fallback keys", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + key1 := generateTestKey(t) + der1, err := x509.MarshalECPrivateKey(key1) + require.NoError(t, err) + signingFile := writePEM(t, dir, "signing.pem", der1) + + provider, err := NewFileProvider(Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + }) + require.NoError(t, err) + + signingKey, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, signingKey.KeyID) + + pubKeys, err := provider.PublicKeys(context.Background()) + require.NoError(t, err) + require.Len(t, pubKeys, 1) + assert.Equal(t, signingKey.KeyID, pubKeys[0].KeyID) + }) +} + +// TestGeneratingProvider tests the GeneratingProvider implementation. +func TestGeneratingProvider(t *testing.T) { + t.Parallel() + + t.Run("generates key on first access", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("ES256") + + key, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.NotEmpty(t, key.KeyID) + assert.Equal(t, "ES256", key.Algorithm) + assert.NotNil(t, key.Key) + }) + + t.Run("returns same key on subsequent calls", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("ES256") + + key1, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + + key2, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + + // Keys should have identical values (copies of the same internal key) + assert.Equal(t, key1.KeyID, key2.KeyID) + assert.Equal(t, key1.Algorithm, key2.Algorithm) + assert.Equal(t, key1.Key, key2.Key) + assert.Equal(t, key1.CreatedAt, key2.CreatedAt) + }) + + t.Run("uses default algorithm when empty", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("") + + key, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.Equal(t, DefaultAlgorithm, key.Algorithm) + }) + + t.Run("supports ES384", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("ES384") + + key, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ES384", key.Algorithm) + }) + + t.Run("supports ES512", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("ES512") + + key, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ES512", key.Algorithm) + }) + + t.Run("fails for unsupported algorithm", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("RS256") // RSA not supported for generation + + _, err := provider.SigningKey(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported algorithm") + }) + + t.Run("PublicKeys generates key if needed", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("ES256") + + pubKeys, err := provider.PublicKeys(context.Background()) + require.NoError(t, err) + require.Len(t, pubKeys, 1) + + // Verify the signing key was also generated + key, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.Equal(t, key.KeyID, pubKeys[0].KeyID) + }) + + t.Run("thread-safe concurrent access", func(t *testing.T) { + t.Parallel() + provider := NewGeneratingProvider("ES256") + + var wg sync.WaitGroup + var keys [10]*SigningKeyData + var errs [10]error + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + keys[idx], errs[idx] = provider.SigningKey(context.Background()) + }(i) + } + + wg.Wait() + + // All should succeed with the same key + for i := 0; i < 10; i++ { + require.NoError(t, errs[i]) + assert.Equal(t, keys[0].KeyID, keys[i].KeyID) + } + }) +} + +// TestNewProviderFromConfig tests the factory function. +func TestNewProviderFromConfig(t *testing.T) { + t.Parallel() + + t.Run("creates FileProvider from config", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ecKey := generateTestKey(t) + der, err := x509.MarshalECPrivateKey(ecKey) + require.NoError(t, err) + keyFile := writePEM(t, dir, "signing.pem", der) + + provider, err := NewProviderFromConfig(Config{ + KeyDir: dir, + SigningKeyFile: keyFile, + }) + require.NoError(t, err) + + key, err := provider.SigningKey(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ES256", key.Algorithm) + }) + + t.Run("creates GeneratingProvider when no key dir configured", func(t *testing.T) { + t.Parallel() + provider, err := NewProviderFromConfig(Config{}) + require.NoError(t, err) + + // Should be a GeneratingProvider + _, ok := provider.(*GeneratingProvider) + assert.True(t, ok, "expected GeneratingProvider") + }) + + t.Run("fails with invalid key file", func(t *testing.T) { + t.Parallel() + _, err := NewProviderFromConfig(Config{ + KeyDir: "/nonexistent", + SigningKeyFile: "key.pem", + }) + require.Error(t, err) + }) + + t.Run("creates FileProvider with fallback keys", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create signing key + key1 := generateTestKey(t) + der1, err := x509.MarshalECPrivateKey(key1) + require.NoError(t, err) + signingFile := writePEM(t, dir, "signing.pem", der1) + + // Create fallback key + key2 := generateTestKey(t) + der2, err := x509.MarshalECPrivateKey(key2) + require.NoError(t, err) + fallbackFile := writePEM(t, dir, "old.pem", der2) + + provider, err := NewProviderFromConfig(Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + FallbackKeyFiles: []string{fallbackFile}, + }) + require.NoError(t, err) + + pubKeys, err := provider.PublicKeys(context.Background()) + require.NoError(t, err) + require.Len(t, pubKeys, 2) + }) + + t.Run("fails with invalid fallback key", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + key1 := generateTestKey(t) + der1, err := x509.MarshalECPrivateKey(key1) + require.NoError(t, err) + signingFile := writePEM(t, dir, "signing.pem", der1) + + _, err = NewProviderFromConfig(Config{ + KeyDir: dir, + SigningKeyFile: signingFile, + FallbackKeyFiles: []string{"nonexistent.pem"}, + }) + require.Error(t, err) + }) +} diff --git a/pkg/authserver/server/keys/types.go b/pkg/authserver/server/keys/types.go new file mode 100644 index 0000000000..8720fc622c --- /dev/null +++ b/pkg/authserver/server/keys/types.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package keys provides signing key management for the OAuth authorization server. +// It handles key lifecycle including loading from files, generation, and retrieval. +package keys + +import ( + "crypto" + "time" +) + +// DefaultAlgorithm is the default signing algorithm for auto-generated keys. +// ES256 (ECDSA with P-256) is recommended by NIST and OWASP for JWT signing. +// It provides equivalent security to RSA-3072 with smaller keys and faster operations. +const DefaultAlgorithm = "ES256" + +// SigningKeyData represents a signing key with its metadata. +// This contains private key material and should not be exposed externally. +type SigningKeyData struct { + // KeyID is the unique identifier for this key (RFC 7638 thumbprint). + KeyID string + + // Algorithm is the signing algorithm (e.g., "ES256", "RS256"). + Algorithm string + + // Key is the private key used for signing. + Key crypto.Signer + + // CreatedAt is when this key was generated or loaded. + CreatedAt time.Time +} + +// PublicKeyData represents the public portion of a signing key. +// This is safe to expose via the JWKS endpoint. +type PublicKeyData struct { + // KeyID is the unique identifier for this key (RFC 7638 thumbprint). + KeyID string + + // Algorithm is the signing algorithm (e.g., "ES256", "RS256"). + Algorithm string + + // PublicKey is the public key for verification. + PublicKey crypto.PublicKey + + // CreatedAt is when this key was generated or loaded. + CreatedAt time.Time +}