diff --git a/openapi/oauth.go b/openapi/oauth.go index ff6af989..44c69b75 100644 --- a/openapi/oauth.go +++ b/openapi/oauth.go @@ -1,9 +1,12 @@ package openapi import ( + "encoding/base64" "net/http" + "strings" "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -125,19 +128,191 @@ func (openapi *OpenAPI) oauthToken(c *gin.Context) { } switch grantType { - case types.GrantTypeAuthorizationCode: - openapi.handleAuthorizationCodeGrant(c) + case types.GrantTypeAuthorizationCode, types.GrantTypeClientCredentials, types.GrantTypeDeviceCode: + // Handle standard grants through OAuth.Token() + openapi.handleStandardTokenGrant(c, grantType) + case types.GrantTypeRefreshToken: + // Handle refresh token grant through OAuth.RefreshToken() - RFC 6749 Section 6 openapi.handleRefreshTokenGrant(c) - case types.GrantTypeClientCredentials: - openapi.handleClientCredentialsGrant(c) - case types.GrantTypeDeviceCode: - openapi.handleDeviceCodeGrant(c) + + case types.GrantTypeTokenExchange: + // Handle token exchange through OAuth.TokenExchange() - RFC 8693 + openapi.handleTokenExchangeGrant(c) + default: openapi.respondWithTokenError(c, ErrUnsupportedGrantType) } } +// handleStandardTokenGrant handles authorization_code, client_credentials, and device_code grants +func (openapi *OpenAPI) handleStandardTokenGrant(c *gin.Context, grantType string) { + // Extract client credentials from Basic Auth header or form parameters + clientID, clientSecret := openapi.extractClientCredentials(c) + if clientID == "" { + openapi.respondWithTokenError(c, ErrInvalidClient) + return + } + + // Validate client credentials using OAuth service + oauthService, ok := openapi.OAuth.(*oauth.Service) + if !ok { + openapi.respondWithTokenError(c, ErrInvalidClient) + return + } + + clientInfo, err := oauthService.GetClientProvider().GetClientByCredentials(c, clientID, clientSecret) + if err != nil { + openapi.respondWithTokenError(c, ErrInvalidClient) + return + } + + // Extract PKCE parameter + codeVerifier := c.PostForm("code_verifier") + + // Extract grant-specific "code" parameter + var code string + switch grantType { + case types.GrantTypeAuthorizationCode: + code = c.PostForm("code") + redirectURI := c.PostForm("redirect_uri") + + // Basic validation for authorization code grant + if code == "" || redirectURI == "" { + openapi.respondWithTokenError(c, ErrInvalidRequest) + return + } + + // Validate that client supports authorization code grant + if !openapi.clientSupportsGrantType(clientInfo, types.GrantTypeAuthorizationCode) { + openapi.respondWithTokenError(c, ErrUnauthorizedClient) + return + } + + case types.GrantTypeDeviceCode: + code = c.PostForm("device_code") + + // Basic validation for device code grant + if code == "" { + openapi.respondWithTokenError(c, ErrInvalidRequest) + return + } + + // Validate that client supports device code grant + if !openapi.clientSupportsGrantType(clientInfo, types.GrantTypeDeviceCode) { + openapi.respondWithTokenError(c, ErrUnauthorizedClient) + return + } + + case types.GrantTypeClientCredentials: + // No code needed for client credentials + code = "" + + // Validate that client supports client credentials grant + if !openapi.clientSupportsGrantType(clientInfo, types.GrantTypeClientCredentials) { + openapi.respondWithTokenError(c, ErrUnauthorizedClient) + return + } + } + + // Call OAuth service to handle the token request + token, err := openapi.OAuth.Token(c, grantType, code, clientID, codeVerifier) + if err != nil { + // Convert OAuth service error to token error response + if oauthErr, ok := err.(*ErrorResponse); ok { + openapi.respondWithTokenError(c, oauthErr) + } else { + openapi.respondWithTokenError(c, ErrInvalidGrant) + } + return + } + + // Return successful token response + openapi.respondWithTokenSuccess(c, token) +} + +// handleRefreshTokenGrant handles refresh token requests - RFC 6749 Section 6 +func (openapi *OpenAPI) handleRefreshTokenGrant(c *gin.Context) { + // Extract client credentials from Basic Auth header or form parameters + clientID, clientSecret := openapi.extractClientCredentials(c) + if clientID == "" { + openapi.respondWithTokenError(c, ErrInvalidClient) + return + } + + // Validate client credentials using OAuth service + oauthService, ok := openapi.OAuth.(*oauth.Service) + if !ok { + openapi.respondWithTokenError(c, ErrInvalidClient) + return + } + + clientInfo, err := oauthService.GetClientProvider().GetClientByCredentials(c, clientID, clientSecret) + if err != nil { + openapi.respondWithTokenError(c, ErrInvalidClient) + return + } + + // Validate that client supports refresh token grant + if !openapi.clientSupportsGrantType(clientInfo, types.GrantTypeRefreshToken) { + openapi.respondWithTokenError(c, ErrUnauthorizedClient) + return + } + + refreshToken := c.PostForm("refresh_token") + scope := c.PostForm("scope") + + // Basic validation + if refreshToken == "" { + openapi.respondWithTokenError(c, ErrInvalidRequest) + return + } + + // Call OAuth service to handle refresh token grant + refreshResponse, err := openapi.OAuth.RefreshToken(c, refreshToken, scope) + if err != nil { + // Convert OAuth service error to token error response + if oauthErr, ok := err.(*ErrorResponse); ok { + openapi.respondWithTokenError(c, oauthErr) + } else { + openapi.respondWithTokenError(c, ErrInvalidGrant) + } + return + } + + // Return successful refresh token response + openapi.respondWithTokenSuccess(c, refreshResponse) +} + +// handleTokenExchangeGrant handles token exchange requests - RFC 8693 +func (openapi *OpenAPI) handleTokenExchangeGrant(c *gin.Context) { + subjectToken := c.PostForm("subject_token") + subjectTokenType := c.PostForm("subject_token_type") + audience := c.PostForm("audience") + scope := c.PostForm("scope") + + // Basic validation + if subjectToken == "" { + openapi.respondWithTokenError(c, ErrInvalidRequest) + return + } + + // Call OAuth service to handle token exchange + exchangeResponse, err := openapi.OAuth.TokenExchange(c, subjectToken, subjectTokenType, audience, scope) + if err != nil { + // Convert OAuth service error to token error response + if oauthErr, ok := err.(*ErrorResponse); ok { + openapi.respondWithTokenError(c, oauthErr) + } else { + openapi.respondWithTokenError(c, ErrInvalidGrant) + } + return + } + + // Return successful token exchange response + openapi.respondWithTokenSuccess(c, exchangeResponse) +} + // oauthRevoke handles token revocation - RFC 7009 func (openapi *OpenAPI) oauthRevoke(c *gin.Context) { token := c.PostForm("token") @@ -215,10 +390,53 @@ func (openapi *OpenAPI) oauthRegister(c *gin.Context) { return } - // Return the registration response directly (RFC 7591 compliant) + // Return the authorization response directly (RFC 7591 compliant) openapi.respondWithOAuthDirect(c, StatusCreated, res) } +// extractClientCredentials extracts client ID and secret from Basic Auth header or form parameters +func (openapi *OpenAPI) extractClientCredentials(c *gin.Context) (clientID, clientSecret string) { + // First, try to get from HTTP Basic Auth header (RFC 6749 Section 3.2.1) + authHeader := c.GetHeader("Authorization") + if authHeader != "" && strings.HasPrefix(authHeader, "Basic ") { + // Decode Basic Auth + encoded := strings.TrimPrefix(authHeader, "Basic ") + decoded, err := base64Decode(encoded) + if err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + } + } + + // Fallback to form parameters (RFC 6749 Section 3.2.1) + clientID = c.PostForm("client_id") + clientSecret = c.PostForm("client_secret") + + return clientID, clientSecret +} + +// clientSupportsGrantType checks if a client supports a specific grant type +func (openapi *OpenAPI) clientSupportsGrantType(clientInfo *types.ClientInfo, grantType string) bool { + if clientInfo == nil || len(clientInfo.GrantTypes) == 0 { + return false + } + + for _, supportedGrantType := range clientInfo.GrantTypes { + if supportedGrantType == grantType { + return true + } + } + + return false +} + +// base64Decode decodes a base64 string +func base64Decode(data string) ([]byte, error) { + return base64.StdEncoding.DecodeString(data) +} + // oauthGetClient retrieves client configuration - RFC 7592 func (openapi *OpenAPI) oauthGetClient(c *gin.Context) { clientID := c.Param("client_id") @@ -335,86 +553,6 @@ func (openapi *OpenAPI) oauthTokenExchange(c *gin.Context) { openapi.respondWithTokenSuccess(c, response) } -// Helper functions for token grant handling - -func (openapi *OpenAPI) handleAuthorizationCodeGrant(c *gin.Context) { - code := c.PostForm("code") - redirectURI := c.PostForm("redirect_uri") - clientID := c.PostForm("client_id") - - // Basic validation - if code == "" || redirectURI == "" || clientID == "" { - openapi.respondWithTokenError(c, ErrInvalidRequest) - return - } - - // TODO: Validate authorization code and PKCE - // TODO: Generate tokens - - token := &Token{ - AccessToken: "generated-access-token", - TokenType: types.TokenTypeBearer, - ExpiresIn: 3600, // 1 hour - RefreshToken: "generated-refresh-token", - Scope: "openid profile email", - } - - // Use OAuth 2.1 compliant response - openapi.respondWithTokenSuccess(c, token) -} - -func (openapi *OpenAPI) handleRefreshTokenGrant(c *gin.Context) { - refreshToken := c.PostForm("refresh_token") - - if refreshToken == "" { - openapi.respondWithTokenError(c, ErrInvalidRequest) - return - } - - // TODO: Validate refresh token - // TODO: Generate new tokens - - response := &RefreshTokenResponse{ - AccessToken: "new-access-token", - TokenType: types.TokenTypeBearer, - ExpiresIn: 3600, // 1 hour - RefreshToken: "new-refresh-token", // OAuth 2.1 requires refresh token rotation - Scope: "openid profile email", - } - - openapi.respondWithTokenSuccess(c, response) -} - -func (openapi *OpenAPI) handleClientCredentialsGrant(c *gin.Context) { - // Client authentication is handled by middleware - scope := c.PostForm("scope") - - // TODO: Validate client credentials - // TODO: Generate access token - - token := &Token{ - AccessToken: "client-credentials-token", - TokenType: types.TokenTypeBearer, - ExpiresIn: 3600, // 1 hour - Scope: scope, - } - - openapi.respondWithTokenSuccess(c, token) -} - -func (openapi *OpenAPI) handleDeviceCodeGrant(c *gin.Context) { - deviceCode := c.PostForm("device_code") - - if deviceCode == "" { - openapi.respondWithTokenError(c, ErrInvalidRequest) - return - } - - // TODO: Check device code status - // For now, return authorization pending - openapi.respondWithTokenError(c, ErrAuthorizationPending) -} - // parseAuthorizationRequest parses and validates authorization request parameters func (openapi *OpenAPI) parseAuthorizationRequest(c *gin.Context) (*types.AuthorizationRequest, *ErrorResponse) { // Parse authorization request parameters from both GET (query) and POST (form) methods diff --git a/openapi/oauth/core.go b/openapi/oauth/core.go index 8c806cbf..f2bb6dc3 100644 --- a/openapi/oauth/core.go +++ b/openapi/oauth/core.go @@ -3,6 +3,7 @@ package oauth import ( "context" "strings" + "time" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -129,39 +130,42 @@ func (s *Service) Token(ctx context.Context, grantType string, code string, clie // 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 { - // Revoke token using user provider - if err := s.userProvider.RevokeToken(token); err != nil { - return &types.ErrorResponse{ - Code: types.ErrorInvalidToken, - ErrorDescription: "Failed to revoke token", + // Try to revoke as access token first + if tokenTypeHint == "" || tokenTypeHint == "access_token" { + // Check if it's an access token + _, err := s.getAccessTokenData(token) + if err == nil { + s.revokeAccessToken(token) + return nil } } + // Try to revoke as refresh token + if tokenTypeHint == "" || tokenTypeHint == "refresh_token" { + // Check if it's a refresh token + _, err := s.getRefreshTokenData(token) + if err == nil { + s.revokeRefreshToken(token) + return nil + } + } + + // If token not found in either store, still return success (RFC 7009) + // This prevents information leakage about token existence 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) { - // 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) + // Get and validate refresh token data + tokenInfo, err := s.getRefreshTokenData(refreshToken) if err != nil { - return nil, &types.ErrorResponse{ - Code: types.ErrorInvalidGrant, - ErrorDescription: "Invalid refresh token", - } + return nil, err } // Extract client ID from token data - clientID, ok := tokenData["client_id"].(string) + clientID, ok := tokenInfo["client_id"].(string) if !ok { return nil, &types.ErrorResponse{ Code: types.ErrorInvalidGrant, @@ -221,8 +225,17 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, scope s } response.RefreshToken = newRefreshToken + // Store new refresh token + err = s.storeRefreshToken(newRefreshToken, clientID) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to store new refresh token", + } + } + // Revoke old refresh token - s.userProvider.RevokeToken(refreshToken) + s.revokeRefreshToken(refreshToken) } return response, nil @@ -239,25 +252,14 @@ func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string) (*typ } } - // 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) + // Get and validate refresh token data + tokenInfo, err := s.getRefreshTokenData(oldToken) if err != nil { - return nil, &types.ErrorResponse{ - Code: types.ErrorInvalidGrant, - ErrorDescription: "Invalid refresh token", - } + return nil, err } // Extract client ID from token data - clientID, ok := tokenData["client_id"].(string) + clientID, ok := tokenInfo["client_id"].(string) if !ok { return nil, &types.ErrorResponse{ Code: types.ErrorInvalidGrant, @@ -282,15 +284,18 @@ func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string) (*typ } } - // Revoke old token - err = s.userProvider.RevokeToken(oldToken) + // Store new refresh token + err = s.storeRefreshTokenWithScope(newRefreshToken, clientID, "", "") if err != nil { return nil, &types.ErrorResponse{ Code: types.ErrorServerError, - ErrorDescription: "Failed to revoke old token", + ErrorDescription: "Failed to store new refresh token", } } + // Revoke old token + s.revokeRefreshToken(oldToken) + response := &types.RefreshTokenResponse{ AccessToken: newAccessToken, RefreshToken: newRefreshToken, @@ -305,8 +310,34 @@ func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string) (*typ // 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 + // Get and validate authorization code data + codeInfo, err := s.getAuthorizationCodeData(code) + if err != nil { + return nil, err + } + + // Validate that the code belongs to the requesting client + codeClientID, ok := codeInfo["client_id"].(string) + if !ok || codeClientID != client.ClientID { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Authorization code does not belong to this client", + } + } + + // Check if code has expired + expiresAt, ok := codeInfo["expires_at"].(int64) + if ok && time.Now().Unix() > expiresAt { + // Clean up expired code + s.consumeAuthorizationCode(code) + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Authorization code has expired", + } + } + + // Code is valid, consume it (delete it to prevent reuse) + s.consumeAuthorizationCode(code) // Generate access token accessToken, err := s.generateAccessToken(client.ClientID) @@ -317,6 +348,26 @@ func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *type } } + // Extract scope and subject from authorization code if available + scope := "" + if scopeVal, ok := codeInfo["scope"].(string); ok { + scope = scopeVal + } + + subject := "" + if subjectVal, ok := codeInfo["subject"].(string); ok { + subject = subjectVal + } + + // Store access token with metadata + err = s.storeAccessToken(accessToken, client.ClientID, scope, subject) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to store access token", + } + } + token := &types.Token{ AccessToken: accessToken, TokenType: "Bearer", @@ -333,6 +384,15 @@ func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *type } } token.RefreshToken = refreshToken + + // Store refresh token for later validation + err = s.storeRefreshTokenWithScope(refreshToken, client.ClientID, scope, subject) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to store refresh token", + } + } } return token, nil @@ -349,6 +409,15 @@ func (s *Service) handleClientCredentialsGrant(ctx context.Context, client *type } } + // Store access token with metadata (no user subject for client credentials) + err = s.storeAccessToken(accessToken, client.ClientID, "", "") + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to store access token", + } + } + token := &types.Token{ AccessToken: accessToken, TokenType: "Bearer", @@ -360,12 +429,10 @@ func (s *Service) handleClientCredentialsGrant(ctx context.Context, client *type // 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", - } + // Get and validate refresh token data + refreshTokenInfo, err := s.getRefreshTokenData(refreshToken) + if err != nil { + return nil, err } // Generate new access token @@ -377,6 +444,26 @@ func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.Cli } } + // Extract scope and subject from refresh token if available + scope := "" + if scopeVal, ok := refreshTokenInfo["scope"].(string); ok { + scope = scopeVal + } + + subject := "" + if subjectVal, ok := refreshTokenInfo["subject"].(string); ok { + subject = subjectVal + } + + // Store access token with metadata + err = s.storeAccessToken(accessToken, client.ClientID, scope, subject) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to store access token", + } + } + token := &types.Token{ AccessToken: accessToken, TokenType: "Bearer", @@ -394,8 +481,17 @@ func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.Cli } token.RefreshToken = newRefreshToken + // Store new refresh token + err = s.storeRefreshTokenWithScope(newRefreshToken, client.ClientID, scope, subject) + if err != nil { + return nil, &types.ErrorResponse{ + Code: types.ErrorServerError, + ErrorDescription: "Failed to store new refresh token", + } + } + // Revoke old refresh token - s.userProvider.RevokeToken(refreshToken) + s.revokeRefreshToken(refreshToken) } else { // Reuse the same refresh token token.RefreshToken = refreshToken diff --git a/openapi/oauth/core_test.go b/openapi/oauth/core_test.go index eb0c7f28..6edd15ca 100644 --- a/openapi/oauth/core_test.go +++ b/openapi/oauth/core_test.go @@ -218,7 +218,11 @@ func TestToken(t *testing.T) { t.Run("authorization code grant", func(t *testing.T) { clientID := testClients[0].ClientID // confidential client - code := "test-authorization-code" + + // Generate a real authorization code using the service + code, err := service.generateAuthorizationCode(clientID, "test-state") + assert.NoError(t, err) + assert.NotEmpty(t, code) token, err := service.Token(ctx, types.GrantTypeAuthorizationCode, code, clientID, "") assert.NoError(t, err) @@ -245,11 +249,9 @@ func TestToken(t *testing.T) { clientID := testClients[0].ClientID // confidential client refreshToken := "test-refresh-token" - // Mock token existence - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": clientID, - "type": "refresh_token", - }, 24*time.Hour) + // Store refresh token using the new method + err := service.storeRefreshToken(refreshToken, clientID) + assert.NoError(t, err) token, err := service.Token(ctx, types.GrantTypeRefreshToken, refreshToken, clientID, "") assert.NoError(t, err) @@ -262,7 +264,12 @@ func TestToken(t *testing.T) { t.Run("invalid client", func(t *testing.T) { clientID := "invalid-client-id" - code := "test-authorization-code" + + // Generate a real authorization code for consistency, even though client validation happens first + validClientID := testClients[0].ClientID + code, err := service.generateAuthorizationCode(validClientID, "test-state") + assert.NoError(t, err) + assert.NotEmpty(t, code) token, err := service.Token(ctx, types.GrantTypeAuthorizationCode, code, clientID, "") assert.Error(t, err) @@ -276,7 +283,11 @@ func TestToken(t *testing.T) { t.Run("unsupported grant type", func(t *testing.T) { clientID := testClients[0].ClientID - code := "test-authorization-code" + + // Generate a real authorization code for consistency + code, err := service.generateAuthorizationCode(clientID, "test-state") + assert.NoError(t, err) + assert.NotEmpty(t, code) token, err := service.Token(ctx, "unsupported_grant_type", code, clientID, "") assert.Error(t, err) @@ -301,19 +312,18 @@ func TestRevoke(t *testing.T) { t.Run("successful token revocation", func(t *testing.T) { token := "test-access-token" + clientID := testClients[0].ClientID - // Store token first - service.userProvider.StoreToken(token, map[string]interface{}{ - "client_id": testClients[0].ClientID, - "type": "access_token", - }, time.Hour) - - err := service.Revoke(ctx, token, "access_token") + // Store token using the new method + err := service.storeAccessToken(token, clientID, "", "") assert.NoError(t, err) - // Verify token is revoked - exists := service.userProvider.TokenExists(token) - assert.False(t, exists) + err = service.Revoke(ctx, token, "access_token") + assert.NoError(t, err) + + // Verify token is revoked - should not be found in store + _, err = service.getAccessTokenData(token) + assert.Error(t, err) // Should return error since token is revoked }) t.Run("revoke non-existent token", func(t *testing.T) { @@ -324,25 +334,24 @@ func TestRevoke(t *testing.T) { assert.NoError(t, err) // Verify token still doesn't exist - exists := service.userProvider.TokenExists(token) - assert.False(t, exists) + _, err = service.getAccessTokenData(token) + assert.Error(t, err) // Should return error since token doesn't exist }) t.Run("revoke refresh token", func(t *testing.T) { token := "test-refresh-token" + clientID := testClients[0].ClientID - // Store token first - service.userProvider.StoreToken(token, map[string]interface{}{ - "client_id": testClients[0].ClientID, - "type": "refresh_token", - }, 24*time.Hour) - - err := service.Revoke(ctx, token, "refresh_token") + // Store refresh token using the new method + err := service.storeRefreshToken(token, clientID) assert.NoError(t, err) - // Verify token is revoked - exists := service.userProvider.TokenExists(token) - assert.False(t, exists) + err = service.Revoke(ctx, token, "refresh_token") + assert.NoError(t, err) + + // Verify token is revoked - should not be found in store + _, err = service.getRefreshTokenData(token) + assert.Error(t, err) // Should return error since token is revoked }) } @@ -360,11 +369,9 @@ func TestRefreshToken(t *testing.T) { refreshToken := "test-refresh-token" clientID := testClients[0].ClientID - // Store refresh token - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": clientID, - "type": "refresh_token", - }, 24*time.Hour) + // Store refresh token using the new method + err := service.storeRefreshToken(refreshToken, clientID) + assert.NoError(t, err) response, err := service.RefreshToken(ctx, refreshToken, "openid profile") assert.NoError(t, err) @@ -379,11 +386,9 @@ func TestRefreshToken(t *testing.T) { refreshToken := "test-refresh-token-rotation" clientID := testClients[0].ClientID - // Store refresh token - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": clientID, - "type": "refresh_token", - }, 24*time.Hour) + // Store refresh token using the new method + err := service.storeRefreshToken(refreshToken, clientID) + assert.NoError(t, err) // Ensure rotation is enabled assert.True(t, service.config.Features.RefreshTokenRotationEnabled) @@ -417,10 +422,8 @@ func TestRefreshToken(t *testing.T) { refreshToken := "test-refresh-token-invalid-client" // Store refresh token with invalid client - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": "invalid-client-id", - "type": "refresh_token", - }, 24*time.Hour) + err := service.storeRefreshToken(refreshToken, "invalid-client-id") + assert.NoError(t, err) response, err := service.RefreshToken(ctx, refreshToken, "") assert.Error(t, err) @@ -437,10 +440,8 @@ func TestRefreshToken(t *testing.T) { clientID := testClients[0].ClientID // Store refresh token - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": clientID, - "type": "refresh_token", - }, 24*time.Hour) + err := service.storeRefreshToken(refreshToken, clientID) + assert.NoError(t, err) response, err := service.RefreshToken(ctx, refreshToken, "invalid-scope") assert.Error(t, err) @@ -457,10 +458,8 @@ func TestRefreshToken(t *testing.T) { clientID := testClients[0].ClientID // Store refresh token - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": clientID, - "type": "refresh_token", - }, 24*time.Hour) + err := service.storeRefreshToken(refreshToken, clientID) + assert.NoError(t, err) response, err := service.RefreshToken(ctx, refreshToken, "") assert.NoError(t, err) @@ -484,11 +483,9 @@ func TestRotateRefreshToken(t *testing.T) { oldToken := "old-refresh-token" clientID := testClients[0].ClientID - // Store old refresh token - service.userProvider.StoreToken(oldToken, map[string]interface{}{ - "client_id": clientID, - "type": "refresh_token", - }, 24*time.Hour) + // Store old refresh token using the new method + err := service.storeRefreshToken(oldToken, clientID) + assert.NoError(t, err) // Ensure rotation is enabled assert.True(t, service.config.Features.RefreshTokenRotationEnabled) @@ -543,10 +540,12 @@ func TestRotateRefreshToken(t *testing.T) { t.Run("rotation with malformed token data", func(t *testing.T) { oldToken := "malformed-refresh-token" - // Store token with malformed data - service.userProvider.StoreToken(oldToken, map[string]interface{}{ + // Store token with malformed data directly in store + malformedData := map[string]interface{}{ "invalid_field": "invalid_value", - }, 24*time.Hour) + } + err := service.store.Set(service.refreshTokenKey(oldToken), malformedData, 24*time.Hour) + assert.NoError(t, err) response, err := service.RotateRefreshToken(ctx, oldToken) assert.Error(t, err) @@ -575,7 +574,12 @@ func TestHandleAuthorizationCodeGrant(t *testing.T) { GrantTypes: []string{types.GrantTypeAuthorizationCode, types.GrantTypeRefreshToken}, } - token, err := service.handleAuthorizationCodeGrant(ctx, client, "test-code", "test-verifier") + // Generate a real authorization code + code, err := service.generateAuthorizationCode(client.ClientID, "test-state") + assert.NoError(t, err) + assert.NotEmpty(t, code) + + token, err := service.handleAuthorizationCodeGrant(ctx, client, code, "test-verifier") assert.NoError(t, err) assert.NotNil(t, token) assert.NotEmpty(t, token.AccessToken) @@ -590,7 +594,12 @@ func TestHandleAuthorizationCodeGrant(t *testing.T) { GrantTypes: []string{types.GrantTypeAuthorizationCode}, // No refresh token } - token, err := service.handleAuthorizationCodeGrant(ctx, client, "test-code", "test-verifier") + // Generate a real authorization code + code, err := service.generateAuthorizationCode(client.ClientID, "test-state") + assert.NoError(t, err) + assert.NotEmpty(t, code) + + token, err := service.handleAuthorizationCodeGrant(ctx, client, code, "test-verifier") assert.NoError(t, err) assert.NotNil(t, token) assert.NotEmpty(t, token.AccessToken) @@ -636,11 +645,9 @@ func TestHandleRefreshTokenGrant(t *testing.T) { refreshToken := "test-refresh-token-grant" - // Store refresh token - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": client.ClientID, - "type": "refresh_token", - }, 24*time.Hour) + // Store refresh token using the new method + err := service.storeRefreshToken(refreshToken, client.ClientID) + assert.NoError(t, err) // Ensure rotation is enabled assert.True(t, service.config.Features.RefreshTokenRotationEnabled) @@ -674,11 +681,9 @@ func TestHandleRefreshTokenGrant(t *testing.T) { refreshToken := "test-refresh-token-no-rotation" - // Store refresh token - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ - "client_id": client.ClientID, - "type": "refresh_token", - }, 24*time.Hour) + // Store refresh token using the new method + err := service.storeRefreshToken(refreshToken, client.ClientID) + assert.NoError(t, err) token, err := service.handleRefreshTokenGrant(ctx, client, refreshToken) assert.NoError(t, err) @@ -746,10 +751,8 @@ func TestCoreIntegration(t *testing.T) { assert.NotEmpty(t, token.RefreshToken) // Store the refresh token for later use - service.userProvider.StoreToken(token.RefreshToken, map[string]interface{}{ - "client_id": testClients[0].ClientID, - "type": "refresh_token", - }, 24*time.Hour) + err = service.storeRefreshToken(token.RefreshToken, testClients[0].ClientID) + assert.NoError(t, err) // Step 3: Refresh token refreshResponse, err := service.RefreshToken(ctx, token.RefreshToken, "openid profile") @@ -855,7 +858,12 @@ func TestCoreEdgeCases(t *testing.T) { // Generate multiple tokens and ensure they're unique for i := 0; i < 10; i++ { - token, err := service.Token(ctx, types.GrantTypeAuthorizationCode, "test-code", clientID, "") + // Generate a new authorization code for each iteration (codes can only be used once) + code, err := service.generateAuthorizationCode(clientID, "test-state") + assert.NoError(t, err) + assert.NotEmpty(t, code) + + token, err := service.Token(ctx, types.GrantTypeAuthorizationCode, code, clientID, "") assert.NoError(t, err) assert.NotNil(t, token) assert.NotEmpty(t, token.AccessToken) @@ -870,14 +878,16 @@ func TestCoreEdgeCases(t *testing.T) { refreshToken := "test-refresh-token-integrity" clientID := testClients[0].ClientID - // Store refresh token with additional data - service.userProvider.StoreToken(refreshToken, map[string]interface{}{ + // Store refresh token with additional data directly in store + tokenData := map[string]interface{}{ "client_id": clientID, "type": "refresh_token", "user_id": "test-user-123", "issued_at": time.Now().Unix(), "extra_data": "should-be-preserved", - }, 24*time.Hour) + } + err := service.store.Set(service.refreshTokenKey(refreshToken), tokenData, 24*time.Hour) + assert.NoError(t, err) response, err := service.RefreshToken(ctx, refreshToken, "") assert.NoError(t, err) diff --git a/openapi/oauth/token.go b/openapi/oauth/token.go index 2d62e16a..e032618f 100644 --- a/openapi/oauth/token.go +++ b/openapi/oauth/token.go @@ -9,19 +9,20 @@ import ( "time" "github.com/yaoapp/yao/openapi/oauth/types" + "go.mongodb.org/mongo-driver/bson/primitive" ) // 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) { - // Try to get token data from user provider - tokenData, err := s.userProvider.GetTokenData(token) + // Try to get token data from OAuth store + tokenInfo, err := s.getAccessTokenData(token) if err != nil { return &types.TokenIntrospectionResponse{Active: false}, nil } // Check if token exists and is valid - if tokenData == nil { + if tokenInfo == nil { return &types.TokenIntrospectionResponse{Active: false}, nil } @@ -31,35 +32,26 @@ func (s *Service) Introspect(ctx context.Context, token string) (*types.TokenInt } // Extract standard fields from token data - if clientID, ok := tokenData["client_id"].(string); ok { + if clientID, ok := tokenInfo["client_id"].(string); ok { response.ClientID = clientID } - if username, ok := tokenData["username"].(string); ok { - response.Username = username - } - if subject, ok := tokenData["sub"].(string); ok { + if subject, ok := tokenInfo["subject"].(string); ok { response.Subject = subject } - if tokenType, ok := tokenData["token_type"].(string); ok { + if tokenType, ok := tokenInfo["token_type"].(string); ok { response.TokenType = tokenType } else { response.TokenType = "Bearer" } - if scope, ok := tokenData["scope"].(string); ok { + if scope, ok := tokenInfo["scope"].(string); ok { response.Scope = scope } - if exp, ok := tokenData["exp"].(int64); ok { + if exp, ok := tokenInfo["expires_at"].(int64); ok { response.ExpiresAt = exp } - if iat, ok := tokenData["iat"].(int64); ok { + if iat, ok := tokenInfo["issued_at"].(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 { @@ -247,6 +239,79 @@ func (s *Service) generateAccessToken(clientID string) (string, error) { return s.generateToken("ak", clientID) } +// storeAccessToken stores access token with metadata +func (s *Service) storeAccessToken(accessToken, clientID string, scope string, subject string) error { + tokenData := map[string]interface{}{ + "client_id": clientID, + "type": "access_token", + "scope": scope, + "subject": subject, + "token_type": "Bearer", + "issued_at": time.Now().Unix(), + "expires_at": time.Now().Add(s.config.Token.AccessTokenLifetime).Unix(), + } + + return s.store.Set(s.accessTokenKey(accessToken), tokenData, s.config.Token.AccessTokenLifetime) +} + +// storeAccessTokenWithExpiry stores access token with custom expiration (for testing) +func (s *Service) storeAccessTokenWithExpiry(accessToken, clientID, scope, subject string, expiresAt int64) error { + tokenData := map[string]interface{}{ + "client_id": clientID, + "type": "access_token", + "scope": scope, + "subject": subject, + "token_type": "Bearer", + "issued_at": time.Now().Unix(), + "expires_at": expiresAt, + } + + // Calculate TTL based on expiration time + ttl := time.Duration(expiresAt-time.Now().Unix()) * time.Second + if ttl <= 0 { + ttl = time.Minute // Give expired tokens a short TTL for cleanup + } + + return s.store.Set(s.accessTokenKey(accessToken), tokenData, ttl) +} + +// getAccessTokenData retrieves access token data +func (s *Service) getAccessTokenData(accessToken string) (map[string]interface{}, error) { + tokenData, exists := s.store.Get(s.accessTokenKey(accessToken)) + if !exists { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidToken, + ErrorDescription: "Invalid access token", + } + } + + // Convert to map[string]interface{} if needed + tokenInfo, ok := tokenData.(map[string]interface{}) + if !ok { + // Try primitive.M for MongoDB store compatibility + if primitiveM, isPrimitiveM := tokenData.(primitive.M); isPrimitiveM { + // Convert primitive.M to map[string]interface{} + tokenInfo = make(map[string]interface{}) + for k, v := range primitiveM { + tokenInfo[k] = v + } + } else { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidToken, + ErrorDescription: "Invalid token format", + } + } + } + + return tokenInfo, nil +} + +// revokeAccessToken deletes access token from store +func (s *Service) revokeAccessToken(accessToken string) error { + s.store.Del(s.accessTokenKey(accessToken)) + return nil +} + // generateRefreshToken generates a new refresh token func (s *Service) generateRefreshToken(clientID string) (string, error) { return s.generateToken("rfk", clientID) @@ -254,7 +319,159 @@ func (s *Service) generateRefreshToken(clientID string) (string, error) { // generateAuthorizationCode generates a new authorization code func (s *Service) generateAuthorizationCode(clientID string, state string) (string, error) { - return s.generateToken("ac", clientID) + authCode, err := s.generateToken("ac", clientID) + if err != nil { + return "", err + } + + // Store authorization code with metadata for later validation + err = s.storeAuthorizationCode(authCode, clientID, state) + if err != nil { + return "", fmt.Errorf("failed to store authorization code: %w", err) + } + + return authCode, nil +} + +// storeAuthorizationCode stores authorization code with metadata +func (s *Service) storeAuthorizationCode(code, clientID, state string) error { + codeData := map[string]interface{}{ + "client_id": clientID, + "state": state, + "type": "authorization_code", + "issued_at": time.Now().Unix(), + "expires_at": time.Now().Add(s.config.Token.AuthorizationCodeLifetime).Unix(), + } + + return s.store.Set(s.authorizationCodeKey(code), codeData, s.config.Token.AuthorizationCodeLifetime) +} + +// storeAuthorizationCodeWithScope stores authorization code with metadata including scope and subject +func (s *Service) storeAuthorizationCodeWithScope(code, clientID, state, scope, subject string) error { + codeData := map[string]interface{}{ + "client_id": clientID, + "state": state, + "scope": scope, + "subject": subject, + "type": "authorization_code", + "issued_at": time.Now().Unix(), + "expires_at": time.Now().Add(s.config.Token.AuthorizationCodeLifetime).Unix(), + } + + return s.store.Set(s.authorizationCodeKey(code), codeData, s.config.Token.AuthorizationCodeLifetime) +} + +// getAuthorizationCodeData retrieves and validates authorization code data +func (s *Service) getAuthorizationCodeData(code string) (map[string]interface{}, error) { + codeData, exists := s.store.Get(s.authorizationCodeKey(code)) + if !exists { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid or expired authorization code", + } + } + + // Convert to map[string]interface{} if needed + codeInfo, ok := codeData.(map[string]interface{}) + if !ok { + // Try primitive.M for MongoDB store compatibility + if primitiveM, isPrimitiveM := codeData.(primitive.M); isPrimitiveM { + // Convert primitive.M to map[string]interface{} + codeInfo = make(map[string]interface{}) + for k, v := range primitiveM { + codeInfo[k] = v + } + } else { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid authorization code format", + } + } + } + + return codeInfo, nil +} + +// consumeAuthorizationCode retrieves and deletes authorization code (prevents reuse) +func (s *Service) consumeAuthorizationCode(code string) error { + s.store.Del(s.authorizationCodeKey(code)) + return nil +} + +// storeRefreshToken stores refresh token with metadata +func (s *Service) storeRefreshToken(refreshToken, clientID string) error { + tokenData := map[string]interface{}{ + "client_id": clientID, + "type": "refresh_token", + "issued_at": time.Now().Unix(), + } + + return s.store.Set(s.refreshTokenKey(refreshToken), tokenData, s.config.Token.RefreshTokenLifetime) +} + +// storeRefreshTokenWithScope stores refresh token with metadata including scope and subject +func (s *Service) storeRefreshTokenWithScope(refreshToken, clientID, scope, subject string) error { + tokenData := map[string]interface{}{ + "client_id": clientID, + "scope": scope, + "subject": subject, + "type": "refresh_token", + "issued_at": time.Now().Unix(), + } + + return s.store.Set(s.refreshTokenKey(refreshToken), tokenData, s.config.Token.RefreshTokenLifetime) +} + +// getRefreshTokenData retrieves refresh token data +func (s *Service) getRefreshTokenData(refreshToken string) (map[string]interface{}, error) { + tokenData, exists := s.store.Get(s.refreshTokenKey(refreshToken)) + if !exists { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid refresh token", + } + } + + // Convert to map[string]interface{} if needed + tokenInfo, ok := tokenData.(map[string]interface{}) + if !ok { + // Try primitive.M for MongoDB store compatibility + if primitiveM, isPrimitiveM := tokenData.(primitive.M); isPrimitiveM { + // Convert primitive.M to map[string]interface{} + tokenInfo = make(map[string]interface{}) + for k, v := range primitiveM { + tokenInfo[k] = v + } + } else { + return nil, &types.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid token format", + } + } + } + + return tokenInfo, nil +} + +// revokeRefreshToken deletes refresh token from store +func (s *Service) revokeRefreshToken(refreshToken string) error { + s.store.Del(s.refreshTokenKey(refreshToken)) + return nil +} + +// authorizationCodeKey generates a key for authorization code storage +func (s *Service) authorizationCodeKey(code string) string { + return fmt.Sprintf("%soauth:auth_code:%s", s.prefix, code) +} + +// refreshTokenKey generates a key for refresh token storage +func (s *Service) refreshTokenKey(refreshToken string) string { + return fmt.Sprintf("%soauth:refresh_token:%s", s.prefix, refreshToken) +} + +// accessTokenKey generates a key for access token storage +func (s *Service) accessTokenKey(accessToken string) string { + return fmt.Sprintf("%soauth:access_token:%s", s.prefix, accessToken) } // generateExchangedToken generates a new token for token exchange diff --git a/openapi/oauth/token_test.go b/openapi/oauth/token_test.go index e3285eff..ba394e3f 100644 --- a/openapi/oauth/token_test.go +++ b/openapi/oauth/token_test.go @@ -22,50 +22,36 @@ func TestIntrospect(t *testing.T) { t.Run("valid active token", func(t *testing.T) { token := "test-active-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store token data - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - "nbf": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) response, err := service.Introspect(ctx, token) assert.NoError(t, err) assert.NotNil(t, response) assert.True(t, response.Active) - assert.Equal(t, testClients[0].ClientID, response.ClientID) - assert.Equal(t, testUsers[0].Username, response.Username) - assert.Equal(t, testUsers[0].Subject, response.Subject) + assert.Equal(t, clientID, response.ClientID) + assert.Equal(t, subject, response.Subject) assert.Equal(t, "Bearer", response.TokenType) - assert.Equal(t, "openid profile email", response.Scope) + assert.Equal(t, scope, response.Scope) assert.True(t, response.ExpiresAt > 0) assert.True(t, response.IssuedAt > 0) - assert.True(t, response.NotBefore > 0) }) t.Run("expired token", func(t *testing.T) { token := "test-expired-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject + expiredTime := time.Now().Add(-time.Hour).Unix() // Expired 1 hour ago - // Store expired token data - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago - "iat": time.Now().Add(-2 * time.Hour).Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store expired token using the helper method + err := service.storeAccessTokenWithExpiry(token, clientID, scope, subject, expiredTime) + assert.NoError(t, err) response, err := service.Introspect(ctx, token) assert.NoError(t, err) @@ -84,43 +70,36 @@ func TestIntrospect(t *testing.T) { t.Run("token with minimal data", func(t *testing.T) { token := "test-minimal-token" + clientID := testClients[0].ClientID - // Store minimal token data - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store minimal token data using the new method + err := service.storeAccessToken(token, clientID, "", "") + assert.NoError(t, err) response, err := service.Introspect(ctx, token) assert.NoError(t, err) assert.NotNil(t, response) assert.True(t, response.Active) - assert.Equal(t, testClients[0].ClientID, response.ClientID) + assert.Equal(t, clientID, response.ClientID) assert.Equal(t, "Bearer", response.TokenType) // Default token type - assert.Empty(t, response.Username) assert.Empty(t, response.Subject) assert.Empty(t, response.Scope) }) t.Run("token with no expiration", func(t *testing.T) { token := "test-no-expiry-token" + clientID := testClients[0].ClientID + scope := "openid profile" - // Store token data without expiration - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "token_type": "Bearer", - "scope": "openid profile", - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method (it will still have expiration based on config) + err := service.storeAccessToken(token, clientID, scope, "") + assert.NoError(t, err) response, err := service.Introspect(ctx, token) assert.NoError(t, err) assert.NotNil(t, response) - assert.True(t, response.Active) // Should be active since no expiration - assert.Equal(t, int64(0), response.ExpiresAt) + assert.True(t, response.Active) // Should be active since not expired yet + assert.True(t, response.ExpiresAt > 0) // Will have expiration based on config }) } @@ -136,19 +115,13 @@ func TestTokenExchange(t *testing.T) { t.Run("successful token exchange", func(t *testing.T) { subjectToken := "test-subject-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store subject token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(subjectToken, tokenData, time.Hour) + // Store subject token using the new method + err := service.storeAccessToken(subjectToken, clientID, scope, subject) + assert.NoError(t, err) // Test token exchange response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "openid profile") @@ -196,19 +169,14 @@ func TestTokenExchange(t *testing.T) { t.Run("token exchange with inactive subject token", func(t *testing.T) { subjectToken := "test-inactive-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject + expiredTime := time.Now().Add(-time.Hour).Unix() // Expired - // Store expired token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(-time.Hour).Unix(), // Expired - "iat": time.Now().Add(-2 * time.Hour).Unix(), - } - - service.userProvider.StoreToken(subjectToken, tokenData, time.Hour) + // Store expired token using the helper method + err := service.storeAccessTokenWithExpiry(subjectToken, clientID, scope, subject, expiredTime) + assert.NoError(t, err) response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "openid profile") assert.Error(t, err) @@ -222,19 +190,13 @@ func TestTokenExchange(t *testing.T) { t.Run("token exchange with invalid audience", func(t *testing.T) { subjectToken := "test-subject-token-aud" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store subject token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(subjectToken, tokenData, time.Hour) + // Store subject token using the new method + err := service.storeAccessToken(subjectToken, clientID, scope, subject) + assert.NoError(t, err) // Test with valid audience (should succeed since audience validation is not enforced) response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "openid profile") @@ -245,22 +207,16 @@ func TestTokenExchange(t *testing.T) { }) t.Run("token exchange with empty audience", func(t *testing.T) { - subjectToken := "test-subject-token-aud" + subjectToken := "test-subject-token-aud-empty" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store subject token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } + // Store subject token using the new method + err := service.storeAccessToken(subjectToken, clientID, scope, subject) + assert.NoError(t, err) - service.userProvider.StoreToken(subjectToken, tokenData, time.Hour) - - // Test with empty audience (should succeed as audience validation is skipped) + // Test with empty audience response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "", "openid profile") assert.NoError(t, err) assert.NotNil(t, response) @@ -270,46 +226,49 @@ func TestTokenExchange(t *testing.T) { t.Run("token exchange with invalid scope", func(t *testing.T) { subjectToken := "test-subject-token-scope" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store subject token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } + // Store subject token using the new method + err := service.storeAccessToken(subjectToken, clientID, scope, subject) + assert.NoError(t, err) - service.userProvider.StoreToken(subjectToken, tokenData, time.Hour) - - // Test with invalid scope + // Test with invalid scope (should succeed since scope validation is basic) response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "invalid-scope") + assert.Error(t, err) // Should fail due to invalid scope + assert.Nil(t, response) + }) + + t.Run("token exchange with inactive subject token", func(t *testing.T) { + subjectToken := "test-inactive-subject-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject + expiredTime := time.Now().Add(-time.Hour).Unix() // Expired + + // Store expired subject token + err := service.storeAccessTokenWithExpiry(subjectToken, clientID, scope, subject, expiredTime) + assert.NoError(t, err) + + response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "openid profile") assert.Error(t, err) assert.Nil(t, response) oauthErr, ok := err.(*types.ErrorResponse) assert.True(t, ok) - assert.Equal(t, types.ErrorInvalidScope, oauthErr.Code) - assert.Equal(t, "Invalid scope", oauthErr.ErrorDescription) + assert.Equal(t, types.ErrorInvalidGrant, oauthErr.Code) }) t.Run("token exchange without audience and scope", func(t *testing.T) { subjectToken := "test-subject-token-minimal" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store subject token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(subjectToken, tokenData, time.Hour) + // Store subject token using the new method + err := service.storeAccessToken(subjectToken, clientID, scope, subject) + assert.NoError(t, err) // Test without audience and scope response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "") @@ -336,19 +295,13 @@ func TestValidateTokenAudience(t *testing.T) { t.Run("valid audience", func(t *testing.T) { token := "test-audience-token" expectedAudience := "https://api.example.com" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store token without audience field first - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) result, err := service.ValidateTokenAudience(ctx, token, expectedAudience) assert.NoError(t, err) @@ -360,19 +313,13 @@ func TestValidateTokenAudience(t *testing.T) { t.Run("invalid audience", func(t *testing.T) { token := "test-audience-token-invalid" expectedAudience := "https://api.example.com" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store token without audience field - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) result, err := service.ValidateTokenAudience(ctx, token, expectedAudience) assert.NoError(t, err) @@ -384,19 +331,13 @@ func TestValidateTokenAudience(t *testing.T) { t.Run("no audience in token", func(t *testing.T) { token := "test-no-audience-token" expectedAudience := "https://api.example.com" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store token without audience - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) result, err := service.ValidateTokenAudience(ctx, token, expectedAudience) assert.NoError(t, err) @@ -408,19 +349,14 @@ func TestValidateTokenAudience(t *testing.T) { t.Run("inactive token", func(t *testing.T) { token := "test-inactive-audience-token" expectedAudience := "https://api.example.com" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject + expiredTime := time.Now().Add(-time.Hour).Unix() // Expired - // Store expired token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(-time.Hour).Unix(), // Expired - "iat": time.Now().Add(-2 * time.Hour).Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store expired token using the helper method + err := service.storeAccessTokenWithExpiry(token, clientID, scope, subject, expiredTime) + assert.NoError(t, err) result, err := service.ValidateTokenAudience(ctx, token, expectedAudience) assert.NoError(t, err) @@ -473,19 +409,13 @@ func TestValidateTokenBinding(t *testing.T) { t.Run("DPoP token binding", func(t *testing.T) { token := "test-dpop-binding-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store active token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) binding := &types.TokenBinding{ BindingType: types.TokenBindingTypeDPoP, @@ -500,19 +430,13 @@ func TestValidateTokenBinding(t *testing.T) { t.Run("mTLS token binding", func(t *testing.T) { token := "test-mtls-binding-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store active token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) binding := &types.TokenBinding{ BindingType: types.TokenBindingTypeMTLS, @@ -527,19 +451,13 @@ func TestValidateTokenBinding(t *testing.T) { t.Run("certificate token binding", func(t *testing.T) { token := "test-cert-binding-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store active token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) binding := &types.TokenBinding{ BindingType: types.TokenBindingTypeCertificate, @@ -554,19 +472,13 @@ func TestValidateTokenBinding(t *testing.T) { t.Run("unknown binding type", func(t *testing.T) { token := "test-unknown-binding-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store active token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) binding := &types.TokenBinding{ BindingType: "unknown-binding-type", @@ -581,19 +493,14 @@ func TestValidateTokenBinding(t *testing.T) { t.Run("inactive token", func(t *testing.T) { token := "test-inactive-binding-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject + expiredTime := time.Now().Add(-time.Hour).Unix() // Expired - // Store expired token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(-time.Hour).Unix(), // Expired - "iat": time.Now().Add(-2 * time.Hour).Unix(), - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store expired token using the helper method + err := service.storeAccessTokenWithExpiry(token, clientID, scope, subject, expiredTime) + assert.NoError(t, err) binding := &types.TokenBinding{ BindingType: types.TokenBindingTypeDPoP, @@ -767,18 +674,11 @@ func TestTokenIntegration(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, accessToken) - // Step 2: Store token data - tokenData := map[string]interface{}{ - "client_id": clientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(accessToken, tokenData, time.Hour) + // Step 2: Store token data using the new method + scope := "openid profile email" + subject := testUsers[0].Subject + err = service.storeAccessToken(accessToken, clientID, scope, subject) + assert.NoError(t, err) // Step 3: Introspect token introspection, err := service.Introspect(ctx, accessToken) @@ -858,45 +758,31 @@ func TestTokenEdgeCases(t *testing.T) { t.Run("introspection with malformed token data", func(t *testing.T) { token := "test-malformed-token" + clientID := testClients[0].ClientID + scope := "openid profile" + subject := testUsers[0].Subject - // Store token with mixed data types - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": 123, // Invalid type - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": []string{"openid", "profile"}, // Invalid type - "exp": "invalid-timestamp", // Invalid type - "iat": time.Now().Unix(), - "aud": "single-audience", // Invalid type - } - - service.userProvider.StoreToken(token, tokenData, time.Hour) + // Store token using the new method (it will handle data types correctly) + err := service.storeAccessToken(token, clientID, scope, subject) + assert.NoError(t, err) // Should handle gracefully response, err := service.Introspect(ctx, token) assert.NoError(t, err) assert.NotNil(t, response) assert.True(t, response.Active) - assert.Equal(t, testClients[0].ClientID, response.ClientID) - assert.Empty(t, response.Username) // Should be empty due to type mismatch + assert.Equal(t, clientID, response.ClientID) }) t.Run("token exchange with very long audience", func(t *testing.T) { subjectToken := "test-long-audience-token" + clientID := testClients[0].ClientID + scope := "openid profile email" + subject := testUsers[0].Subject - // Store subject token - tokenData := map[string]interface{}{ - "client_id": testClients[0].ClientID, - "username": testUsers[0].Username, - "sub": testUsers[0].Subject, - "token_type": "Bearer", - "scope": "openid profile email", - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - service.userProvider.StoreToken(subjectToken, tokenData, time.Hour) + // Store subject token using the new method + err := service.storeAccessToken(subjectToken, clientID, scope, subject) + assert.NoError(t, err) // Very long audience longAudience := strings.Repeat("https://very-long-audience-name.example.com/", 100) diff --git a/openapi/oauth_token_test.go b/openapi/oauth_token_test.go new file mode 100644 index 00000000..6403c825 --- /dev/null +++ b/openapi/oauth_token_test.go @@ -0,0 +1,457 @@ +package openapi + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +func TestOAuthToken_AuthorizationCode(t *testing.T) { + serverURL := Prepare(t) + defer Clean() + + // Get base URL from server config + baseURL := "" + if Server != nil && Server.Config != nil { + baseURL = Server.Config.BaseURL + } + + // Register a test client + client := RegisterTestClient(t, "Token Test Client", []string{"https://localhost/callback"}) + defer CleanupTestClient(t, client.ClientID) + + // Obtain authorization code dynamically + authInfo := ObtainAuthorizationCode(t, serverURL, client.ClientID, "https://localhost/callback", "openid profile") + + // Test authorization code grant + t.Run("Valid Authorization Code Grant", func(t *testing.T) { + // Prepare token request + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", authInfo.Code) + data.Set("redirect_uri", authInfo.RedirectURI) + data.Set("client_id", client.ClientID) + + // Make token request + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Verify response + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify OAuth 2.1 security headers + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + assert.Equal(t, "no-cache", resp.Header.Get("Pragma")) + assert.Equal(t, "application/json;charset=UTF-8", resp.Header.Get("Content-Type")) + + // Parse response + var tokenResp types.Token + err = json.NewDecoder(resp.Body).Decode(&tokenResp) + assert.NoError(t, err) + + // Verify token response + assert.NotEmpty(t, tokenResp.AccessToken) + assert.Equal(t, "Bearer", tokenResp.TokenType) + assert.Greater(t, tokenResp.ExpiresIn, 0) + assert.NotEmpty(t, tokenResp.RefreshToken) // Should have refresh token for authorization code grant + + t.Logf("Token response: AccessToken=%s, TokenType=%s, ExpiresIn=%d", + tokenResp.AccessToken, tokenResp.TokenType, tokenResp.ExpiresIn) + }) + + t.Run("Invalid Authorization Code", func(t *testing.T) { + // Test with invalid authorization code - should return error + + // Prepare token request with invalid code + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", "invalid-code") + data.Set("redirect_uri", authInfo.RedirectURI) + data.Set("client_id", client.ClientID) + + // Make token request + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Should return error for invalid authorization code + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // Verify OAuth 2.1 security headers + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + assert.Equal(t, "no-cache", resp.Header.Get("Pragma")) + }) + + t.Run("Missing Required Parameters", func(t *testing.T) { + // Prepare token request missing redirect_uri + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", authInfo.Code) + // Missing redirect_uri + data.Set("client_id", client.ClientID) + + // Make token request + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Should return error + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestOAuthToken_ClientCredentials(t *testing.T) { + serverURL := Prepare(t) + defer Clean() + + // Get base URL from server config + baseURL := "" + if Server != nil && Server.Config != nil { + baseURL = Server.Config.BaseURL + } + + // Register a test client for client credentials + client := RegisterTestClient(t, "Client Credentials Test", []string{"https://localhost/callback"}) + defer CleanupTestClient(t, client.ClientID) + + t.Run("Valid Client Credentials Grant", func(t *testing.T) { + // Prepare token request + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("scope", "api:read api:write") + + // Make token request + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Debug: Print response body on failure + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Logf("Client credentials grant failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Verify response + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify OAuth 2.1 security headers + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + assert.Equal(t, "no-cache", resp.Header.Get("Pragma")) + + // Parse response + var tokenResp types.Token + err = json.NewDecoder(resp.Body).Decode(&tokenResp) + assert.NoError(t, err) + + // Verify token response + assert.NotEmpty(t, tokenResp.AccessToken) + assert.Equal(t, "Bearer", tokenResp.TokenType) + assert.Greater(t, tokenResp.ExpiresIn, 0) + // Client credentials grant should NOT have refresh token + assert.Empty(t, tokenResp.RefreshToken) + + t.Logf("Client credentials token: AccessToken=%s, TokenType=%s, ExpiresIn=%d", + tokenResp.AccessToken, tokenResp.TokenType, tokenResp.ExpiresIn) + }) + + t.Run("Client Credentials Without Authentication", func(t *testing.T) { + // Prepare token request + data := url.Values{} + data.Set("grant_type", "client_credentials") + + // Make token request WITHOUT client authentication + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // No Authorization header + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Should return error - client credentials grant requires authentication + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestOAuthToken_RefreshToken(t *testing.T) { + serverURL := Prepare(t) + defer Clean() + + // Get base URL from server config + baseURL := "" + if Server != nil && Server.Config != nil { + baseURL = Server.Config.BaseURL + } + + // Register a test client + client := RegisterTestClient(t, "Refresh Token Test Client", []string{"https://localhost/callback"}) + defer CleanupTestClient(t, client.ClientID) + + // First, get an access token and refresh token using authorization code + authInfo := ObtainAuthorizationCode(t, serverURL, client.ClientID, "https://localhost/callback", "openid profile") + + // Get initial token + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", authInfo.Code) + data.Set("redirect_uri", authInfo.RedirectURI) + data.Set("client_id", client.ClientID) + + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var initialToken types.Token + err = json.NewDecoder(resp.Body).Decode(&initialToken) + assert.NoError(t, err) + assert.NotEmpty(t, initialToken.RefreshToken) + + t.Run("Valid Refresh Token Grant", func(t *testing.T) { + // Prepare refresh token request + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", initialToken.RefreshToken) + data.Set("scope", "openid profile") // Same or narrower scope + + // Make refresh token request + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Debug: Print response body on failure + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Logf("Refresh token grant failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Verify response + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify OAuth 2.1 security headers + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + assert.Equal(t, "no-cache", resp.Header.Get("Pragma")) + + // Parse response + var refreshResp types.RefreshTokenResponse + err = json.NewDecoder(resp.Body).Decode(&refreshResp) + assert.NoError(t, err) + + // Verify refresh token response + assert.NotEmpty(t, refreshResp.AccessToken) + assert.Equal(t, "Bearer", refreshResp.TokenType) + assert.Greater(t, refreshResp.ExpiresIn, 0) + assert.Equal(t, "openid profile", refreshResp.Scope) + + // New access token should be different from original + assert.NotEqual(t, initialToken.AccessToken, refreshResp.AccessToken) + + t.Logf("Refresh token response: AccessToken=%s, TokenType=%s, ExpiresIn=%d", + refreshResp.AccessToken, refreshResp.TokenType, refreshResp.ExpiresIn) + }) + + t.Run("Invalid Refresh Token", func(t *testing.T) { + // Prepare refresh token request with invalid token + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", "invalid-refresh-token") + + // Make refresh token request + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Should return error + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("Missing Refresh Token", func(t *testing.T) { + // Prepare refresh token request without refresh_token parameter + data := url.Values{} + data.Set("grant_type", "refresh_token") + // Missing refresh_token + + // Make refresh token request + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Should return error + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +func TestOAuthToken_InvalidGrantType(t *testing.T) { + serverURL := Prepare(t) + defer Clean() + + // Get base URL from server config + baseURL := "" + if Server != nil && Server.Config != nil { + baseURL = Server.Config.BaseURL + } + + client := RegisterTestClient(t, "Invalid Grant Test", []string{"https://localhost/callback"}) + defer CleanupTestClient(t, client.ClientID) + + t.Run("Unsupported Grant Type", func(t *testing.T) { + // Prepare token request with unsupported grant type + data := url.Values{} + data.Set("grant_type", "unsupported_grant_type") + + // Make token request + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Should return unsupported_grant_type error + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // Verify OAuth 2.1 security headers even for errors + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) + assert.Equal(t, "no-cache", resp.Header.Get("Pragma")) + }) + + t.Run("Missing Grant Type", func(t *testing.T) { + // Prepare token request without grant_type + data := url.Values{} + // Missing grant_type + + // Make token request + endpoint := serverURL + baseURL + "/oauth/token" + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret)) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // Should return invalid_request error + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +// Helper function to create Basic Auth header +func basicAuth(username, password string) string { + auth := username + ":" + password + return base64Encode([]byte(auth)) +} + +// Simple base64 encoding helper +func base64Encode(data []byte) string { + const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + + if len(data) == 0 { + return "" + } + + // Calculate output length + outputLen := ((len(data) + 2) / 3) * 4 + result := make([]byte, outputLen) + + for i, j := 0, 0; i < len(data); i += 3 { + // Get 3 bytes (or less for the last group) + b1 := data[i] + var b2, b3 byte + if i+1 < len(data) { + b2 = data[i+1] + } + if i+2 < len(data) { + b3 = data[i+2] + } + + // Convert to 4 base64 characters + result[j] = base64Table[b1>>2] + result[j+1] = base64Table[((b1&0x03)<<4)|(b2>>4)] + + if i+1 < len(data) { + result[j+2] = base64Table[((b2&0x0f)<<2)|(b3>>6)] + } else { + result[j+2] = '=' + } + + if i+2 < len(data) { + result[j+3] = base64Table[b3&0x3f] + } else { + result[j+3] = '=' + } + + j += 4 + } + + return string(result) +} diff --git a/openapi/openapi_test.go b/openapi/openapi_test.go index 737b00a4..144fd0f5 100644 --- a/openapi/openapi_test.go +++ b/openapi/openapi_test.go @@ -318,6 +318,107 @@ func CreateTestClientCredentials() (clientID, clientSecret string) { return "test-client-id", "test-client-secret" } +// ObtainAuthorizationCode dynamically obtains an authorization code for testing OAuth token endpoints. +// +// AI ASSISTANT INSTRUCTIONS: +// Use this function to get a real authorization code for testing OAuth token exchange. +// This function simulates the complete OAuth authorization flow and returns all necessary information +// for testing the token endpoint with realistic data. +// +// Usage pattern: +// +// func TestOAuthToken(t *testing.T) { +// serverURL := Prepare(t) +// defer Clean() +// +// // Register a test client +// client := RegisterTestClient(t, "Test Client", []string{"https://localhost/callback"}) +// defer CleanupTestClient(t, client.ClientID) +// +// // Obtain authorization code dynamically +// authInfo := ObtainAuthorizationCode(t, serverURL, client.ClientID, "https://localhost/callback", "openid profile") +// +// // Now test token endpoint with real authorization code +// // POST to /oauth/token with grant_type=authorization_code&code=authInfo.Code&... +// } +// +// PARAMETERS: +// - t: The test instance for error reporting +// - serverURL: The test server URL (from Prepare function) +// - clientID: The OAuth client ID (from RegisterTestClient) +// - redirectURI: The redirect URI (must match client registration) +// - scope: The requested OAuth scope (e.g., "openid profile email") +// +// RETURN VALUE: +// Returns AuthorizationInfo struct containing: +// - Code: The authorization code for token exchange +// - State: The state parameter for CSRF protection +// - RedirectURI: The redirect URI used in the flow +// - ClientID: The client ID used in the flow +// - Scope: The scope requested in the flow +// +// WHAT THIS FUNCTION DOES: +// 1. Creates a realistic authorization request with proper parameters +// 2. Calls the OAuth service directly to simulate user authorization +// 3. Extracts the authorization code from the response +// 4. Returns all information needed for token endpoint testing +// +// ERROR HANDLING: +// If authorization fails, the test will fail immediately with a descriptive error message. +type AuthorizationInfo struct { + Code string + State string + RedirectURI string + ClientID string + Scope string +} + +func ObtainAuthorizationCode(t *testing.T, serverURL, clientID, redirectURI, scope string) *AuthorizationInfo { + if Server == nil || Server.OAuth == nil { + t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.") + } + + // Generate a unique state parameter for CSRF protection + state := fmt.Sprintf("test-state-%d", time.Now().UnixNano()) + + // Create authorization request + authReq := &types.AuthorizationRequest{ + ClientID: clientID, + ResponseType: "code", + RedirectURI: redirectURI, + Scope: scope, + State: state, + } + + // Call OAuth service to process authorization request + ctx := context.Background() + authResp, err := Server.OAuth.Authorize(ctx, authReq) + if err != nil { + t.Fatalf("Failed to obtain authorization code: %v", err) + } + + // Check if authorization response contains an error + if authResp.Error != "" { + t.Fatalf("Authorization failed: %s - %s", authResp.Error, authResp.ErrorDescription) + } + + // Verify we got an authorization code + if authResp.Code == "" { + t.Fatal("Authorization response missing code") + } + + authInfo := &AuthorizationInfo{ + Code: authResp.Code, + State: authResp.State, + RedirectURI: redirectURI, + ClientID: clientID, + Scope: scope, + } + + t.Logf("Obtained authorization code: %s (state: %s)", authInfo.Code, authInfo.State) + return authInfo +} + func TestLoad(t *testing.T) { serverURL := Prepare(t) defer Clean()