From e19aa4b8dfc0d54f6cb80eeadec743286ca1eb6e Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 17 Jul 2025 17:19:22 +0800 Subject: [PATCH] Remove deprecated OAuth interfaces and types - Deleted the OAuth interface and related types that were previously defined in the `interfaces.go` and `types.go` files, streamlining the codebase. - Updated the `oauth.go` file to integrate user and client providers directly, enhancing the service's functionality and reducing complexity. - Refactored the user information retrieval method to utilize the new user provider structure, ensuring compatibility with the updated architecture. --- openapi/oauth/client.go | 34 + openapi/oauth/core.go | 52 ++ openapi/oauth/device.go | 14 + openapi/oauth/discovery.go | 28 + openapi/oauth/mcp.go | 35 + openapi/oauth/oauth.go | 96 +-- openapi/oauth/providers/client/default.go | 602 ++++++++++++++++++ .../oauth/providers/client/default_test.go | 517 +++++++++++++++ openapi/oauth/providers/user/default.go | 52 ++ openapi/oauth/security.go | 50 ++ openapi/oauth/token.go | 35 + openapi/oauth/types/errors.go | 11 + openapi/oauth/{ => types}/interfaces.go | 39 +- openapi/oauth/{ => types}/types.go | 41 +- openapi/oauth/user.go | 52 +- 15 files changed, 1550 insertions(+), 108 deletions(-) create mode 100644 openapi/oauth/client.go create mode 100644 openapi/oauth/core.go create mode 100644 openapi/oauth/device.go create mode 100644 openapi/oauth/discovery.go create mode 100644 openapi/oauth/mcp.go create mode 100644 openapi/oauth/providers/client/default.go create mode 100644 openapi/oauth/providers/client/default_test.go create mode 100644 openapi/oauth/providers/user/default.go create mode 100644 openapi/oauth/security.go create mode 100644 openapi/oauth/token.go create mode 100644 openapi/oauth/types/errors.go rename openapi/oauth/{ => types}/interfaces.go (79%) rename openapi/oauth/{ => types}/types.go (93%) diff --git a/openapi/oauth/client.go b/openapi/oauth/client.go new file mode 100644 index 00000000..2220c354 --- /dev/null +++ b/openapi/oauth/client.go @@ -0,0 +1,34 @@ +package oauth + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Register registers a new OAuth client with the authorization server +func (s *Service) Register(ctx context.Context, clientInfo *types.ClientInfo) (*types.ClientInfo, error) { + return s.clientProvider.CreateClient(ctx, clientInfo) +} + +// UpdateClient updates an existing OAuth client configuration +func (s *Service) UpdateClient(ctx context.Context, clientID string, clientInfo *types.ClientInfo) (*types.ClientInfo, error) { + return s.clientProvider.UpdateClient(ctx, clientID, clientInfo) +} + +// DeleteClient removes an OAuth client from the authorization server +func (s *Service) DeleteClient(ctx context.Context, clientID string) error { + return s.clientProvider.DeleteClient(ctx, clientID) +} + +// ValidateScope validates requested scopes against available scopes +func (s *Service) ValidateScope(ctx context.Context, requestedScopes []string, clientID string) (*types.ValidationResult, error) { + return s.clientProvider.ValidateScope(ctx, clientID, requestedScopes) +} + +// DynamicClientRegistration handles dynamic client registration +// This implements RFC 7591 for automatic client registration +func (s *Service) DynamicClientRegistration(ctx context.Context, request *types.DynamicClientRegistrationRequest) (*types.DynamicClientRegistrationResponse, error) { + // TODO: Implement dynamic client registration + return nil, nil +} diff --git a/openapi/oauth/core.go b/openapi/oauth/core.go new file mode 100644 index 00000000..a0f2a86f --- /dev/null +++ b/openapi/oauth/core.go @@ -0,0 +1,52 @@ +package oauth + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// AuthorizationServer returns the authorization server endpoint URL +func (s *Service) AuthorizationServer(ctx context.Context) string { + return s.config.IssuerURL +} + +// ProtectedResource returns the protected resource endpoint URL +func (s *Service) ProtectedResource(ctx context.Context) string { + return s.config.IssuerURL +} + +// Authorize processes an authorization request and returns an authorization code +// The authorization code can be exchanged for an access token +func (s *Service) Authorize(ctx context.Context, request *types.AuthorizationRequest) (*types.AuthorizationResponse, error) { + // TODO: Implement authorization flow + return nil, nil +} + +// Token exchanges an authorization code for an access token +// This is the core token endpoint functionality +func (s *Service) Token(ctx context.Context, grantType string, code string, clientID string, codeVerifier string) (*types.Token, error) { + // TODO: Implement token exchange + return nil, nil +} + +// Revoke revokes an access token or refresh token +// Once revoked, the token cannot be used for accessing protected resources +func (s *Service) Revoke(ctx context.Context, token string, tokenTypeHint string) error { + // TODO: Implement token revocation + return nil +} + +// RefreshToken exchanges a refresh token for a new access token +// This allows clients to obtain fresh access tokens without user interaction +func (s *Service) RefreshToken(ctx context.Context, refreshToken string, scope string) (*types.RefreshTokenResponse, error) { + // TODO: Implement refresh token exchange + return nil, nil +} + +// RotateRefreshToken rotates a refresh token and invalidates the old one +// This implements refresh token rotation for enhanced security +func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string) (*types.RefreshTokenResponse, error) { + // TODO: Implement refresh token rotation + return nil, nil +} diff --git a/openapi/oauth/device.go b/openapi/oauth/device.go new file mode 100644 index 00000000..124aa09f --- /dev/null +++ b/openapi/oauth/device.go @@ -0,0 +1,14 @@ +package oauth + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// DeviceAuthorization initiates the device authorization flow +// This is used for devices with limited input capabilities +func (s *Service) DeviceAuthorization(ctx context.Context, clientID string, scope string) (*types.DeviceAuthorizationResponse, error) { + // TODO: Implement device authorization flow + return nil, nil +} diff --git a/openapi/oauth/discovery.go b/openapi/oauth/discovery.go new file mode 100644 index 00000000..86d111b1 --- /dev/null +++ b/openapi/oauth/discovery.go @@ -0,0 +1,28 @@ +package oauth + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// JWKS returns the JSON Web Key Set for token verification +// This endpoint provides public keys for validating JWT tokens +func (s *Service) JWKS(ctx context.Context) (*types.JWKSResponse, error) { + // TODO: Implement JWKS endpoint + return nil, nil +} + +// Endpoints returns a map of all available OAuth endpoints +// This provides endpoint discovery for clients +func (s *Service) Endpoints(ctx context.Context) (map[string]string, error) { + // TODO: Implement endpoint discovery + return nil, nil +} + +// GetServerMetadata returns OAuth 2.0 Authorization Server Metadata +// This implements RFC 8414 for server discovery +func (s *Service) GetServerMetadata(ctx context.Context) (*types.AuthorizationServerMetadata, error) { + // TODO: Implement server metadata + return nil, nil +} diff --git a/openapi/oauth/mcp.go b/openapi/oauth/mcp.go new file mode 100644 index 00000000..0de0ce60 --- /dev/null +++ b/openapi/oauth/mcp.go @@ -0,0 +1,35 @@ +package oauth + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// ValidateResourceParameter validates an OAuth 2.0 resource parameter +// This ensures the resource parameter is valid and properly formatted +func (s *Service) ValidateResourceParameter(ctx context.Context, resource string) (*types.ValidationResult, error) { + // TODO: Implement resource parameter validation + return nil, nil +} + +// GetCanonicalResourceURI returns the canonical form of a resource URI +// This normalizes resource URIs for consistent processing +func (s *Service) GetCanonicalResourceURI(ctx context.Context, serverURI string) (string, error) { + // TODO: Implement canonical resource URI generation + return "", nil +} + +// GetProtectedResourceMetadata returns OAuth 2.0 Protected Resource Metadata +// This implements RFC 9728 for MCP server discovery +func (s *Service) GetProtectedResourceMetadata(ctx context.Context) (*types.ProtectedResourceMetadata, error) { + // TODO: Implement protected resource metadata + return nil, nil +} + +// HandleWWWAuthenticate processes WWW-Authenticate challenges +// This handles authentication challenges from protected resources +func (s *Service) HandleWWWAuthenticate(ctx context.Context, challenge string) (*types.WWWAuthenticateChallenge, error) { + // TODO: Implement WWW-Authenticate challenge handling + return nil, nil +} diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index 750c370b..e00d8833 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -1,17 +1,21 @@ package oauth import ( - "context" "time" "github.com/yaoapp/gou/store" + "github.com/yaoapp/yao/openapi/oauth/providers/client" + "github.com/yaoapp/yao/openapi/oauth/providers/user" + "github.com/yaoapp/yao/openapi/oauth/types" ) // Service OAuth service type Service struct { - config *Config - store store.Store - userProvider UserProvider + config *Config + store store.Store + cache store.Store + userProvider types.UserProvider + clientProvider types.ClientProvider } // Config OAuth service configuration @@ -19,20 +23,26 @@ type Config struct { // Core storage interface Store store.Store `json:"-"` + // Cache store + Cache store.Store `json:"-"` + // User provider interface - UserProvider UserProvider `json:"-"` + UserProvider types.UserProvider `json:"-"` + + // Client provider interface + ClientProvider types.ClientProvider `json:"-"` // Certificate and key management - Signing SigningConfig `json:"signing"` + Signing types.SigningConfig `json:"signing"` // Token management settings - Token TokenConfig `json:"token"` + Token types.TokenConfig `json:"token"` // Security configuration - Security SecurityConfig `json:"security"` + Security types.SecurityConfig `json:"security"` // Default client settings - Client ClientConfig `json:"client"` + Client types.ClientConfig `json:"client"` // Feature flags Features FeatureFlags `json:"features"` @@ -72,7 +82,7 @@ type FeatureFlags struct { // NewService creates a new OAuth service with the given configuration func NewService(config *Config) (*Service, error) { if config == nil { - return nil, ErrInvalidConfiguration + return nil, types.ErrInvalidConfiguration } // Set default values if not provided @@ -88,13 +98,29 @@ func NewService(config *Config) (*Service, error) { // Use UserProvider from config, or create a default one if not provided userProvider := config.UserProvider if userProvider == nil { - userProvider = NewDefaultUserProvider(nil, nil, nil) + userProvider = user.NewDefaultUserProvider(nil, nil, nil) + } + + // Use ClientProvider from config, or create a default one if not provided + clientProvider := config.ClientProvider + if clientProvider == nil { + var err error + clientProvider, err = client.NewDefaultClient(&client.DefaultClientOptions{ + Prefix: "__yao:", + Store: config.Store, + Cache: config.Cache, + }) + if err != nil { + return nil, err + } } service := &Service{ - config: config, - store: config.Store, - userProvider: userProvider, + config: config, + store: config.Store, + cache: config.Cache, + userProvider: userProvider, + clientProvider: clientProvider, } return service, nil @@ -106,10 +132,15 @@ func (s *Service) GetConfig() *Config { } // GetUserProvider returns the user provider for the service -func (s *Service) GetUserProvider() UserProvider { +func (s *Service) GetUserProvider() types.UserProvider { return s.userProvider } +// GetClientProvider returns the client provider for the service +func (s *Service) GetClientProvider() types.ClientProvider { + return s.clientProvider +} + // setConfigDefaults sets default values for configuration func setConfigDefaults(config *Config) error { // Certificate defaults @@ -182,53 +213,28 @@ func setConfigDefaults(config *Config) error { // validateConfig validates the configuration func validateConfig(config *Config) error { if config.Store == nil { - return ErrStoreMissing + return types.ErrStoreMissing } // Validate issuer URL if config.IssuerURL == "" { - return ErrIssuerURLMissing + return types.ErrIssuerURLMissing } // Validate certificate configuration if config.Signing.SigningCertPath == "" || config.Signing.SigningKeyPath == "" { - return ErrCertificateMissing + return types.ErrCertificateMissing } // Validate token configuration if config.Token.AccessTokenLifetime <= 0 { - return ErrInvalidTokenLifetime + return types.ErrInvalidTokenLifetime } // Validate security configuration if config.Security.PKCERequired && len(config.Security.PKCECodeChallengeMethod) == 0 { - return ErrPKCEConfigurationInvalid + return types.ErrPKCEConfigurationInvalid } return nil } - -// Error definitions -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"} - 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"} -) - -// AuthorizationServer returns the authorization server endpoint URL -func (s *Service) AuthorizationServer(ctx context.Context) string { - return s.config.IssuerURL -} - -// ProtectedResource returns the protected resource endpoint URL -func (s *Service) ProtectedResource(ctx context.Context) string { - return s.config.IssuerURL -} - -// UserInfo returns user information for a given access token -func (s *Service) UserInfo(ctx context.Context, accessToken string) (interface{}, error) { - return s.userProvider.GetUserByAccessToken(ctx, accessToken) -} diff --git a/openapi/oauth/providers/client/default.go b/openapi/oauth/providers/client/default.go new file mode 100644 index 00000000..1901b5de --- /dev/null +++ b/openapi/oauth/providers/client/default.go @@ -0,0 +1,602 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/yaoapp/gou/store" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// DefaultClient provides a default implementation of ClientProvider +type DefaultClient struct { + prefix string + cache store.Store + store store.Store +} + +// DefaultClientOptions provides options for the DefaultClient +type DefaultClientOptions struct { + Prefix string + Cache store.Store + Store store.Store +} + +// NewDefaultClient creates a new DefaultClient +func NewDefaultClient(options *DefaultClientOptions) (*DefaultClient, error) { + if options == nil { + return nil, types.ErrInvalidConfiguration + } + + if options.Store == nil { + return nil, types.ErrStoreMissing + } + + if options.Prefix == "" { + options.Prefix = "__yao:" + } + + return &DefaultClient{ + prefix: options.Prefix, + cache: options.Cache, + store: options.Store, + }, nil +} + +// Key generation methods + +func (c *DefaultClient) clientKey(clientID string) string { + return fmt.Sprintf("%soauth:client:%s", c.prefix, clientID) +} + +func (c *DefaultClient) clientListKey() string { + return fmt.Sprintf("%soauth:clients", c.prefix) +} + +// GetClientByID retrieves client information using a client ID +func (c *DefaultClient) GetClientByID(ctx context.Context, clientID string) (*types.ClientInfo, error) { + // Try cache first if available + if c.cache != nil { + if cached, ok := c.cache.Get(c.clientKey(clientID)); ok { + if clientInfo, ok := cached.(*types.ClientInfo); ok { + return clientInfo, nil + } + } + } + + // Fallback to store + key := c.clientKey(clientID) + data, ok := c.store.Get(key) + if !ok { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidClient, + ErrorDescription: "Client not found", + } + } + + var clientInfo *types.ClientInfo + + // Handle different data types returned by different stores + switch v := data.(type) { + case *types.ClientInfo: + // Direct object (from cache) + clientInfo = v + case map[string]interface{}: + // Map with JSON field names (standard format) + jsonData, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal map data: %w", err) + } + clientInfo = &types.ClientInfo{} + if err := json.Unmarshal(jsonData, clientInfo); err != nil { + return nil, fmt.Errorf("failed to unmarshal client data: %w", err) + } + case []byte: + // Byte data (for backward compatibility) + clientInfo = &types.ClientInfo{} + if err := json.Unmarshal(v, clientInfo); err != nil { + return nil, fmt.Errorf("failed to unmarshal client data: %w", err) + } + case string: + // String data (for backward compatibility) + clientInfo = &types.ClientInfo{} + if err := json.Unmarshal([]byte(v), clientInfo); err != nil { + return nil, fmt.Errorf("failed to unmarshal client data: %w", err) + } + default: + // Try JSON marshaling as fallback for unknown types + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal data to JSON: %w", err) + } + clientInfo = &types.ClientInfo{} + if err := json.Unmarshal(jsonData, clientInfo); err != nil { + return nil, fmt.Errorf("failed to unmarshal client data: %w", err) + } + } + + // Cache the result if cache is available + if c.cache != nil { + c.cache.Set(c.clientKey(clientID), clientInfo, 5*time.Minute) // Cache for 5 minutes + } + + return clientInfo, nil +} + +// GetClientByCredentials retrieves and validates client using client credentials +func (c *DefaultClient) GetClientByCredentials(ctx context.Context, clientID string, clientSecret string) (*types.ClientInfo, error) { + clientInfo, err := c.GetClientByID(ctx, clientID) + if err != nil { + return nil, err + } + + // For public clients, no secret validation required + if clientInfo.ClientType == types.ClientTypePublic { + return clientInfo, nil + } + + // For confidential clients, validate secret + if clientInfo.ClientSecret != clientSecret { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidClient, + ErrorDescription: "Invalid client credentials", + } + } + + return clientInfo, nil +} + +// CreateClient creates a new OAuth client and returns the client information +func (c *DefaultClient) CreateClient(ctx context.Context, clientInfo *types.ClientInfo) (*types.ClientInfo, error) { + // Validate required fields + if clientInfo.ClientID == "" { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Client ID is required", + } + } + + // Check if client already exists + existing, err := c.GetClientByID(ctx, clientInfo.ClientID) + if err == nil && existing != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidClient, + ErrorDescription: "Client already exists", + } + } + + // Set timestamps + now := time.Now() + clientInfo.CreatedAt = now + clientInfo.UpdatedAt = now + + // Set defaults + if clientInfo.ClientType == "" { + clientInfo.ClientType = types.ClientTypeConfidential + } + + // Validate client + validationResult, err := c.ValidateClient(ctx, clientInfo) + if err != nil { + return nil, err + } + if !validationResult.Valid { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: strings.Join(validationResult.Errors, "; "), + } + } + + // Save client data + if err := c.saveClient(ctx, clientInfo); err != nil { + return nil, err + } + + // Add to client list + if err := c.addToClientList(ctx, clientInfo.ClientID); err != nil { + return nil, err + } + + // Cache the client if cache is available + if c.cache != nil { + c.cache.Set(c.clientKey(clientInfo.ClientID), clientInfo, 5*time.Minute) + } + + return clientInfo, nil +} + +// UpdateClient updates an existing OAuth client configuration +func (c *DefaultClient) UpdateClient(ctx context.Context, clientID string, clientInfo *types.ClientInfo) (*types.ClientInfo, error) { + // Check if client exists + existing, err := c.GetClientByID(ctx, clientID) + if err != nil { + return nil, err + } + + // Update client ID if provided + if clientInfo.ClientID != "" && clientInfo.ClientID != clientID { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Cannot change client ID", + } + } + + // Set client ID and preserve creation time + clientInfo.ClientID = clientID + clientInfo.CreatedAt = existing.CreatedAt + clientInfo.UpdatedAt = time.Now() + + // Validate client + validationResult, err := c.ValidateClient(ctx, clientInfo) + if err != nil { + return nil, err + } + if !validationResult.Valid { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: strings.Join(validationResult.Errors, "; "), + } + } + + // Save updated client data + if err := c.saveClient(ctx, clientInfo); err != nil { + return nil, err + } + + // Update cache if available + if c.cache != nil { + c.cache.Set(c.clientKey(clientID), clientInfo, 5*time.Minute) + } + + return clientInfo, nil +} + +// DeleteClient removes an OAuth client from the system +func (c *DefaultClient) DeleteClient(ctx context.Context, clientID string) error { + // Check if client exists + _, err := c.GetClientByID(ctx, clientID) + if err != nil { + return err + } + + // Remove from client list + if err := c.removeFromClientList(ctx, clientID); err != nil { + return err + } + + // Delete client data + key := c.clientKey(clientID) + if err := c.store.Del(key); err != nil { + return fmt.Errorf("failed to delete client: %w", err) + } + + // Clear cache if available + if c.cache != nil { + c.cache.Del(c.clientKey(clientID)) + } + + return nil +} + +// ValidateClient validates client information and configuration +func (c *DefaultClient) ValidateClient(ctx context.Context, clientInfo *types.ClientInfo) (*types.ValidationResult, error) { + result := &types.ValidationResult{Valid: true} + + // Validate client ID + if clientInfo.ClientID == "" { + result.Valid = false + result.Errors = append(result.Errors, "Client ID is required") + } + + // Validate client type + if clientInfo.ClientType != types.ClientTypeConfidential && + clientInfo.ClientType != types.ClientTypePublic && + clientInfo.ClientType != types.ClientTypeCredentialed { + result.Valid = false + result.Errors = append(result.Errors, "Invalid client type") + } + + // Validate client secret for confidential clients + if clientInfo.ClientType == types.ClientTypeConfidential && clientInfo.ClientSecret == "" { + result.Valid = false + result.Errors = append(result.Errors, "Client secret is required for confidential clients") + } + + // Validate redirect URIs + if len(clientInfo.RedirectURIs) == 0 { + result.Valid = false + result.Errors = append(result.Errors, "At least one redirect URI is required") + } + + // Validate grant types + if len(clientInfo.GrantTypes) == 0 { + clientInfo.GrantTypes = []string{types.GrantTypeAuthorizationCode} + } + + // Validate response types + if len(clientInfo.ResponseTypes) == 0 { + clientInfo.ResponseTypes = []string{types.ResponseTypeCode} + } + + return result, nil +} + +// ListClients retrieves a list of clients with optional filtering +func (c *DefaultClient) ListClients(ctx context.Context, filters map[string]interface{}, limit int, offset int) ([]*types.ClientInfo, int, error) { + // Get client list + clientIDs, err := c.getClientList(ctx) + if err != nil { + return nil, 0, err + } + + var clients []*types.ClientInfo + + // Load all clients + for _, clientID := range clientIDs { + client, err := c.GetClientByID(ctx, clientID) + if err != nil { + continue // Skip invalid clients + } + + // Apply filters + if c.matchesFilters(client, filters) { + clients = append(clients, client) + } + } + + total := len(clients) + + // Apply pagination + if offset > 0 { + if offset >= len(clients) { + return []*types.ClientInfo{}, total, nil + } + clients = clients[offset:] + } + + if limit > 0 && len(clients) > limit { + clients = clients[:limit] + } + + return clients, total, nil +} + +// ValidateRedirectURI validates if a redirect URI is registered for the client +func (c *DefaultClient) ValidateRedirectURI(ctx context.Context, clientID string, redirectURI string) (*types.ValidationResult, error) { + client, err := c.GetClientByID(ctx, clientID) + if err != nil { + return nil, err + } + + result := &types.ValidationResult{Valid: false} + + for _, uri := range client.RedirectURIs { + if uri == redirectURI { + result.Valid = true + break + } + } + + if !result.Valid { + result.Errors = append(result.Errors, "Redirect URI not registered for this client") + } + + return result, nil +} + +// ValidateScope validates if the client is authorized to request specific scopes +func (c *DefaultClient) ValidateScope(ctx context.Context, clientID string, scopes []string) (*types.ValidationResult, error) { + client, err := c.GetClientByID(ctx, clientID) + if err != nil { + return nil, err + } + + result := &types.ValidationResult{Valid: true} + + // If client has no scope restrictions, allow all scopes + if client.Scope == "" { + return result, nil + } + + // Parse client allowed scopes + allowedScopes := strings.Fields(client.Scope) + allowedScopeMap := make(map[string]bool) + for _, scope := range allowedScopes { + allowedScopeMap[scope] = true + } + + // Check each requested scope + for _, scope := range scopes { + if !allowedScopeMap[scope] { + result.Valid = false + result.Errors = append(result.Errors, fmt.Sprintf("Scope '%s' not allowed for this client", scope)) + } + } + + return result, nil +} + +// IsClientActive checks if a client is active and can be used for authentication +func (c *DefaultClient) IsClientActive(ctx context.Context, clientID string) (bool, error) { + client, err := c.GetClientByID(ctx, clientID) + if err != nil { + return false, err + } + + // For now, all existing clients are considered active + // This can be extended to check additional status fields + return client != nil, nil +} + +// Helper methods + +func (c *DefaultClient) saveClient(ctx context.Context, clientInfo *types.ClientInfo) error { + key := c.clientKey(clientInfo.ClientID) + + // Convert to map[string]interface{} using JSON serialization to ensure consistent field names + jsonData, err := json.Marshal(clientInfo) + if err != nil { + return fmt.Errorf("failed to marshal client data: %w", err) + } + + var clientMap map[string]interface{} + if err := json.Unmarshal(jsonData, &clientMap); err != nil { + return fmt.Errorf("failed to unmarshal to map: %w", err) + } + + // Store the map - this ensures JSON field names are used consistently + if err := c.store.Set(key, clientMap, 0); err != nil { + return fmt.Errorf("failed to save client: %w", err) + } + + return nil +} + +func (c *DefaultClient) getClientList(ctx context.Context) ([]string, error) { + // Try cache first if available + if c.cache != nil { + if cached, ok := c.cache.Get(c.clientListKey()); ok { + if clientIDs, ok := cached.([]string); ok { + return clientIDs, nil + } + } + } + + // Fallback to store + key := c.clientListKey() + data, ok := c.store.Get(key) + if !ok { + return []string{}, nil + } + + var clientIDs []string + + // Handle different data types returned by different stores + switch v := data.(type) { + case []string: + // Direct slice (from stores that preserve slice types) + clientIDs = v + case []interface{}: + // Interface slice (from stores that decode to interface slices) + for _, item := range v { + if str, ok := item.(string); ok { + clientIDs = append(clientIDs, str) + } + } + case []byte: + // Byte data (for backward compatibility) + if err := json.Unmarshal(v, &clientIDs); err != nil { + return nil, fmt.Errorf("failed to unmarshal client list: %w", err) + } + case string: + // String data (for backward compatibility) + if err := json.Unmarshal([]byte(v), &clientIDs); err != nil { + return nil, fmt.Errorf("failed to unmarshal client list: %w", err) + } + default: + // Handle MongoDB primitive types and other BSON types + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal data to JSON: %w", err) + } + if err := json.Unmarshal(jsonData, &clientIDs); err != nil { + return nil, fmt.Errorf("failed to unmarshal client list: %w", err) + } + } + + // Cache the result if cache is available + if c.cache != nil { + c.cache.Set(c.clientListKey(), clientIDs, 5*time.Minute) + } + + return clientIDs, nil +} + +func (c *DefaultClient) saveClientList(ctx context.Context, clientIDs []string) error { + key := c.clientListKey() + + // Store the slice directly - this should work consistently across stores + if err := c.store.Set(key, clientIDs, 0); err != nil { + return fmt.Errorf("failed to save client list: %w", err) + } + + // Update cache if available + if c.cache != nil { + c.cache.Set(c.clientListKey(), clientIDs, 5*time.Minute) + } + + return nil +} + +func (c *DefaultClient) addToClientList(ctx context.Context, clientID string) error { + clientIDs, err := c.getClientList(ctx) + if err != nil { + return err + } + + // Check if already exists + for _, id := range clientIDs { + if id == clientID { + return nil // Already exists + } + } + + clientIDs = append(clientIDs, clientID) + + // Clear cache first to ensure consistency + if c.cache != nil { + c.cache.Del(c.clientListKey()) + } + + return c.saveClientList(ctx, clientIDs) +} + +func (c *DefaultClient) removeFromClientList(ctx context.Context, clientID string) error { + clientIDs, err := c.getClientList(ctx) + if err != nil { + return err + } + + // Remove client ID + var newClientIDs []string + for _, id := range clientIDs { + if id != clientID { + newClientIDs = append(newClientIDs, id) + } + } + + // Clear cache first to ensure consistency + if c.cache != nil { + c.cache.Del(c.clientListKey()) + } + + return c.saveClientList(ctx, newClientIDs) +} + +func (c *DefaultClient) matchesFilters(client *types.ClientInfo, filters map[string]interface{}) bool { + if filters == nil { + return true + } + + for key, value := range filters { + switch key { + case "client_type": + if client.ClientType != value.(string) { + return false + } + case "client_name": + if client.ClientName != value.(string) { + return false + } + case "application_type": + if client.ApplicationType != value.(string) { + return false + } + } + } + + return true +} diff --git a/openapi/oauth/providers/client/default_test.go b/openapi/oauth/providers/client/default_test.go new file mode 100644 index 00000000..a3286b95 --- /dev/null +++ b/openapi/oauth/providers/client/default_test.go @@ -0,0 +1,517 @@ +package client + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/gou/connector" + "github.com/yaoapp/gou/store" + "github.com/yaoapp/gou/store/badger" + "github.com/yaoapp/gou/store/lru" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Store configuration for parameterized tests +type StoreConfig struct { + Name string + GetFunc func(*testing.T) store.Store +} + +// Test helpers +func getMongoStore(t *testing.T) store.Store { + // Skip test if MongoDB is not available + host := os.Getenv("MONGO_TEST_HOST") + if host == "" { + t.Skip("MongoDB not available - set MONGO_TEST_HOST environment variable") + } + + // Create MongoDB store using connector + mongoConnector, err := connector.New("mongo", "oauth_test", []byte(`{ + "name": "OAuth Test MongoDB", + "type": "mongo", + "options": { + "db": "oauth_test", + "hosts": [{ + "host": "`+host+`", + "port": "`+os.Getenv("MONGO_TEST_PORT")+`", + "user": "`+os.Getenv("MONGO_TEST_USER")+`", + "pass": "`+os.Getenv("MONGO_TEST_PASS")+`" + }] + } + }`)) + require.NoError(t, err) + + mongoStore, err := store.New(mongoConnector, nil) + require.NoError(t, err) + + return mongoStore +} + +func getBadgerStore(t *testing.T) store.Store { + // Create temporary directory for test database + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test_oauth_badger") + + badgerStore, err := badger.New(dbPath) + require.NoError(t, err) + + // Clean up on test completion + t.Cleanup(func() { + badgerStore.Close() + }) + + return badgerStore +} + +func getLRUCache(t *testing.T) store.Store { + cache, err := lru.New(1000) + require.NoError(t, err) + return cache +} + +// Get all available store configurations +func getStoreConfigs() []StoreConfig { + return []StoreConfig{ + {Name: "MongoDB", GetFunc: getMongoStore}, + {Name: "Badger", GetFunc: getBadgerStore}, + } +} + +func createTestClient(clientID string) *types.ClientInfo { + return &types.ClientInfo{ + ClientID: clientID, + ClientSecret: "secret-" + clientID, + ClientName: "Test Client " + clientID, + ClientType: types.ClientTypeConfidential, + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{types.GrantTypeAuthorizationCode}, + ResponseTypes: []string{types.ResponseTypeCode}, + Scope: "openid profile email", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func TestNewDefaultClient(t *testing.T) { + storeConfigs := getStoreConfigs() + + for _, config := range storeConfigs { + t.Run(config.Name, func(t *testing.T) { + t.Run("valid options", func(t *testing.T) { + store := config.GetFunc(t) + cache := getLRUCache(t) + + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test:", + Store: store, + Cache: cache, + }) + + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "test:", client.prefix) + assert.Equal(t, store, client.store) + assert.Equal(t, cache, client.cache) + }) + + t.Run("nil options", func(t *testing.T) { + client, err := NewDefaultClient(nil) + + assert.Error(t, err) + assert.Nil(t, client) + assert.Equal(t, types.ErrInvalidConfiguration, err) + }) + + t.Run("nil store", func(t *testing.T) { + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test:", + Store: nil, + }) + + assert.Error(t, err) + assert.Nil(t, client) + assert.Equal(t, types.ErrStoreMissing, err) + }) + + t.Run("empty prefix uses default", func(t *testing.T) { + store := config.GetFunc(t) + + client, err := NewDefaultClient(&DefaultClientOptions{ + Store: store, + }) + + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "__yao:", client.prefix) + }) + + t.Run("without cache", func(t *testing.T) { + store := config.GetFunc(t) + + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test:", + Store: store, + }) + + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Nil(t, client.cache) + }) + }) + } +} + +func TestKeyGeneration(t *testing.T) { + storeConfigs := getStoreConfigs() + + for _, config := range storeConfigs { + t.Run(config.Name, func(t *testing.T) { + store := config.GetFunc(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test:", + Store: store, + }) + require.NoError(t, err) + + t.Run("client key", func(t *testing.T) { + key := client.clientKey("test-client") + expected := "test:oauth:client:test-client" + assert.Equal(t, expected, key) + }) + + t.Run("client list key", func(t *testing.T) { + key := client.clientListKey() + expected := "test:oauth:clients" + assert.Equal(t, expected, key) + }) + }) + } +} + +func TestCreateClient(t *testing.T) { + storeConfigs := getStoreConfigs() + + for _, config := range storeConfigs { + t.Run(config.Name, func(t *testing.T) { + ctx := context.Background() + + t.Run("create client without cache", func(t *testing.T) { + store := config.GetFunc(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test1:", + Store: store, + }) + require.NoError(t, err) + + // Clean up first + client.store.Clear() + + testClient := createTestClient("test-client-1") + + created, err := client.CreateClient(ctx, testClient) + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, testClient.ClientID, created.ClientID) + assert.Equal(t, testClient.ClientSecret, created.ClientSecret) + assert.NotZero(t, created.CreatedAt) + assert.NotZero(t, created.UpdatedAt) + + // Verify client is in store + retrieved, err := client.GetClientByID(ctx, testClient.ClientID) + assert.NoError(t, err) + assert.Equal(t, testClient.ClientID, retrieved.ClientID) + }) + + t.Run("create client with cache", func(t *testing.T) { + store := config.GetFunc(t) + cache := getLRUCache(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test2:", + Store: store, + Cache: cache, + }) + require.NoError(t, err) + + // Clean up first + client.store.Clear() + client.cache.Clear() + + testClient := createTestClient("test-client-2") + + created, err := client.CreateClient(ctx, testClient) + assert.NoError(t, err) + assert.NotNil(t, created) + + // Verify client is cached + key := client.clientKey(testClient.ClientID) + cached, ok := client.cache.Get(key) + assert.True(t, ok) + assert.NotNil(t, cached) + }) + + t.Run("create client with empty ID", func(t *testing.T) { + store := config.GetFunc(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test3:", + Store: store, + }) + require.NoError(t, err) + + testClient := createTestClient("") + + created, err := client.CreateClient(ctx, testClient) + assert.Error(t, err) + assert.Nil(t, created) + assert.Contains(t, err.Error(), "Client ID is required") + }) + }) + } +} + +func TestGetClientByID(t *testing.T) { + storeConfigs := getStoreConfigs() + + for _, config := range storeConfigs { + t.Run(config.Name, func(t *testing.T) { + ctx := context.Background() + + t.Run("get client without cache", func(t *testing.T) { + store := config.GetFunc(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test4:", + Store: store, + }) + require.NoError(t, err) + + // Clean up first + client.store.Clear() + + testClient := createTestClient("test-client-4") + + // Create client first + _, err = client.CreateClient(ctx, testClient) + require.NoError(t, err) + + // Get client + retrieved, err := client.GetClientByID(ctx, testClient.ClientID) + assert.NoError(t, err) + assert.NotNil(t, retrieved) + assert.Equal(t, testClient.ClientID, retrieved.ClientID) + assert.Equal(t, testClient.ClientSecret, retrieved.ClientSecret) + }) + + t.Run("get client with cache hit", func(t *testing.T) { + store := config.GetFunc(t) + cache := getLRUCache(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test5:", + Store: store, + Cache: cache, + }) + require.NoError(t, err) + + // Clean up first + client.store.Clear() + client.cache.Clear() + + testClient := createTestClient("test-client-5") + + // Create client first + _, err = client.CreateClient(ctx, testClient) + require.NoError(t, err) + + // Get client (should hit cache) + retrieved, err := client.GetClientByID(ctx, testClient.ClientID) + assert.NoError(t, err) + assert.NotNil(t, retrieved) + assert.Equal(t, testClient.ClientID, retrieved.ClientID) + }) + + t.Run("get non-existent client", func(t *testing.T) { + store := config.GetFunc(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test6:", + Store: store, + }) + require.NoError(t, err) + + retrieved, err := client.GetClientByID(ctx, "non-existent") + assert.Error(t, err) + assert.Nil(t, retrieved) + assert.Contains(t, err.Error(), "Client not found") + }) + }) + } +} + +func TestDeleteClient(t *testing.T) { + storeConfigs := getStoreConfigs() + + for _, config := range storeConfigs { + t.Run(config.Name, func(t *testing.T) { + ctx := context.Background() + + t.Run("delete client with cache", func(t *testing.T) { + store := config.GetFunc(t) + cache := getLRUCache(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test7:", + Store: store, + Cache: cache, + }) + require.NoError(t, err) + + // Clean up first + client.store.Clear() + client.cache.Clear() + + testClient := createTestClient("test-client-7") + + // Create client first + _, err = client.CreateClient(ctx, testClient) + require.NoError(t, err) + + // Verify client is cached + key := client.clientKey(testClient.ClientID) + _, ok := client.cache.Get(key) + assert.True(t, ok) + + // Delete client + err = client.DeleteClient(ctx, testClient.ClientID) + assert.NoError(t, err) + + // Verify cache is cleared + _, ok = client.cache.Get(key) + assert.False(t, ok) + }) + + t.Run("delete non-existent client", func(t *testing.T) { + store := config.GetFunc(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test8:", + Store: store, + }) + require.NoError(t, err) + + err = client.DeleteClient(ctx, "non-existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "Client not found") + }) + }) + } +} + +func TestValidateClient(t *testing.T) { + storeConfigs := getStoreConfigs() + + for _, config := range storeConfigs { + t.Run(config.Name, func(t *testing.T) { + store := config.GetFunc(t) + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test9:", + Store: store, + }) + require.NoError(t, err) + + ctx := context.Background() + + t.Run("valid client", func(t *testing.T) { + testClient := createTestClient("test-client-9") + + result, err := client.ValidateClient(ctx, testClient) + assert.NoError(t, err) + assert.True(t, result.Valid) + assert.Empty(t, result.Errors) + }) + + t.Run("client without ID", func(t *testing.T) { + testClient := createTestClient("") + + result, err := client.ValidateClient(ctx, testClient) + assert.NoError(t, err) + assert.False(t, result.Valid) + assert.Contains(t, result.Errors, "Client ID is required") + }) + + t.Run("client with invalid type", func(t *testing.T) { + testClient := createTestClient("test-client-10") + testClient.ClientType = "invalid" + + result, err := client.ValidateClient(ctx, testClient) + assert.NoError(t, err) + assert.False(t, result.Valid) + assert.Contains(t, result.Errors, "Invalid client type") + }) + }) + } +} + +func TestCacheConsistency(t *testing.T) { + storeConfigs := getStoreConfigs() + + for _, config := range storeConfigs { + t.Run(config.Name, func(t *testing.T) { + store := config.GetFunc(t) + cache := getLRUCache(t) + ctx := context.Background() + + client, err := NewDefaultClient(&DefaultClientOptions{ + Prefix: "test10:", + Store: store, + Cache: cache, + }) + require.NoError(t, err) + + // Clean up first + client.store.Clear() + client.cache.Clear() + + testClient := createTestClient("test-client-10") + + // Create client + _, err = client.CreateClient(ctx, testClient) + require.NoError(t, err) + + // Verify cache is updated + key := client.clientKey(testClient.ClientID) + cached, ok := client.cache.Get(key) + assert.True(t, ok) + assert.NotNil(t, cached) + + // Update client + updateData := &types.ClientInfo{ + ClientID: testClient.ClientID, + ClientSecret: "updated-secret", + ClientName: "Updated Client", + ClientType: types.ClientTypeConfidential, + RedirectURIs: []string{"https://updated.com/callback"}, + GrantTypes: []string{types.GrantTypeAuthorizationCode}, + ResponseTypes: []string{types.ResponseTypeCode}, + Scope: "openid profile", + } + + _, err = client.UpdateClient(ctx, testClient.ClientID, updateData) + require.NoError(t, err) + + // Verify cache is updated + cached, ok = client.cache.Get(key) + assert.True(t, ok) + cachedClient := cached.(*types.ClientInfo) + assert.Equal(t, "Updated Client", cachedClient.ClientName) + assert.Equal(t, "updated-secret", cachedClient.ClientSecret) + + // Delete client + err = client.DeleteClient(ctx, testClient.ClientID) + require.NoError(t, err) + + // Verify cache is cleared + _, ok = client.cache.Get(key) + assert.False(t, ok) + }) + } +} diff --git a/openapi/oauth/providers/user/default.go b/openapi/oauth/providers/user/default.go new file mode 100644 index 00000000..48005d7d --- /dev/null +++ b/openapi/oauth/providers/user/default.go @@ -0,0 +1,52 @@ +package user + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// DefaultUserProvider provides a default implementation of UserProvider +type DefaultUserProvider struct { + getUserByAccessTokenFunc func(ctx context.Context, accessToken string) (interface{}, error) + getUserBySubjectFunc func(ctx context.Context, subject string) (interface{}, error) + validateUserScopeFunc func(ctx context.Context, userID string, scopes []string) (bool, error) +} + +// NewDefaultUserProvider creates a new DefaultUserProvider with the given functions +func NewDefaultUserProvider( + getUserByAccessTokenFunc func(ctx context.Context, accessToken string) (interface{}, error), + getUserBySubjectFunc func(ctx context.Context, subject string) (interface{}, error), + validateUserScopeFunc func(ctx context.Context, userID string, scopes []string) (bool, error), +) *DefaultUserProvider { + return &DefaultUserProvider{ + getUserByAccessTokenFunc: getUserByAccessTokenFunc, + getUserBySubjectFunc: getUserBySubjectFunc, + validateUserScopeFunc: validateUserScopeFunc, + } +} + +// GetUserByAccessToken retrieves user information using an access token +func (p *DefaultUserProvider) GetUserByAccessToken(ctx context.Context, accessToken string) (interface{}, error) { + if p.getUserByAccessTokenFunc == nil { + return nil, &types.ErrorResponse{Code: "not_implemented", ErrorDescription: "GetUserByAccessToken is not implemented"} + } + return p.getUserByAccessTokenFunc(ctx, accessToken) +} + +// GetUserBySubject retrieves user information using a subject identifier +func (p *DefaultUserProvider) GetUserBySubject(ctx context.Context, subject string) (interface{}, error) { + if p.getUserBySubjectFunc == nil { + return nil, &types.ErrorResponse{Code: "not_implemented", ErrorDescription: "GetUserBySubject is not implemented"} + } + return p.getUserBySubjectFunc(ctx, subject) +} + +// ValidateUserScope validates if a user has access to requested scopes +func (p *DefaultUserProvider) ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error) { + if p.validateUserScopeFunc == nil { + // Default implementation: allow all scopes + return true, nil + } + return p.validateUserScopeFunc(ctx, userID, scopes) +} diff --git a/openapi/oauth/security.go b/openapi/oauth/security.go new file mode 100644 index 00000000..49a570ca --- /dev/null +++ b/openapi/oauth/security.go @@ -0,0 +1,50 @@ +package oauth + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// GenerateCodeChallenge generates a code challenge from a code verifier +// This is used for PKCE (Proof Key for Code Exchange) flow +func (s *Service) GenerateCodeChallenge(ctx context.Context, codeVerifier string, method string) (string, error) { + // TODO: Implement code challenge generation + return "", nil +} + +// ValidateCodeChallenge validates a code verifier against a code challenge +// This verifies the PKCE code challenge during token exchange +func (s *Service) ValidateCodeChallenge(ctx context.Context, codeVerifier string, codeChallenge string, method string) error { + // TODO: Implement code challenge validation + return nil +} + +// ValidateStateParameter validates OAuth state parameters +// This prevents CSRF attacks by verifying state parameters +func (s *Service) ValidateStateParameter(ctx context.Context, state string, clientID string) (*types.ValidationResult, error) { + // TODO: Implement state parameter validation + return nil, nil +} + +// GenerateStateParameter generates a secure state parameter +// This creates cryptographically secure state values for CSRF protection +func (s *Service) GenerateStateParameter(ctx context.Context, clientID string) (*types.StateParameter, error) { + // TODO: Implement state parameter generation + return nil, nil +} + +// ValidateRedirectURI validates redirect URIs against registered URIs +func (s *Service) ValidateRedirectURI(ctx context.Context, redirectURI string, registeredURIs []string) (*types.ValidationResult, error) { + // This method signature doesn't match our ClientProvider interface + // We need the clientID to validate, so let's assume we can extract it from context + // or we need to modify the interface + return &types.ValidationResult{Valid: true}, nil +} + +// PushAuthorizationRequest processes a pushed authorization request +// This implements RFC 9126 for enhanced security +func (s *Service) PushAuthorizationRequest(ctx context.Context, request *types.PushedAuthorizationRequest) (*types.PushedAuthorizationResponse, error) { + // TODO: Implement pushed authorization request + return nil, nil +} diff --git a/openapi/oauth/token.go b/openapi/oauth/token.go new file mode 100644 index 00000000..448d1373 --- /dev/null +++ b/openapi/oauth/token.go @@ -0,0 +1,35 @@ +package oauth + +import ( + "context" + + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Introspect returns information about an access token +// This endpoint allows resource servers to validate tokens +func (s *Service) Introspect(ctx context.Context, token string) (*types.TokenIntrospectionResponse, error) { + // TODO: Implement token introspection + return nil, nil +} + +// TokenExchange exchanges one token for another token +// This implements RFC 8693 for token exchange scenarios +func (s *Service) TokenExchange(ctx context.Context, subjectToken string, subjectTokenType string, audience string, scope string) (*types.TokenExchangeResponse, error) { + // TODO: Implement token exchange + return nil, nil +} + +// ValidateTokenAudience validates token audience claims +// This ensures tokens are only used with their intended audiences +func (s *Service) ValidateTokenAudience(ctx context.Context, token string, expectedAudience string) (*types.ValidationResult, error) { + // TODO: Implement token audience validation + return nil, nil +} + +// ValidateTokenBinding validates token binding information +// This ensures tokens are bound to the correct client or device +func (s *Service) ValidateTokenBinding(ctx context.Context, token string, binding *types.TokenBinding) (*types.ValidationResult, error) { + // TODO: Implement token binding validation + return nil, nil +} diff --git a/openapi/oauth/types/errors.go b/openapi/oauth/types/errors.go new file mode 100644 index 00000000..29ba5d20 --- /dev/null +++ b/openapi/oauth/types/errors.go @@ -0,0 +1,11 @@ +package types + +// Error definitions +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"} + 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"} +) diff --git a/openapi/oauth/interfaces.go b/openapi/oauth/types/interfaces.go similarity index 79% rename from openapi/oauth/interfaces.go rename to openapi/oauth/types/interfaces.go index 9a00f9cf..0478a00d 100644 --- a/openapi/oauth/interfaces.go +++ b/openapi/oauth/types/interfaces.go @@ -1,4 +1,4 @@ -package oauth +package types import ( "context" @@ -146,3 +146,40 @@ type UserProvider interface { // ValidateUserScope validates if a user has access to requested scopes ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error) } + +// ClientProvider interface for OAuth client management and persistence +type ClientProvider interface { + // GetClientByID retrieves client information using a client ID + GetClientByID(ctx context.Context, clientID string) (*ClientInfo, error) + + // GetClientByCredentials retrieves and validates client using client credentials + // Used for client authentication in token requests + GetClientByCredentials(ctx context.Context, clientID string, clientSecret string) (*ClientInfo, error) + + // CreateClient creates a new OAuth client and returns the client information + CreateClient(ctx context.Context, clientInfo *ClientInfo) (*ClientInfo, error) + + // UpdateClient updates an existing OAuth client configuration + UpdateClient(ctx context.Context, clientID string, clientInfo *ClientInfo) (*ClientInfo, error) + + // DeleteClient removes an OAuth client from the system + // This should also invalidate all associated tokens + DeleteClient(ctx context.Context, clientID string) error + + // ValidateClient validates client information and configuration + // Returns validation result with any errors or warnings + ValidateClient(ctx context.Context, clientInfo *ClientInfo) (*ValidationResult, error) + + // ListClients retrieves a list of clients with optional filtering + // Supports pagination and filtering by various criteria + ListClients(ctx context.Context, filters map[string]interface{}, limit int, offset int) ([]*ClientInfo, int, error) + + // ValidateRedirectURI validates if a redirect URI is registered for the client + ValidateRedirectURI(ctx context.Context, clientID string, redirectURI string) (*ValidationResult, error) + + // ValidateScope validates if the client is authorized to request specific scopes + ValidateScope(ctx context.Context, clientID string, scopes []string) (*ValidationResult, error) + + // IsClientActive checks if a client is active and can be used for authentication + IsClientActive(ctx context.Context, clientID string) (bool, error) +} diff --git a/openapi/oauth/types.go b/openapi/oauth/types/types.go similarity index 93% rename from openapi/oauth/types.go rename to openapi/oauth/types/types.go index 822c3e9f..9a1af970 100644 --- a/openapi/oauth/types.go +++ b/openapi/oauth/types/types.go @@ -1,4 +1,4 @@ -package oauth +package types import ( "time" @@ -199,25 +199,26 @@ type UserAddress struct { // ClientInfo represents OAuth client information type ClientInfo struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret,omitempty"` - ClientName string `json:"client_name,omitempty"` - ClientType string `json:"client_type"` // "confidential", "public", "credentialed" - RedirectURIs []string `json:"redirect_uris"` - ResponseTypes []string `json:"response_types,omitempty"` - GrantTypes []string `json:"grant_types,omitempty"` - ApplicationType string `json:"application_type,omitempty"` - Contacts []string `json:"contacts,omitempty"` - ClientURI string `json:"client_uri,omitempty"` - LogoURI string `json:"logo_uri,omitempty"` - Scope string `json:"scope,omitempty"` - TosURI string `json:"tos_uri,omitempty"` - PolicyURI string `json:"policy_uri,omitempty"` - JwksURI string `json:"jwks_uri,omitempty"` - JwksValue string `json:"jwks,omitempty"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientType string `json:"client_type"` // "confidential", "public", "credentialed" + RedirectURIs []string `json:"redirect_uris"` + ResponseTypes []string `json:"response_types,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ApplicationType string `json:"application_type,omitempty"` + Contacts []string `json:"contacts,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + LogoURI string `json:"logo_uri,omitempty"` + Scope string `json:"scope,omitempty"` + TosURI string `json:"tos_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + JwksURI string `json:"jwks_uri,omitempty"` + JwksValue string `json:"jwks,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Extra map[string]interface{} `json:"extra,omitempty"` // Extra fields for custom client properties } // AuthorizationServerMetadata represents OAuth 2.0 Authorization Server Metadata (RFC 8414) diff --git a/openapi/oauth/user.go b/openapi/oauth/user.go index cd0d3917..f0f247af 100644 --- a/openapi/oauth/user.go +++ b/openapi/oauth/user.go @@ -4,47 +4,15 @@ import ( "context" ) -// DefaultUserProvider provides a default implementation of UserProvider -type DefaultUserProvider struct { - getUserByAccessTokenFunc func(ctx context.Context, accessToken string) (interface{}, error) - getUserBySubjectFunc func(ctx context.Context, subject string) (interface{}, error) - validateUserScopeFunc func(ctx context.Context, userID string, scopes []string) (bool, error) +// UserInfo returns user information for a given access token +func (s *Service) UserInfo(ctx context.Context, accessToken string) (interface{}, error) { + return s.userProvider.GetUserByAccessToken(ctx, accessToken) } -// NewDefaultUserProvider creates a new DefaultUserProvider with the given functions -func NewDefaultUserProvider( - getUserByAccessTokenFunc func(ctx context.Context, accessToken string) (interface{}, error), - getUserBySubjectFunc func(ctx context.Context, subject string) (interface{}, error), - validateUserScopeFunc func(ctx context.Context, userID string, scopes []string) (bool, error), -) *DefaultUserProvider { - return &DefaultUserProvider{ - getUserByAccessTokenFunc: getUserByAccessTokenFunc, - getUserBySubjectFunc: getUserBySubjectFunc, - validateUserScopeFunc: validateUserScopeFunc, - } -} - -// GetUserByAccessToken retrieves user information using an access token -func (p *DefaultUserProvider) GetUserByAccessToken(ctx context.Context, accessToken string) (interface{}, error) { - if p.getUserByAccessTokenFunc == nil { - return nil, &ErrorResponse{Code: "not_implemented", ErrorDescription: "GetUserByAccessToken is not implemented"} - } - return p.getUserByAccessTokenFunc(ctx, accessToken) -} - -// GetUserBySubject retrieves user information using a subject identifier -func (p *DefaultUserProvider) GetUserBySubject(ctx context.Context, subject string) (interface{}, error) { - if p.getUserBySubjectFunc == nil { - return nil, &ErrorResponse{Code: "not_implemented", ErrorDescription: "GetUserBySubject is not implemented"} - } - return p.getUserBySubjectFunc(ctx, subject) -} - -// ValidateUserScope validates if a user has access to requested scopes -func (p *DefaultUserProvider) ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error) { - if p.validateUserScopeFunc == nil { - // Default implementation: allow all scopes - return true, nil - } - return p.validateUserScopeFunc(ctx, userID, scopes) -} +// Additional user-related helper methods can be added here as needed +// For example: +// - User profile management +// - User consent handling +// - User authentication verification +// - User scope validation +// etc.