diff --git a/openapi/oauth/client.go b/openapi/oauth/client.go index 2220c354..fc24daf0 100644 --- a/openapi/oauth/client.go +++ b/openapi/oauth/client.go @@ -2,6 +2,11 @@ package oauth import ( "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/url" + "strings" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -29,6 +34,269 @@ func (s *Service) ValidateScope(ctx context.Context, requestedScopes []string, c // 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 + // Check if dynamic client registration is enabled + if !s.config.Features.DynamicClientRegistrationEnabled { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Dynamic client registration is not enabled", + } + } + + // Validate the request + if err := s.validateDynamicClientRegistrationRequest(request); err != nil { + return nil, err + } + + // Generate client ID and secret + clientID, err := s.generateClientID() + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate client ID", + } + } + + clientSecret := "" + // Determine client type based on token endpoint auth method + clientType := types.ClientTypePublic + if request.TokenEndpointAuthMethod == "" || + request.TokenEndpointAuthMethod == types.TokenEndpointAuthBasic || + request.TokenEndpointAuthMethod == types.TokenEndpointAuthPost || + request.TokenEndpointAuthMethod == types.TokenEndpointAuthJWT { + clientType = types.ClientTypeConfidential + clientSecret, err = s.generateClientSecret() + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate client secret", + } + } + } + + // Create client info from request + clientInfo := &types.ClientInfo{ + ClientID: clientID, + ClientSecret: clientSecret, + ClientName: request.ClientName, + ClientType: clientType, + RedirectURIs: request.RedirectURIs, + ResponseTypes: request.ResponseTypes, + GrantTypes: request.GrantTypes, + ApplicationType: request.ApplicationType, + Contacts: request.Contacts, + ClientURI: request.ClientURI, + LogoURI: request.LogoURI, + Scope: request.Scope, + TosURI: request.TosURI, + PolicyURI: request.PolicyURI, + JwksURI: request.JwksURI, + JwksValue: request.Jwks, + TokenEndpointAuthMethod: request.TokenEndpointAuthMethod, + } + + // Set defaults if not provided + if len(clientInfo.GrantTypes) == 0 { + clientInfo.GrantTypes = s.config.Client.DefaultGrantTypes + } + if len(clientInfo.ResponseTypes) == 0 { + clientInfo.ResponseTypes = s.config.Client.DefaultResponseTypes + } + if clientInfo.ApplicationType == "" { + clientInfo.ApplicationType = types.ApplicationTypeWeb + } + if clientInfo.TokenEndpointAuthMethod == "" { + clientInfo.TokenEndpointAuthMethod = s.config.Client.DefaultTokenEndpointAuthMethod + } + + // Create the client + createdClient, err := s.clientProvider.CreateClient(ctx, clientInfo) + if err != nil { + return nil, err + } + + // Create response + response := &types.DynamicClientRegistrationResponse{ + ClientID: createdClient.ClientID, + ClientSecret: createdClient.ClientSecret, + ClientIDIssuedAt: createdClient.CreatedAt.Unix(), + DynamicClientRegistrationRequest: request, + } + + // Set client secret expiration (0 means it never expires) + if s.config.Client.ClientSecretLifetime > 0 { + response.ClientSecretExpiresAt = createdClient.CreatedAt.Add(s.config.Client.ClientSecretLifetime).Unix() + } + + return response, nil +} + +// generateClientID generates a random client ID +func (s *Service) generateClientID() (string, error) { + length := s.config.Client.ClientIDLength + if length == 0 { + length = 32 + } + + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + // Use base64 URL encoding without padding + return strings.TrimRight(base64.URLEncoding.EncodeToString(bytes), "="), nil +} + +// generateClientSecret generates a random client secret +func (s *Service) generateClientSecret() (string, error) { + length := s.config.Client.ClientSecretLength + if length == 0 { + length = 64 + } + + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + // Use base64 URL encoding without padding + return strings.TrimRight(base64.URLEncoding.EncodeToString(bytes), "="), nil +} + +// validateDynamicClientRegistrationRequest validates the dynamic client registration request +func (s *Service) validateDynamicClientRegistrationRequest(request *types.DynamicClientRegistrationRequest) error { + // Validate redirect URIs + if len(request.RedirectURIs) == 0 { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "At least one redirect URI is required", + } + } + + // Validate redirect URI schemes and hosts + for _, uri := range request.RedirectURIs { + if err := s.validateRedirectURIForRegistration(uri); err != nil { + return err + } + } + + // Validate grant types + if len(request.GrantTypes) > 0 { + for _, grantType := range request.GrantTypes { + if !s.isValidGrantType(grantType) { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: fmt.Sprintf("Invalid grant type: %s", grantType), + } + } + } + } + + // Validate response types + if len(request.ResponseTypes) > 0 { + for _, responseType := range request.ResponseTypes { + if !s.isValidResponseType(responseType) { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: fmt.Sprintf("Invalid response type: %s", responseType), + } + } + } + } + + // Validate application type + if request.ApplicationType != "" { + if request.ApplicationType != types.ApplicationTypeWeb && request.ApplicationType != types.ApplicationTypeNative { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Invalid application type", + } + } + } + + return nil +} + +// validateRedirectURIForRegistration validates redirect URI for dynamic registration +func (s *Service) validateRedirectURIForRegistration(uri string) error { + parsedURI, err := url.Parse(uri) + if err != nil { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Invalid redirect URI format", + } + } + + // Check allowed schemes + if len(s.config.Client.AllowedRedirectURISchemes) > 0 { + schemeAllowed := false + for _, scheme := range s.config.Client.AllowedRedirectURISchemes { + if parsedURI.Scheme == scheme { + schemeAllowed = true + break + } + } + if !schemeAllowed { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: fmt.Sprintf("Redirect URI scheme '%s' is not allowed", parsedURI.Scheme), + } + } + } + + // Check allowed hosts + if len(s.config.Client.AllowedRedirectURIHosts) > 0 { + hostAllowed := false + for _, host := range s.config.Client.AllowedRedirectURIHosts { + if parsedURI.Host == host { + hostAllowed = true + break + } + } + if !hostAllowed { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: fmt.Sprintf("Redirect URI host '%s' is not allowed", parsedURI.Host), + } + } + } + + return nil +} + +// isValidGrantType checks if a grant type is valid +func (s *Service) isValidGrantType(grantType string) bool { + validGrantTypes := []string{ + types.GrantTypeAuthorizationCode, + types.GrantTypeRefreshToken, + types.GrantTypeClientCredentials, + types.GrantTypeDeviceCode, + types.GrantTypeTokenExchange, + } + + for _, valid := range validGrantTypes { + if grantType == valid { + return true + } + } + return false +} + +// isValidResponseType checks if a response type is valid +func (s *Service) isValidResponseType(responseType string) bool { + validResponseTypes := []string{ + types.ResponseTypeCode, + types.ResponseTypeToken, + types.ResponseTypeIDToken, + "code token", + "code id_token", + "token id_token", + "code token id_token", + } + + for _, valid := range validResponseTypes { + if responseType == valid { + return true + } + } + return false } diff --git a/openapi/oauth/core.go b/openapi/oauth/core.go index a0f2a86f..8c806cbf 100644 --- a/openapi/oauth/core.go +++ b/openapi/oauth/core.go @@ -2,6 +2,7 @@ package oauth import ( "context" + "strings" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -19,34 +20,386 @@ func (s *Service) ProtectedResource(ctx context.Context) string { // 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 + // Validate client + _, err := s.clientProvider.GetClientByID(ctx, request.ClientID) + if err != nil { + return &types.AuthorizationResponse{ + Error: types.ErrorInvalidClient, + ErrorDescription: "Invalid client", + }, nil + } + + // Validate redirect URI + if request.RedirectURI == "" { + return &types.AuthorizationResponse{ + Error: types.ErrorInvalidRequest, + ErrorDescription: "Missing redirect URI", + }, nil + } + + validationResult, err := s.clientProvider.ValidateRedirectURI(ctx, request.ClientID, request.RedirectURI) + if err != nil || !validationResult.Valid { + return &types.AuthorizationResponse{ + Error: types.ErrorInvalidRequest, + ErrorDescription: "Invalid redirect URI", + }, nil + } + + // Validate response type + if request.ResponseType == "" { + return &types.AuthorizationResponse{ + Error: types.ErrorInvalidRequest, + ErrorDescription: "Missing response type", + }, nil + } + + validResponseTypes := []string{"code", "token", "id_token"} + validResponseType := false + for _, validType := range validResponseTypes { + if request.ResponseType == validType || strings.Contains(request.ResponseType, validType) { + validResponseType = true + break + } + } + + if !validResponseType { + return &types.AuthorizationResponse{ + Error: types.ErrorUnsupportedResponseType, + ErrorDescription: "Unsupported response type", + }, nil + } + + // Validate scope if provided + if request.Scope != "" { + scopes := strings.Fields(request.Scope) + scopeValidation, err := s.clientProvider.ValidateScope(ctx, request.ClientID, scopes) + if err != nil || !scopeValidation.Valid { + return &types.AuthorizationResponse{ + Error: types.ErrorInvalidScope, + ErrorDescription: "Invalid scope", + }, nil + } + } + + // Generate authorization code + authCode, err := s.generateAuthorizationCode(request.ClientID, request.State) + if err != nil { + return &types.AuthorizationResponse{ + Error: types.ErrorServerError, + ErrorDescription: "Failed to generate authorization code", + }, nil + } + + response := &types.AuthorizationResponse{ + Code: authCode, + State: request.State, + } + + return response, 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 + // Validate client + client, err := s.clientProvider.GetClientByID(ctx, clientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidClient, + ErrorDescription: "Invalid client", + } + } + + // Validate grant type + switch grantType { + case types.GrantTypeAuthorizationCode: + return s.handleAuthorizationCodeGrant(ctx, client, code, codeVerifier) + case types.GrantTypeClientCredentials: + return s.handleClientCredentialsGrant(ctx, client) + case types.GrantTypeRefreshToken: + return s.handleRefreshTokenGrant(ctx, client, code) // code is refresh token in this case + default: + return nil, &types.ErrorResponse{ + Code: types.ErrorUnsupportedGrantType, + ErrorDescription: "Unsupported grant type", + } + } } // 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 + // Revoke token using user provider + if err := s.userProvider.RevokeToken(token); err != nil { + return &types.ErrorResponse{ + Code: types.ErrorInvalidToken, + ErrorDescription: "Failed to revoke token", + } + } + 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 + // Validate refresh token + if !s.userProvider.TokenExists(refreshToken) { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid refresh token", + } + } + + // Get token data + tokenData, err := s.userProvider.GetTokenData(refreshToken) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid refresh token", + } + } + + // Extract client ID from token data + clientID, ok := tokenData["client_id"].(string) + if !ok { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid token format", + } + } + + // Validate client + client, err := s.clientProvider.GetClientByID(ctx, clientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidClient, + ErrorDescription: "Invalid client", + } + } + + // Validate scope if provided + if scope != "" { + scopes := strings.Fields(scope) + scopeValidation, err := s.clientProvider.ValidateScope(ctx, client.ClientID, scopes) + if err != nil || !scopeValidation.Valid { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidScope, + ErrorDescription: "Invalid scope", + } + } + } + + // Generate new access token + newAccessToken, err := s.generateAccessToken(clientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate access token", + } + } + + response := &types.RefreshTokenResponse{ + AccessToken: newAccessToken, + TokenType: "Bearer", + ExpiresIn: 3600, // 1 hour + } + + // Include scope if provided + if scope != "" { + response.Scope = scope + } + + // Include refresh token if rotation is enabled + if s.config.Features.RefreshTokenRotationEnabled { + newRefreshToken, err := s.generateRefreshToken(clientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate refresh token", + } + } + response.RefreshToken = newRefreshToken + + // Revoke old refresh token + s.userProvider.RevokeToken(refreshToken) + } + + return response, 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 + // Check if refresh token rotation is enabled + if !s.config.Features.RefreshTokenRotationEnabled { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Refresh token rotation is not enabled", + } + } + + // Validate old token + if !s.userProvider.TokenExists(oldToken) { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid refresh token", + } + } + + // Get token data + tokenData, err := s.userProvider.GetTokenData(oldToken) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid refresh token", + } + } + + // Extract client ID from token data + clientID, ok := tokenData["client_id"].(string) + if !ok { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid token format", + } + } + + // Generate new tokens + newAccessToken, err := s.generateAccessToken(clientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate access token", + } + } + + newRefreshToken, err := s.generateRefreshToken(clientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate refresh token", + } + } + + // Revoke old token + err = s.userProvider.RevokeToken(oldToken) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to revoke old token", + } + } + + response := &types.RefreshTokenResponse{ + AccessToken: newAccessToken, + RefreshToken: newRefreshToken, + TokenType: "Bearer", + ExpiresIn: 3600, // 1 hour + } + + return response, nil +} + +// Helper methods for token grant types + +// handleAuthorizationCodeGrant handles authorization code grant +func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *types.ClientInfo, code string, codeVerifier string) (*types.Token, error) { + // TODO: Validate authorization code + // In a real implementation, this would validate the authorization code and extract user info + + // Generate access token + accessToken, err := s.generateAccessToken(client.ClientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate access token", + } + } + + token := &types.Token{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: 3600, // 1 hour + } + + // Generate refresh token if supported + if types.Contains(client.GrantTypes, types.GrantTypeRefreshToken) { + refreshToken, err := s.generateRefreshToken(client.ClientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate refresh token", + } + } + token.RefreshToken = refreshToken + } + + return token, nil +} + +// handleClientCredentialsGrant handles client credentials grant +func (s *Service) handleClientCredentialsGrant(ctx context.Context, client *types.ClientInfo) (*types.Token, error) { + // Generate access token + accessToken, err := s.generateAccessToken(client.ClientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate access token", + } + } + + token := &types.Token{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: 3600, // 1 hour + } + + return token, nil +} + +// handleRefreshTokenGrant handles refresh token grant +func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.ClientInfo, refreshToken string) (*types.Token, error) { + // Validate refresh token + if !s.userProvider.TokenExists(refreshToken) { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid refresh token", + } + } + + // Generate new access token + accessToken, err := s.generateAccessToken(client.ClientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate access token", + } + } + + token := &types.Token{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: 3600, // 1 hour + } + + // Include refresh token if rotation is enabled + if s.config.Features.RefreshTokenRotationEnabled { + newRefreshToken, err := s.generateRefreshToken(client.ClientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to generate refresh token", + } + } + token.RefreshToken = newRefreshToken + + // Revoke old refresh token + s.userProvider.RevokeToken(refreshToken) + } else { + // Reuse the same refresh token + token.RefreshToken = refreshToken + } + + return token, nil } diff --git a/openapi/oauth/discovery.go b/openapi/oauth/discovery.go index 86d111b1..71cfb216 100644 --- a/openapi/oauth/discovery.go +++ b/openapi/oauth/discovery.go @@ -2,6 +2,7 @@ package oauth import ( "context" + "fmt" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -9,20 +10,87 @@ import ( // 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 + // TODO: Implement JWKS endpoint - this requires certificate/key management + // For now, return empty JWKS + return &types.JWKSResponse{ + Keys: []types.JWK{}, + }, 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 + baseURL := s.config.IssuerURL + + endpoints := map[string]string{ + "authorization_endpoint": fmt.Sprintf("%s/oauth/authorize", baseURL), + "token_endpoint": fmt.Sprintf("%s/oauth/token", baseURL), + "userinfo_endpoint": fmt.Sprintf("%s/oauth/userinfo", baseURL), + "jwks_uri": fmt.Sprintf("%s/oauth/jwks", baseURL), + "registration_endpoint": fmt.Sprintf("%s/oauth/register", baseURL), + "introspection_endpoint": fmt.Sprintf("%s/oauth/introspect", baseURL), + "revocation_endpoint": fmt.Sprintf("%s/oauth/revoke", baseURL), + "device_authorization_endpoint": fmt.Sprintf("%s/oauth/device", baseURL), + "pushed_authorization_request_endpoint": fmt.Sprintf("%s/oauth/par", baseURL), + } + + return endpoints, 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 + endpoints, err := s.Endpoints(ctx) + if err != nil { + return nil, err + } + + metadata := &types.AuthorizationServerMetadata{ + Issuer: s.config.IssuerURL, + AuthorizationEndpoint: endpoints["authorization_endpoint"], + TokenEndpoint: endpoints["token_endpoint"], + UserinfoEndpoint: endpoints["userinfo_endpoint"], + JwksURI: endpoints["jwks_uri"], + RegistrationEndpoint: endpoints["registration_endpoint"], + ScopesSupported: []string{"openid", "profile", "email", "address", "phone", "offline_access"}, + ResponseTypesSupported: []string{"code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token"}, + ResponseModesSupported: []string{"query", "fragment", "form_post"}, + GrantTypesSupported: []string{"authorization_code", "client_credentials", "refresh_token"}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt"}, + TokenEndpointAuthSigningAlgValuesSupported: []string{"RS256", "HS256"}, + ServiceDocumentation: fmt.Sprintf("%s/docs", s.config.IssuerURL), + UILocalesSupported: []string{"en-US", "en-GB", "en-CA", "fr-FR", "fr-CA"}, + OpPolicyURI: fmt.Sprintf("%s/policy", s.config.IssuerURL), + OpTosURI: fmt.Sprintf("%s/terms", s.config.IssuerURL), + RevocationEndpoint: endpoints["revocation_endpoint"], + RevocationEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt"}, + IntrospectionEndpoint: endpoints["introspection_endpoint"], + IntrospectionEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt"}, + CodeChallengeMethodsSupported: []string{"plain", "S256"}, + DeviceAuthorizationEndpoint: endpoints["device_authorization_endpoint"], + PushedAuthorizationRequestEndpoint: endpoints["pushed_authorization_request_endpoint"], + RequirePushedAuthorizationRequests: false, + DPoPSigningAlgValuesSupported: []string{"RS256", "PS256", "ES256"}, + } + + // Add feature-specific endpoints and capabilities + if s.config.Features.DeviceFlowEnabled { + metadata.DeviceAuthorizationEndpoint = endpoints["device_authorization_endpoint"] + metadata.GrantTypesSupported = append(metadata.GrantTypesSupported, "urn:ietf:params:oauth:grant-type:device_code") + } + + if s.config.Features.TokenExchangeEnabled { + metadata.GrantTypesSupported = append(metadata.GrantTypesSupported, "urn:ietf:params:oauth:grant-type:token-exchange") + } + + if s.config.Features.PushedAuthorizationEnabled { + metadata.PushedAuthorizationRequestEndpoint = endpoints["pushed_authorization_request_endpoint"] + metadata.RequirePushedAuthorizationRequests = true + } + + if s.config.Features.DynamicClientRegistrationEnabled { + metadata.RegistrationEndpoint = endpoints["registration_endpoint"] + } + + return metadata, nil } diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index e148aa6c..6b5227b2 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -18,6 +18,7 @@ type Service struct { cache store.Store userProvider types.UserProvider clientProvider types.ClientProvider + prefix string } // Config OAuth service configuration @@ -98,10 +99,11 @@ func NewService(config *Config) (*Service, error) { } // Use UserProvider from config, or create a default one if not provided + keyPrefix := fmt.Sprintf("%s:", share.App.Prefix) userProvider := config.UserProvider if userProvider == nil { userProvider = user.NewDefaultUser(&user.DefaultUserOptions{ - Prefix: fmt.Sprintf("%s:", share.App.Prefix), + Prefix: keyPrefix, Model: "__yao.user", Cache: config.Cache, TokenStore: config.Store, @@ -113,7 +115,7 @@ func NewService(config *Config) (*Service, error) { if clientProvider == nil { var err error clientProvider, err = client.NewDefaultClient(&client.DefaultClientOptions{ - Prefix: fmt.Sprintf("%s:", share.App.Prefix), + Prefix: keyPrefix, Store: config.Store, Cache: config.Cache, }) @@ -128,6 +130,7 @@ func NewService(config *Config) (*Service, error) { cache: config.Cache, userProvider: userProvider, clientProvider: clientProvider, + prefix: keyPrefix, } return service, nil diff --git a/openapi/oauth/security.go b/openapi/oauth/security.go index 49a570ca..08015b3e 100644 --- a/openapi/oauth/security.go +++ b/openapi/oauth/security.go @@ -2,6 +2,12 @@ package oauth import ( "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "time" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -9,42 +15,234 @@ import ( // 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 + switch method { + case "S256": + // SHA256 hash of the code verifier + hash := sha256.Sum256([]byte(codeVerifier)) + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:]), nil + case "plain": + // Plain text code verifier (not recommended for production) + return codeVerifier, nil + default: + return "", fmt.Errorf("unsupported code challenge method: %s", method) + } } // 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 + expectedChallenge, err := s.GenerateCodeChallenge(ctx, codeVerifier, method) + if err != nil { + return err + } + + if expectedChallenge != codeChallenge { + return fmt.Errorf("code challenge verification failed") + } + 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 + result := &types.ValidationResult{Valid: false} + + // Get state parameter from store + stateKey := s.stateParameterKey(clientID, state) + + // Try cache first if available + if s.cache != nil { + if cached, ok := s.cache.Get(stateKey); ok { + if stateParam, ok := cached.(*types.StateParameter); ok { + // Check if state parameter is still valid + if time.Now().Before(stateParam.ExpiresAt) { + result.Valid = true + return result, nil + } + } + } + } + + // Try store + data, ok := s.store.Get(stateKey) + if !ok { + result.Errors = append(result.Errors, "State parameter not found") + return result, nil + } + + // Parse state parameter from store + stateParam, ok := data.(*types.StateParameter) + if !ok { + result.Errors = append(result.Errors, "Invalid state parameter format") + return result, nil + } + + // Check if state parameter is still valid + if time.Now().After(stateParam.ExpiresAt) { + result.Errors = append(result.Errors, "State parameter has expired") + return result, nil + } + + // Validate that the state parameter belongs to the client + if stateParam.ClientID != clientID { + result.Errors = append(result.Errors, "State parameter does not belong to this client") + return result, nil + } + + result.Valid = true + return result, 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 + // Generate random state value + length := s.config.Security.StateParameterLength + if length == 0 { + length = 32 + } + + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return nil, fmt.Errorf("failed to generate state parameter: %w", err) + } + + stateValue := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes) + + // Create state parameter + stateParam := &types.StateParameter{ + Value: stateValue, + ClientID: clientID, + ExpiresAt: time.Now().Add(s.config.Security.StateParameterLifetime), + } + + // Store state parameter + stateKey := s.stateParameterKey(clientID, stateValue) + + // Store in cache if available + if s.cache != nil { + s.cache.Set(stateKey, stateParam, s.config.Security.StateParameterLifetime) + } + + // Store in persistent store + if err := s.store.Set(stateKey, stateParam, s.config.Security.StateParameterLifetime); err != nil { + return nil, fmt.Errorf("failed to store state parameter: %w", err) + } + + return stateParam, 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 + // For now, we'll do a basic validation since we don't have a clientID + result := &types.ValidationResult{Valid: false} + + // If no registered URIs provided, cannot validate + if len(registeredURIs) == 0 { + result.Errors = append(result.Errors, "No registered URIs provided") + return result, nil + } + + // Check if redirect URI matches any registered URI + for _, uri := range registeredURIs { + if uri == redirectURI { + result.Valid = true + return result, nil + } + } + + result.Errors = append(result.Errors, "Redirect URI not found in registered URIs") + return result, nil +} + +// ValidateRedirectURIForClient validates redirect URIs for a specific client +func (s *Service) ValidateRedirectURIForClient(ctx context.Context, clientID string, redirectURI string) (*types.ValidationResult, error) { + return s.clientProvider.ValidateRedirectURI(ctx, clientID, redirectURI) } // 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 + // Validate client + _, err := s.clientProvider.GetClientByID(ctx, request.ClientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidClient, + ErrorDescription: "Invalid client", + } + } + + // Validate redirect URI + validationResult, err := s.clientProvider.ValidateRedirectURI(ctx, request.ClientID, request.RedirectURI) + if err != nil { + return nil, err + } + if !validationResult.Valid { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Invalid redirect URI", + } + } + + // Validate scopes if provided + if request.Scope != "" { + scopes := strings.Fields(request.Scope) + scopeValidation, err := s.clientProvider.ValidateScope(ctx, request.ClientID, scopes) + if err != nil { + return nil, err + } + if !scopeValidation.Valid { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidScope, + ErrorDescription: "Invalid scope", + } + } + } + + // Generate request URI + requestURI := s.generateRequestURI() + + // Store the request + requestKey := s.pushedAuthRequestKey(requestURI) + expiresIn := 600 // 10 minutes + + if s.cache != nil { + s.cache.Set(requestKey, request, time.Duration(expiresIn)*time.Second) + } + + if err := s.store.Set(requestKey, request, time.Duration(expiresIn)*time.Second); err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to store pushed authorization request", + } + } + + response := &types.PushedAuthorizationResponse{ + RequestURI: requestURI, + ExpiresIn: expiresIn, + } + + return response, nil +} + +// Helper methods + +// stateParameterKey generates a key for state parameter storage +func (s *Service) stateParameterKey(clientID string, state string) string { + return fmt.Sprintf("%soauth:state:%s:%s", s.prefix, clientID, state) +} + +// pushedAuthRequestKey generates a key for pushed authorization request storage +func (s *Service) pushedAuthRequestKey(requestURI string) string { + return fmt.Sprintf("%soauth:par:%s", s.prefix, requestURI) +} + +// generateRequestURI generates a request URI for pushed authorization requests +func (s *Service) generateRequestURI() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return fmt.Sprintf("urn:ietf:params:oauth:request_uri:%s", + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes)) } diff --git a/openapi/oauth/token.go b/openapi/oauth/token.go index 448d1373..58bb2059 100644 --- a/openapi/oauth/token.go +++ b/openapi/oauth/token.go @@ -2,6 +2,11 @@ package oauth import ( "context" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + "time" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -9,27 +14,255 @@ import ( // 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 + // Try to get token data from user provider + tokenData, err := s.userProvider.GetTokenData(token) + if err != nil { + return &types.TokenIntrospectionResponse{Active: false}, nil + } + + // Check if token exists and is valid + if tokenData == nil { + return &types.TokenIntrospectionResponse{Active: false}, nil + } + + // Extract token information + response := &types.TokenIntrospectionResponse{ + Active: true, + } + + // Extract standard fields from token data + if clientID, ok := tokenData["client_id"].(string); ok { + response.ClientID = clientID + } + if username, ok := tokenData["username"].(string); ok { + response.Username = username + } + if subject, ok := tokenData["sub"].(string); ok { + response.Subject = subject + } + if tokenType, ok := tokenData["token_type"].(string); ok { + response.TokenType = tokenType + } else { + response.TokenType = "Bearer" + } + if scope, ok := tokenData["scope"].(string); ok { + response.Scope = scope + } + if exp, ok := tokenData["exp"].(int64); ok { + response.ExpiresAt = exp + } + if iat, ok := tokenData["iat"].(int64); ok { + response.IssuedAt = iat + } + if nbf, ok := tokenData["nbf"].(int64); ok { + response.NotBefore = nbf + } + if aud, ok := tokenData["aud"].([]string); ok { + response.Audience = aud + } + + // Check if token is expired + if response.ExpiresAt > 0 && time.Now().Unix() > response.ExpiresAt { + response.Active = false + } + + return response, 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 + // Check if token exchange is enabled + if !s.config.Features.TokenExchangeEnabled { + return nil, &types.ErrorResponse{ + Code: types.ErrorUnsupportedGrantType, + ErrorDescription: "Token exchange is not enabled", + } + } + + // Validate subject token + introspectionResult, err := s.Introspect(ctx, subjectToken) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid subject token", + } + } + + if !introspectionResult.Active { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Subject token is not active", + } + } + + // Validate audience if provided + if audience != "" { + if err := s.validateAudience(audience); err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Invalid audience", + } + } + } + + // Validate scope if provided + if scope != "" { + scopes := strings.Fields(scope) + if introspectionResult.ClientID != "" { + scopeValidation, err := s.clientProvider.ValidateScope(ctx, introspectionResult.ClientID, scopes) + if err != nil { + return nil, err + } + if !scopeValidation.Valid { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidScope, + ErrorDescription: "Invalid scope", + } + } + } + } + + // Generate new token (placeholder implementation) + // In a real implementation, this would generate a JWT or opaque token + newToken := "exchanged_" + subjectToken[:20] + "_" + audience + + response := &types.TokenExchangeResponse{ + AccessToken: newToken, + IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + TokenType: "Bearer", + ExpiresIn: 3600, // 1 hour + } + + if scope != "" { + response.Scope = scope + } + + return response, 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 + result := &types.ValidationResult{Valid: false} + + // Get token introspection + introspectionResult, err := s.Introspect(ctx, token) + if err != nil { + return nil, err + } + + if !introspectionResult.Active { + result.Errors = append(result.Errors, "Token is not active") + return result, nil + } + + // Check audience + if len(introspectionResult.Audience) == 0 { + // If no audience is specified in token, allow access + result.Valid = true + return result, nil + } + + // Check if expected audience is in token audience list + for _, aud := range introspectionResult.Audience { + if aud == expectedAudience { + result.Valid = true + return result, nil + } + } + + result.Errors = append(result.Errors, "Token audience does not match expected audience") + return result, 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 + result := &types.ValidationResult{Valid: false} + + // Check if token binding is enabled + if !s.config.Features.TokenBindingEnabled { + result.Valid = true // If not enabled, always valid + return result, nil + } + + // Get token introspection + introspectionResult, err := s.Introspect(ctx, token) + if err != nil { + return nil, err + } + + if !introspectionResult.Active { + result.Errors = append(result.Errors, "Token is not active") + return result, nil + } + + // Validate binding type + switch binding.BindingType { + case types.TokenBindingTypeDPoP: + // DPoP binding validation would go here + result.Valid = true // Placeholder + case types.TokenBindingTypeMTLS: + // mTLS binding validation would go here + result.Valid = true // Placeholder + case types.TokenBindingTypeCertificate: + // Certificate binding validation would go here + result.Valid = true // Placeholder + default: + result.Errors = append(result.Errors, "Unknown token binding type") + return result, nil + } + + return result, nil +} + +// Helper methods + +// validateAudience validates if an audience is valid +func (s *Service) validateAudience(audience string) error { + // Basic audience validation + if audience == "" { + return &types.ErrorResponse{ + Code: types.ErrorInvalidRequest, + ErrorDescription: "Audience cannot be empty", + } + } + + // Add more sophisticated audience validation here + // For example, checking against a whitelist of valid audiences + + return nil +} + +// Token generation helper methods + +// generateAccessToken generates a new access token +func (s *Service) generateAccessToken(clientID string) (string, error) { + return s.generateToken("ak", clientID) +} + +// generateRefreshToken generates a new refresh token +func (s *Service) generateRefreshToken(clientID string) (string, error) { + return s.generateToken("rfk", clientID) +} + +// generateAuthorizationCode generates a new authorization code +func (s *Service) generateAuthorizationCode(clientID string, state string) (string, error) { + return s.generateToken("ac", clientID) +} + +// generateToken generates a token with the specified type and client ID +func (s *Service) generateToken(tokenType string, clientID string) (string, error) { + // Generate random bytes for token + randomBytes := make([]byte, 32) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Create token with type, client ID, timestamp, and random component + randomPart := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(randomBytes) + timestamp := time.Now().Format("20060102150405") + + return fmt.Sprintf("%s_%s_%s_%s", tokenType, clientID, timestamp, randomPart), nil } diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index 376d188e..50fe5460 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -31,7 +31,9 @@ const ( // OAuth 2.1 Response Types const ( - ResponseTypeCode = "code" + ResponseTypeCode = "code" + ResponseTypeToken = "token" + ResponseTypeIDToken = "id_token" ) // OAuth 2.1 Token Types diff --git a/openapi/oauth/types/utils.go b/openapi/oauth/types/utils.go new file mode 100644 index 00000000..61e084df --- /dev/null +++ b/openapi/oauth/types/utils.go @@ -0,0 +1,11 @@ +package types + +// Contains checks if a slice contains a string +func Contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +}