diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index 6b5227b2..718b2452 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -19,6 +19,8 @@ type Service struct { userProvider types.UserProvider clientProvider types.ClientProvider prefix string + // Signing certificates for JWT token signing and verification + signingCerts *SigningCertificates } // Config OAuth service configuration @@ -124,6 +126,17 @@ func NewService(config *Config) (*Service, error) { } } + // Load Certificates + signingCerts, err := LoadSigningCertificates(&config.Signing) + if err != nil { + return nil, fmt.Errorf("failed to load signing certificates: %w", err) + } + + // Validate the loaded certificates + if err := signingCerts.ValidateCertificate(); err != nil { + return nil, fmt.Errorf("certificate validation failed: %w", err) + } + service := &Service{ config: config, store: config.Store, @@ -131,6 +144,7 @@ func NewService(config *Config) (*Service, error) { userProvider: userProvider, clientProvider: clientProvider, prefix: keyPrefix, + signingCerts: signingCerts, } return service, nil @@ -231,11 +245,17 @@ func validateConfig(config *Config) error { return types.ErrIssuerURLMissing } - // Validate certificate configuration - if config.Signing.SigningCertPath == "" || config.Signing.SigningKeyPath == "" { - return types.ErrCertificateMissing + // Certificate configuration validation + // If both cert and key paths are provided, they must both exist or be empty + certPathProvided := config.Signing.SigningCertPath != "" + keyPathProvided := config.Signing.SigningKeyPath != "" + + if certPathProvided != keyPathProvided { + return types.ErrCertificateMissing // Both paths must be provided together or not at all } + // If paths are not provided, temporary certificates will be generated automatically + // Validate token configuration if config.Token.AccessTokenLifetime <= 0 { return types.ErrInvalidTokenLifetime diff --git a/openapi/oauth/oauth_test.go b/openapi/oauth/oauth_test.go index 3152f6f9..900c0a62 100644 --- a/openapi/oauth/oauth_test.go +++ b/openapi/oauth/oauth_test.go @@ -28,6 +28,12 @@ import ( // $YAO_SOURCE_ROOT is the root directory of the Yao source code. // source $YAO_SOURCE_ROOT/env.local.sh +// Test certificate paths - created once and reused across tests +var ( + testCertPath string + testKeyPath string +) + // Store configuration for parameterized tests type StoreConfig struct { Name string @@ -311,14 +317,19 @@ func setupOAuthTestEnvironment(t *testing.T) (*Service, store.Store, store.Store // Create cache cache := getLRUCache(t) + // Create test certificates once if not already created + if testCertPath == "" || testKeyPath == "" { + createTestCertificatesOnce(t) + } + // Create OAuth service configuration oauthConfig := &Config{ Store: mainStore, Cache: cache, Signing: types.SigningConfig{ SigningAlgorithm: "RS256", - SigningCertPath: "/tmp/test-cert.pem", - SigningKeyPath: "/tmp/test-key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, Token: types.TokenConfig{ AccessTokenLifetime: time.Hour, @@ -498,6 +509,26 @@ func cleanupTestData(t *testing.T, service *Service) { } } +// createTestCertificatesOnce creates temporary certificate pair for all tests +func createTestCertificatesOnce(t *testing.T) { + // Generate temporary certificates (auto-generate with empty paths) + config := &types.SigningConfig{ + SigningAlgorithm: "RS256", + SigningCertPath: "", + SigningKeyPath: "", + } + + certs, err := LoadSigningCertificates(config) + if err != nil { + t.Fatalf("Failed to generate test certificates: %v", err) + } + + testCertPath = certs.SigningCertPath + testKeyPath = certs.SigningKeyPath + + t.Logf("Created test certificates: cert=%s, key=%s", testCertPath, testKeyPath) +} + // Helper functions for store setup (same as in other test files) func getMongoStore(t *testing.T) store.Store { @@ -561,9 +592,27 @@ func getStoreConfigs() []StoreConfig { func TestMain(m *testing.M) { // Run tests code := m.Run() + + // Cleanup global test certificates + cleanupGlobalTestCertificates() + os.Exit(code) } +// cleanupGlobalTestCertificates removes global test certificates +func cleanupGlobalTestCertificates() { + if testCertPath != "" { + if _, err := os.Stat(testCertPath); !os.IsNotExist(err) { + os.Remove(testCertPath) + } + } + if testKeyPath != "" { + if _, err := os.Stat(testKeyPath); !os.IsNotExist(err) { + os.Remove(testKeyPath) + } + } +} + func TestNewService(t *testing.T) { t.Run("create service with valid config", func(t *testing.T) { service, _, _, cleanup := setupOAuthTestEnvironment(t) @@ -601,8 +650,8 @@ func TestNewService(t *testing.T) { config := &Config{ Store: store, Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, } @@ -645,8 +694,8 @@ func TestConfigDefaults(t *testing.T) { Store: store, IssuerURL: "https://test.example.com", Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, } @@ -725,8 +774,8 @@ func TestProviderInitialization(t *testing.T) { Cache: cache, IssuerURL: "https://test.example.com", Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, } @@ -753,8 +802,8 @@ func TestProviderInitialization(t *testing.T) { Cache: cache, IssuerURL: "https://test.example.com", Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, } @@ -772,8 +821,8 @@ func TestProviderInitialization(t *testing.T) { ClientProvider: customClientProvider, IssuerURL: "https://test.example.com", Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, } @@ -829,8 +878,8 @@ func TestConfigValidation(t *testing.T) { Store: getBadgerStore(t), IssuerURL: "https://test.example.com", Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, Token: types.TokenConfig{ AccessTokenLifetime: time.Hour, @@ -839,7 +888,12 @@ func TestConfigValidation(t *testing.T) { }, } - err := validateConfig(config) + // Set defaults first (like NewService does) + err := setConfigDefaults(config) + assert.NoError(t, err) + + // Then validate + err = validateConfig(config) assert.NoError(t, err) }) @@ -847,12 +901,17 @@ func TestConfigValidation(t *testing.T) { config := &Config{ IssuerURL: "https://test.example.com", Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, } - err := validateConfig(config) + // Set defaults first (like NewService does) + err := setConfigDefaults(config) + assert.NoError(t, err) + + // Then validate + err = validateConfig(config) assert.Error(t, err) assert.Equal(t, types.ErrStoreMissing, err) }) @@ -861,24 +920,37 @@ func TestConfigValidation(t *testing.T) { config := &Config{ Store: getBadgerStore(t), Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, } - err := validateConfig(config) + // Set defaults first (like NewService does) + err := setConfigDefaults(config) + assert.NoError(t, err) + + // Then validate + err = validateConfig(config) assert.Error(t, err) assert.Equal(t, types.ErrIssuerURLMissing, err) }) - t.Run("missing certificate configuration", func(t *testing.T) { + t.Run("partial certificate configuration", func(t *testing.T) { config := &Config{ Store: getBadgerStore(t), IssuerURL: "https://test.example.com", - Signing: types.SigningConfig{}, + Signing: types.SigningConfig{ + SigningCertPath: testCertPath, // Only cert path, missing key path + SigningKeyPath: "", + }, } - err := validateConfig(config) + // Set defaults first (like NewService does) + err := setConfigDefaults(config) + assert.NoError(t, err) + + // Then validate + err = validateConfig(config) assert.Error(t, err) assert.Equal(t, types.ErrCertificateMissing, err) }) @@ -888,15 +960,20 @@ func TestConfigValidation(t *testing.T) { Store: getBadgerStore(t), IssuerURL: "https://test.example.com", Signing: types.SigningConfig{ - SigningCertPath: "/tmp/cert.pem", - SigningKeyPath: "/tmp/key.pem", + SigningCertPath: testCertPath, + SigningKeyPath: testKeyPath, }, Token: types.TokenConfig{ - AccessTokenLifetime: -1 * time.Hour, + AccessTokenLifetime: -1 * time.Hour, // Invalid negative lifetime }, } - err := validateConfig(config) + // Set defaults first (like NewService does) + err := setConfigDefaults(config) + assert.NoError(t, err) + + // Then validate + err = validateConfig(config) assert.Error(t, err) assert.Equal(t, types.ErrInvalidTokenLifetime, err) }) diff --git a/openapi/oauth/signing.go b/openapi/oauth/signing.go new file mode 100644 index 00000000..2644218f --- /dev/null +++ b/openapi/oauth/signing.go @@ -0,0 +1,429 @@ +package oauth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" + + "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/share" +) + +// SigningCertificates holds the loaded signing certificates and keys +type SigningCertificates struct { + // Primary signing certificate and private key + SigningCert *x509.Certificate `json:"-"` + SigningKey interface{} `json:"-"` // *rsa.PrivateKey, *ecdsa.PrivateKey, etc. + SigningKeyPair *tls.Certificate `json:"-"` + + // Verification certificates for token validation + VerificationCerts []*x509.Certificate `json:"-"` + + // mTLS CA certificate for client validation + MTLSClientCACert *x509.Certificate `json:"-"` + + // Signing algorithm + Algorithm string `json:"algorithm"` + + // Certificate paths for reference + SigningCertPath string `json:"signing_cert_path"` + SigningKeyPath string `json:"signing_key_path"` + + // Auto-generated flag + IsAutoGenerated bool `json:"is_auto_generated"` +} + +// LoadSigningCertificates loads or generates signing certificates based on configuration +func LoadSigningCertificates(config *types.SigningConfig) (*SigningCertificates, error) { + certs := &SigningCertificates{ + Algorithm: config.SigningAlgorithm, + } + + // Check if certificate files exist + signingCertExists := fileExists(config.SigningCertPath) + signingKeyExists := fileExists(config.SigningKeyPath) + + // If both files exist, try to load them + if signingCertExists && signingKeyExists { + err := loadExistingCertificates(certs, config) + if err != nil { + // If loading fails, log warning and generate new certificates + fmt.Printf("Warning: Failed to load existing certificates (%v), generating new temporary certificates\n", err) + return generateTemporaryCertificates(config) + } + return certs, nil + } + + // If certificates don't exist, generate temporary ones + return generateTemporaryCertificates(config) +} + +// loadExistingCertificates loads certificates from the configured paths +func loadExistingCertificates(certs *SigningCertificates, config *types.SigningConfig) error { + // Load signing certificate + certPEM, err := os.ReadFile(config.SigningCertPath) + if err != nil { + return fmt.Errorf("failed to read signing certificate: %w", err) + } + + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return fmt.Errorf("failed to decode signing certificate PEM") + } + + signingCert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse signing certificate: %w", err) + } + + // Load signing key + keyPEM, err := os.ReadFile(config.SigningKeyPath) + if err != nil { + return fmt.Errorf("failed to read signing key: %w", err) + } + + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return fmt.Errorf("failed to decode signing key PEM") + } + + var signingKey interface{} + if config.SigningKeyPassword != "" { + // Decrypt encrypted key + keyBytes, err := x509.DecryptPEMBlock(keyBlock, []byte(config.SigningKeyPassword)) + if err != nil { + return fmt.Errorf("failed to decrypt signing key: %w", err) + } + signingKey, err = parsePrivateKey(keyBytes) + if err != nil { + return fmt.Errorf("failed to parse decrypted signing key: %w", err) + } + } else { + // Parse unencrypted key + var err error + signingKey, err = parsePrivateKey(keyBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse signing key: %w", err) + } + } + + // Create TLS certificate pair + keyPair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return fmt.Errorf("failed to create key pair: %w", err) + } + + certs.SigningCert = signingCert + certs.SigningKey = signingKey + certs.SigningKeyPair = &keyPair + certs.SigningCertPath = config.SigningCertPath + certs.SigningKeyPath = config.SigningKeyPath + certs.IsAutoGenerated = false + + // Load verification certificates if configured + if len(config.VerificationCerts) > 0 { + verificationCerts, err := loadVerificationCertificates(config.VerificationCerts) + if err != nil { + return fmt.Errorf("failed to load verification certificates: %w", err) + } + certs.VerificationCerts = verificationCerts + } + + // Load mTLS CA certificate if configured + if config.MTLSClientCACertPath != "" { + mtlsCACert, err := loadCertificateFromFile(config.MTLSClientCACertPath) + if err != nil { + return fmt.Errorf("failed to load mTLS CA certificate: %w", err) + } + certs.MTLSClientCACert = mtlsCACert + } + + return nil +} + +// generateTemporaryCertificates generates temporary self-signed certificates +func generateTemporaryCertificates(config *types.SigningConfig) (*SigningCertificates, error) { + // Generate RSA private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %w", err) + } + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{share.App.Name}, + Country: []string{"US"}, + Province: []string{""}, + Locality: []string{""}, + StreetAddress: []string{""}, + PostalCode: []string{""}, + CommonName: fmt.Sprintf("%s OAuth Signing Certificate", share.App.Name), + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), // Valid for 1 year + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + // Generate certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + // Parse the generated certificate + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, fmt.Errorf("failed to parse generated certificate: %w", err) + } + + // Create PEM blocks + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal private key: %w", err) + } + + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: keyDER, + }) + + // Create TLS certificate pair + keyPair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("failed to create key pair: %w", err) + } + + // Determine system directory for storing temporary certificates + systemDir := getSystemCertificateDirectory() + + // Create directory if it doesn't exist + if err := os.MkdirAll(systemDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create system certificate directory: %w", err) + } + + // Generate unique filenames + timestamp := time.Now().Format("20060102150405") + certPath := filepath.Join(systemDir, fmt.Sprintf("oauth_signing_cert_%s.pem", timestamp)) + keyPath := filepath.Join(systemDir, fmt.Sprintf("oauth_signing_key_%s.pem", timestamp)) + + // Save certificate and key to system directory + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + return nil, fmt.Errorf("failed to write certificate file: %w", err) + } + + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + return nil, fmt.Errorf("failed to write key file: %w", err) + } + + fmt.Printf("Generated temporary OAuth signing certificate at: %s\n", certPath) + fmt.Printf("Generated temporary OAuth signing key at: %s\n", keyPath) + + return &SigningCertificates{ + SigningCert: cert, + SigningKey: privateKey, + SigningKeyPair: &keyPair, + Algorithm: config.SigningAlgorithm, + SigningCertPath: certPath, + SigningKeyPath: keyPath, + IsAutoGenerated: true, + }, nil +} + +// loadVerificationCertificates loads additional verification certificates +func loadVerificationCertificates(certPaths []string) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + + for _, certPath := range certPaths { + cert, err := loadCertificateFromFile(certPath) + if err != nil { + return nil, fmt.Errorf("failed to load verification certificate %s: %w", certPath, err) + } + certs = append(certs, cert) + } + + return certs, nil +} + +// loadCertificateFromFile loads a certificate from a PEM file +func loadCertificateFromFile(certPath string) (*x509.Certificate, error) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, fmt.Errorf("failed to read certificate file: %w", err) + } + + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return nil, fmt.Errorf("failed to decode certificate PEM") + } + + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + return cert, nil +} + +// parsePrivateKey parses a private key from DER bytes +func parsePrivateKey(der []byte) (interface{}, error) { + // Try PKCS#8 first + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + return key, nil + } + + // Try PKCS#1 RSA + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + + // Try EC private key + if key, err := x509.ParseECPrivateKey(der); err == nil { + return key, nil + } + + return nil, fmt.Errorf("unable to parse private key") +} + +// fileExists checks if a file exists +func fileExists(filename string) bool { + _, err := os.Stat(filename) + return !os.IsNotExist(err) +} + +// getSystemCertificateDirectory returns the appropriate system directory for storing certificates +func getSystemCertificateDirectory() string { + // Use different directories based on the operating system + homeDir, err := os.UserHomeDir() + if err != nil { + // Fallback to temporary directory + return filepath.Join(os.TempDir(), "yao-oauth-certs") + } + + // Create a hidden directory in user's home + return filepath.Join(homeDir, ".yao", "oauth", "certs") +} + +// ValidateCertificate validates a certificate for OAuth signing +func (c *SigningCertificates) ValidateCertificate() error { + if c.SigningCert == nil { + return fmt.Errorf("signing certificate is nil") + } + + // Check if certificate is expired + now := time.Now() + if now.Before(c.SigningCert.NotBefore) { + return fmt.Errorf("signing certificate is not yet valid") + } + + if now.After(c.SigningCert.NotAfter) { + return fmt.Errorf("signing certificate has expired") + } + + // Check if certificate has appropriate key usage + if c.SigningCert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return fmt.Errorf("signing certificate does not have digital signature key usage") + } + + return nil +} + +// GetPublicKey returns the public key from the signing certificate +func (c *SigningCertificates) GetPublicKey() interface{} { + if c.SigningCert == nil { + return nil + } + return c.SigningCert.PublicKey +} + +// GetKeyID returns a key identifier for the signing certificate +func (c *SigningCertificates) GetKeyID() string { + if c.SigningCert == nil { + return "" + } + + // Use the certificate's serial number as key ID + return c.SigningCert.SerialNumber.String() +} + +// CleanupTemporaryCertificates removes auto-generated temporary certificates +func (c *SigningCertificates) CleanupTemporaryCertificates() error { + if !c.IsAutoGenerated { + return nil // Don't delete user-provided certificates + } + + var errs []error + + if c.SigningCertPath != "" && fileExists(c.SigningCertPath) { + if err := os.Remove(c.SigningCertPath); err != nil { + errs = append(errs, fmt.Errorf("failed to remove certificate file %s: %w", c.SigningCertPath, err)) + } + } + + if c.SigningKeyPath != "" && fileExists(c.SigningKeyPath) { + if err := os.Remove(c.SigningKeyPath); err != nil { + errs = append(errs, fmt.Errorf("failed to remove key file %s: %w", c.SigningKeyPath, err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("cleanup errors: %v", errs) + } + + return nil +} + +// Service signing certificate methods + +// GetSigningCertificates returns the signing certificates for the service +func (s *Service) GetSigningCertificates() *SigningCertificates { + return s.signingCerts +} + +// GetSigningKey returns the signing private key +func (s *Service) GetSigningKey() interface{} { + if s.signingCerts == nil { + return nil + } + return s.signingCerts.SigningKey +} + +// GetSigningCertificate returns the signing certificate +func (s *Service) GetSigningCertificate() interface{} { + if s.signingCerts == nil { + return nil + } + return s.signingCerts.SigningCert +} + +// GetSigningAlgorithm returns the signing algorithm +func (s *Service) GetSigningAlgorithm() string { + if s.signingCerts == nil { + return "RS256" // default + } + return s.signingCerts.Algorithm +} + +// GetKeyID returns the key identifier for JWT token signing +func (s *Service) GetKeyID() string { + if s.signingCerts == nil { + return "" + } + return s.signingCerts.GetKeyID() +} diff --git a/openapi/oauth/types/errors.go b/openapi/oauth/types/errors.go index 29ba5d20..e4bee7ed 100644 --- a/openapi/oauth/types/errors.go +++ b/openapi/oauth/types/errors.go @@ -5,7 +5,7 @@ var ( ErrInvalidConfiguration = &ErrorResponse{Code: "invalid_configuration", ErrorDescription: "Invalid OAuth service configuration"} ErrStoreMissing = &ErrorResponse{Code: "store_missing", ErrorDescription: "Store is required for OAuth service"} ErrIssuerURLMissing = &ErrorResponse{Code: "issuer_url_missing", ErrorDescription: "Issuer URL is required for OAuth service"} - ErrCertificateMissing = &ErrorResponse{Code: "certificate_missing", ErrorDescription: "JWT signing certificate and key are required"} + ErrCertificateMissing = &ErrorResponse{Code: "certificate_missing", ErrorDescription: "JWT signing certificate and key paths must both be provided or both be empty"} ErrInvalidTokenLifetime = &ErrorResponse{Code: "invalid_token_lifetime", ErrorDescription: "Token lifetime must be greater than 0"} ErrPKCEConfigurationInvalid = &ErrorResponse{Code: "pkce_configuration_invalid", ErrorDescription: "PKCE configuration is invalid"} )