Merge pull request #1026 from trheyi/main
Add OAuth token handling and enhance grant type support
This commit is contained in:
commit
8bce24a00b
8 changed files with 1814 additions and 515 deletions
363
openapi/oauth.go
363
openapi/oauth.go
|
|
@ -1,7 +1,12 @@
|
||||||
package openapi
|
package openapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,15 +71,50 @@ func (openapi *OpenAPI) attachOAuth(base *gin.RouterGroup) {
|
||||||
// oauthAuthorize handles authorization requests - RFC 6749 Section 3.1
|
// oauthAuthorize handles authorization requests - RFC 6749 Section 3.1
|
||||||
func (openapi *OpenAPI) oauthAuthorize(c *gin.Context) {
|
func (openapi *OpenAPI) oauthAuthorize(c *gin.Context) {
|
||||||
// Parse and validate authorization request
|
// Parse and validate authorization request
|
||||||
authReq, err := openapi.parseAuthorizationRequest(c)
|
authReq, parseErr := openapi.parseAuthorizationRequest(c)
|
||||||
if err != nil {
|
if parseErr != nil {
|
||||||
openapi.respondWithAuthorizationError(c, authReq.RedirectURI, err, authReq.State)
|
openapi.respondWithAuthorizationError(c, authReq.RedirectURI, parseErr, authReq.State)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement full authorization logic
|
// Call OAuth service to process authorization request
|
||||||
// For now, return server error to indicate not implemented
|
authResp, err := openapi.OAuth.Authorize(c, authReq)
|
||||||
|
if err != nil {
|
||||||
|
// OAuth service returned an error
|
||||||
openapi.respondWithAuthorizationError(c, authReq.RedirectURI, ErrServerError, authReq.State)
|
openapi.respondWithAuthorizationError(c, authReq.RedirectURI, ErrServerError, authReq.State)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if authorization response contains an error
|
||||||
|
if authResp.Error != "" {
|
||||||
|
// Convert OAuth service error to ErrorResponse
|
||||||
|
oauthError := &ErrorResponse{
|
||||||
|
Code: authResp.Error,
|
||||||
|
ErrorDescription: authResp.ErrorDescription,
|
||||||
|
}
|
||||||
|
openapi.respondWithAuthorizationError(c, authReq.RedirectURI, oauthError, authReq.State)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: redirect to client with authorization code
|
||||||
|
redirectURL := authReq.RedirectURI
|
||||||
|
if redirectURL != "" {
|
||||||
|
separator := "?"
|
||||||
|
if len(redirectURL) > 0 && redirectURL[len(redirectURL)-1:] == "?" {
|
||||||
|
separator = "&"
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL += separator + "code=" + authResp.Code
|
||||||
|
if authResp.State != "" {
|
||||||
|
redirectURL += "&state=" + authResp.State
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, redirectURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return JSON response if no redirect URI (should not happen with valid requests)
|
||||||
|
openapi.respondWithSuccess(c, StatusOK, authResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthToken handles token requests - RFC 6749 Section 3.2
|
// oauthToken handles token requests - RFC 6749 Section 3.2
|
||||||
|
|
@ -88,19 +128,191 @@ func (openapi *OpenAPI) oauthToken(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch grantType {
|
switch grantType {
|
||||||
case types.GrantTypeAuthorizationCode:
|
case types.GrantTypeAuthorizationCode, types.GrantTypeClientCredentials, types.GrantTypeDeviceCode:
|
||||||
openapi.handleAuthorizationCodeGrant(c)
|
// Handle standard grants through OAuth.Token()
|
||||||
|
openapi.handleStandardTokenGrant(c, grantType)
|
||||||
|
|
||||||
case types.GrantTypeRefreshToken:
|
case types.GrantTypeRefreshToken:
|
||||||
|
// Handle refresh token grant through OAuth.RefreshToken() - RFC 6749 Section 6
|
||||||
openapi.handleRefreshTokenGrant(c)
|
openapi.handleRefreshTokenGrant(c)
|
||||||
case types.GrantTypeClientCredentials:
|
|
||||||
openapi.handleClientCredentialsGrant(c)
|
case types.GrantTypeTokenExchange:
|
||||||
case types.GrantTypeDeviceCode:
|
// Handle token exchange through OAuth.TokenExchange() - RFC 8693
|
||||||
openapi.handleDeviceCodeGrant(c)
|
openapi.handleTokenExchangeGrant(c)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
openapi.respondWithTokenError(c, ErrUnsupportedGrantType)
|
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
|
// oauthRevoke handles token revocation - RFC 7009
|
||||||
func (openapi *OpenAPI) oauthRevoke(c *gin.Context) {
|
func (openapi *OpenAPI) oauthRevoke(c *gin.Context) {
|
||||||
token := c.PostForm("token")
|
token := c.PostForm("token")
|
||||||
|
|
@ -178,10 +390,53 @@ func (openapi *OpenAPI) oauthRegister(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the registration response directly (RFC 7591 compliant)
|
// Return the authorization response directly (RFC 7591 compliant)
|
||||||
openapi.respondWithOAuthDirect(c, StatusCreated, res)
|
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
|
// oauthGetClient retrieves client configuration - RFC 7592
|
||||||
func (openapi *OpenAPI) oauthGetClient(c *gin.Context) {
|
func (openapi *OpenAPI) oauthGetClient(c *gin.Context) {
|
||||||
clientID := c.Param("client_id")
|
clientID := c.Param("client_id")
|
||||||
|
|
@ -298,90 +553,10 @@ func (openapi *OpenAPI) oauthTokenExchange(c *gin.Context) {
|
||||||
openapi.respondWithTokenSuccess(c, response)
|
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
|
// parseAuthorizationRequest parses and validates authorization request parameters
|
||||||
func (openapi *OpenAPI) parseAuthorizationRequest(c *gin.Context) (*AuthorizationRequest, *ErrorResponse) {
|
func (openapi *OpenAPI) parseAuthorizationRequest(c *gin.Context) (*types.AuthorizationRequest, *ErrorResponse) {
|
||||||
// Parse authorization request parameters from both GET (query) and POST (form) methods
|
// Parse authorization request parameters from both GET (query) and POST (form) methods
|
||||||
authReq := &AuthorizationRequest{
|
authReq := &types.AuthorizationRequest{
|
||||||
ClientID: openapi.getParam(c, "client_id"),
|
ClientID: openapi.getParam(c, "client_id"),
|
||||||
ResponseType: openapi.getParam(c, "response_type"),
|
ResponseType: openapi.getParam(c, "response_type"),
|
||||||
RedirectURI: openapi.getParam(c, "redirect_uri"),
|
RedirectURI: openapi.getParam(c, "redirect_uri"),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package oauth
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"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
|
// Revoke revokes an access token or refresh token
|
||||||
// Once revoked, the token cannot be used for accessing protected resources
|
// Once revoked, the token cannot be used for accessing protected resources
|
||||||
func (s *Service) Revoke(ctx context.Context, token string, tokenTypeHint string) error {
|
func (s *Service) Revoke(ctx context.Context, token string, tokenTypeHint string) error {
|
||||||
// Revoke token using user provider
|
// Try to revoke as access token first
|
||||||
if err := s.userProvider.RevokeToken(token); err != nil {
|
if tokenTypeHint == "" || tokenTypeHint == "access_token" {
|
||||||
return &types.ErrorResponse{
|
// Check if it's an access token
|
||||||
Code: types.ErrorInvalidToken,
|
_, err := s.getAccessTokenData(token)
|
||||||
ErrorDescription: "Failed to revoke 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken exchanges a refresh token for a new access token
|
// RefreshToken exchanges a refresh token for a new access token
|
||||||
// This allows clients to obtain fresh access tokens without user interaction
|
// 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) {
|
func (s *Service) RefreshToken(ctx context.Context, refreshToken string, scope string) (*types.RefreshTokenResponse, error) {
|
||||||
// Validate refresh token
|
// Get and validate refresh token data
|
||||||
if !s.userProvider.TokenExists(refreshToken) {
|
tokenInfo, err := s.getRefreshTokenData(refreshToken)
|
||||||
return nil, &types.ErrorResponse{
|
|
||||||
Code: types.ErrorInvalidGrant,
|
|
||||||
ErrorDescription: "Invalid refresh token",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get token data
|
|
||||||
tokenData, err := s.userProvider.GetTokenData(refreshToken)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, err
|
||||||
Code: types.ErrorInvalidGrant,
|
|
||||||
ErrorDescription: "Invalid refresh token",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract client ID from token data
|
// Extract client ID from token data
|
||||||
clientID, ok := tokenData["client_id"].(string)
|
clientID, ok := tokenInfo["client_id"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, &types.ErrorResponse{
|
||||||
Code: types.ErrorInvalidGrant,
|
Code: types.ErrorInvalidGrant,
|
||||||
|
|
@ -221,8 +225,17 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, scope s
|
||||||
}
|
}
|
||||||
response.RefreshToken = newRefreshToken
|
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
|
// Revoke old refresh token
|
||||||
s.userProvider.RevokeToken(refreshToken)
|
s.revokeRefreshToken(refreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|
@ -239,25 +252,14 @@ func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string) (*typ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate old token
|
// Get and validate refresh token data
|
||||||
if !s.userProvider.TokenExists(oldToken) {
|
tokenInfo, err := s.getRefreshTokenData(oldToken)
|
||||||
return nil, &types.ErrorResponse{
|
|
||||||
Code: types.ErrorInvalidGrant,
|
|
||||||
ErrorDescription: "Invalid refresh token",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get token data
|
|
||||||
tokenData, err := s.userProvider.GetTokenData(oldToken)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, err
|
||||||
Code: types.ErrorInvalidGrant,
|
|
||||||
ErrorDescription: "Invalid refresh token",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract client ID from token data
|
// Extract client ID from token data
|
||||||
clientID, ok := tokenData["client_id"].(string)
|
clientID, ok := tokenInfo["client_id"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, &types.ErrorResponse{
|
||||||
Code: types.ErrorInvalidGrant,
|
Code: types.ErrorInvalidGrant,
|
||||||
|
|
@ -282,15 +284,18 @@ func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string) (*typ
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke old token
|
// Store new refresh token
|
||||||
err = s.userProvider.RevokeToken(oldToken)
|
err = s.storeRefreshTokenWithScope(newRefreshToken, clientID, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, &types.ErrorResponse{
|
||||||
Code: types.ErrorServerError,
|
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{
|
response := &types.RefreshTokenResponse{
|
||||||
AccessToken: newAccessToken,
|
AccessToken: newAccessToken,
|
||||||
RefreshToken: newRefreshToken,
|
RefreshToken: newRefreshToken,
|
||||||
|
|
@ -305,8 +310,34 @@ func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string) (*typ
|
||||||
|
|
||||||
// handleAuthorizationCodeGrant handles authorization code grant
|
// handleAuthorizationCodeGrant handles authorization code grant
|
||||||
func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *types.ClientInfo, code string, codeVerifier string) (*types.Token, error) {
|
func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *types.ClientInfo, code string, codeVerifier string) (*types.Token, error) {
|
||||||
// TODO: Validate authorization code
|
// Get and validate authorization code data
|
||||||
// In a real implementation, this would validate the authorization code and extract user info
|
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
|
// Generate access token
|
||||||
accessToken, err := s.generateAccessToken(client.ClientID)
|
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{
|
token := &types.Token{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
|
|
@ -333,6 +384,15 @@ func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
token.RefreshToken = refreshToken
|
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
|
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{
|
token := &types.Token{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
|
|
@ -360,12 +429,10 @@ func (s *Service) handleClientCredentialsGrant(ctx context.Context, client *type
|
||||||
|
|
||||||
// handleRefreshTokenGrant handles refresh token grant
|
// handleRefreshTokenGrant handles refresh token grant
|
||||||
func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.ClientInfo, refreshToken string) (*types.Token, error) {
|
func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.ClientInfo, refreshToken string) (*types.Token, error) {
|
||||||
// Validate refresh token
|
// Get and validate refresh token data
|
||||||
if !s.userProvider.TokenExists(refreshToken) {
|
refreshTokenInfo, err := s.getRefreshTokenData(refreshToken)
|
||||||
return nil, &types.ErrorResponse{
|
if err != nil {
|
||||||
Code: types.ErrorInvalidGrant,
|
return nil, err
|
||||||
ErrorDescription: "Invalid refresh token",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// 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{
|
token := &types.Token{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
|
|
@ -394,8 +481,17 @@ func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.Cli
|
||||||
}
|
}
|
||||||
token.RefreshToken = newRefreshToken
|
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
|
// Revoke old refresh token
|
||||||
s.userProvider.RevokeToken(refreshToken)
|
s.revokeRefreshToken(refreshToken)
|
||||||
} else {
|
} else {
|
||||||
// Reuse the same refresh token
|
// Reuse the same refresh token
|
||||||
token.RefreshToken = refreshToken
|
token.RefreshToken = refreshToken
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,11 @@ func TestToken(t *testing.T) {
|
||||||
|
|
||||||
t.Run("authorization code grant", func(t *testing.T) {
|
t.Run("authorization code grant", func(t *testing.T) {
|
||||||
clientID := testClients[0].ClientID // confidential client
|
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, "")
|
token, err := service.Token(ctx, types.GrantTypeAuthorizationCode, code, clientID, "")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -245,11 +249,9 @@ func TestToken(t *testing.T) {
|
||||||
clientID := testClients[0].ClientID // confidential client
|
clientID := testClients[0].ClientID // confidential client
|
||||||
refreshToken := "test-refresh-token"
|
refreshToken := "test-refresh-token"
|
||||||
|
|
||||||
// Mock token existence
|
// Store refresh token using the new method
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, clientID)
|
||||||
"client_id": clientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
token, err := service.Token(ctx, types.GrantTypeRefreshToken, refreshToken, clientID, "")
|
token, err := service.Token(ctx, types.GrantTypeRefreshToken, refreshToken, clientID, "")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -262,7 +264,12 @@ func TestToken(t *testing.T) {
|
||||||
|
|
||||||
t.Run("invalid client", func(t *testing.T) {
|
t.Run("invalid client", func(t *testing.T) {
|
||||||
clientID := "invalid-client-id"
|
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, "")
|
token, err := service.Token(ctx, types.GrantTypeAuthorizationCode, code, clientID, "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
@ -276,7 +283,11 @@ func TestToken(t *testing.T) {
|
||||||
|
|
||||||
t.Run("unsupported grant type", func(t *testing.T) {
|
t.Run("unsupported grant type", func(t *testing.T) {
|
||||||
clientID := testClients[0].ClientID
|
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, "")
|
token, err := service.Token(ctx, "unsupported_grant_type", code, clientID, "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
@ -301,19 +312,18 @@ func TestRevoke(t *testing.T) {
|
||||||
|
|
||||||
t.Run("successful token revocation", func(t *testing.T) {
|
t.Run("successful token revocation", func(t *testing.T) {
|
||||||
token := "test-access-token"
|
token := "test-access-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store token first
|
// Store token using the new method
|
||||||
service.userProvider.StoreToken(token, map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, "", "")
|
||||||
"client_id": testClients[0].ClientID,
|
|
||||||
"type": "access_token",
|
|
||||||
}, time.Hour)
|
|
||||||
|
|
||||||
err := service.Revoke(ctx, token, "access_token")
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify token is revoked
|
err = service.Revoke(ctx, token, "access_token")
|
||||||
exists := service.userProvider.TokenExists(token)
|
assert.NoError(t, err)
|
||||||
assert.False(t, exists)
|
|
||||||
|
// 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) {
|
t.Run("revoke non-existent token", func(t *testing.T) {
|
||||||
|
|
@ -324,25 +334,24 @@ func TestRevoke(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify token still doesn't exist
|
// Verify token still doesn't exist
|
||||||
exists := service.userProvider.TokenExists(token)
|
_, err = service.getAccessTokenData(token)
|
||||||
assert.False(t, exists)
|
assert.Error(t, err) // Should return error since token doesn't exist
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("revoke refresh token", func(t *testing.T) {
|
t.Run("revoke refresh token", func(t *testing.T) {
|
||||||
token := "test-refresh-token"
|
token := "test-refresh-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store token first
|
// Store refresh token using the new method
|
||||||
service.userProvider.StoreToken(token, map[string]interface{}{
|
err := service.storeRefreshToken(token, clientID)
|
||||||
"client_id": testClients[0].ClientID,
|
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
err := service.Revoke(ctx, token, "refresh_token")
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify token is revoked
|
err = service.Revoke(ctx, token, "refresh_token")
|
||||||
exists := service.userProvider.TokenExists(token)
|
assert.NoError(t, err)
|
||||||
assert.False(t, exists)
|
|
||||||
|
// 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"
|
refreshToken := "test-refresh-token"
|
||||||
clientID := testClients[0].ClientID
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token using the new method
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, clientID)
|
||||||
"client_id": clientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
response, err := service.RefreshToken(ctx, refreshToken, "openid profile")
|
response, err := service.RefreshToken(ctx, refreshToken, "openid profile")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -379,11 +386,9 @@ func TestRefreshToken(t *testing.T) {
|
||||||
refreshToken := "test-refresh-token-rotation"
|
refreshToken := "test-refresh-token-rotation"
|
||||||
clientID := testClients[0].ClientID
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token using the new method
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, clientID)
|
||||||
"client_id": clientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
// Ensure rotation is enabled
|
// Ensure rotation is enabled
|
||||||
assert.True(t, service.config.Features.RefreshTokenRotationEnabled)
|
assert.True(t, service.config.Features.RefreshTokenRotationEnabled)
|
||||||
|
|
@ -417,10 +422,8 @@ func TestRefreshToken(t *testing.T) {
|
||||||
refreshToken := "test-refresh-token-invalid-client"
|
refreshToken := "test-refresh-token-invalid-client"
|
||||||
|
|
||||||
// Store refresh token with invalid client
|
// Store refresh token with invalid client
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, "invalid-client-id")
|
||||||
"client_id": "invalid-client-id",
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
response, err := service.RefreshToken(ctx, refreshToken, "")
|
response, err := service.RefreshToken(ctx, refreshToken, "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
@ -437,10 +440,8 @@ func TestRefreshToken(t *testing.T) {
|
||||||
clientID := testClients[0].ClientID
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, clientID)
|
||||||
"client_id": clientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
response, err := service.RefreshToken(ctx, refreshToken, "invalid-scope")
|
response, err := service.RefreshToken(ctx, refreshToken, "invalid-scope")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
@ -457,10 +458,8 @@ func TestRefreshToken(t *testing.T) {
|
||||||
clientID := testClients[0].ClientID
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, clientID)
|
||||||
"client_id": clientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
response, err := service.RefreshToken(ctx, refreshToken, "")
|
response, err := service.RefreshToken(ctx, refreshToken, "")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -484,11 +483,9 @@ func TestRotateRefreshToken(t *testing.T) {
|
||||||
oldToken := "old-refresh-token"
|
oldToken := "old-refresh-token"
|
||||||
clientID := testClients[0].ClientID
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store old refresh token
|
// Store old refresh token using the new method
|
||||||
service.userProvider.StoreToken(oldToken, map[string]interface{}{
|
err := service.storeRefreshToken(oldToken, clientID)
|
||||||
"client_id": clientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
// Ensure rotation is enabled
|
// Ensure rotation is enabled
|
||||||
assert.True(t, service.config.Features.RefreshTokenRotationEnabled)
|
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) {
|
t.Run("rotation with malformed token data", func(t *testing.T) {
|
||||||
oldToken := "malformed-refresh-token"
|
oldToken := "malformed-refresh-token"
|
||||||
|
|
||||||
// Store token with malformed data
|
// Store token with malformed data directly in store
|
||||||
service.userProvider.StoreToken(oldToken, map[string]interface{}{
|
malformedData := map[string]interface{}{
|
||||||
"invalid_field": "invalid_value",
|
"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)
|
response, err := service.RotateRefreshToken(ctx, oldToken)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
@ -575,7 +574,12 @@ func TestHandleAuthorizationCodeGrant(t *testing.T) {
|
||||||
GrantTypes: []string{types.GrantTypeAuthorizationCode, types.GrantTypeRefreshToken},
|
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.NoError(t, err)
|
||||||
assert.NotNil(t, token)
|
assert.NotNil(t, token)
|
||||||
assert.NotEmpty(t, token.AccessToken)
|
assert.NotEmpty(t, token.AccessToken)
|
||||||
|
|
@ -590,7 +594,12 @@ func TestHandleAuthorizationCodeGrant(t *testing.T) {
|
||||||
GrantTypes: []string{types.GrantTypeAuthorizationCode}, // No refresh token
|
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.NoError(t, err)
|
||||||
assert.NotNil(t, token)
|
assert.NotNil(t, token)
|
||||||
assert.NotEmpty(t, token.AccessToken)
|
assert.NotEmpty(t, token.AccessToken)
|
||||||
|
|
@ -636,11 +645,9 @@ func TestHandleRefreshTokenGrant(t *testing.T) {
|
||||||
|
|
||||||
refreshToken := "test-refresh-token-grant"
|
refreshToken := "test-refresh-token-grant"
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token using the new method
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, client.ClientID)
|
||||||
"client_id": client.ClientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
// Ensure rotation is enabled
|
// Ensure rotation is enabled
|
||||||
assert.True(t, service.config.Features.RefreshTokenRotationEnabled)
|
assert.True(t, service.config.Features.RefreshTokenRotationEnabled)
|
||||||
|
|
@ -674,11 +681,9 @@ func TestHandleRefreshTokenGrant(t *testing.T) {
|
||||||
|
|
||||||
refreshToken := "test-refresh-token-no-rotation"
|
refreshToken := "test-refresh-token-no-rotation"
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token using the new method
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
err := service.storeRefreshToken(refreshToken, client.ClientID)
|
||||||
"client_id": client.ClientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
token, err := service.handleRefreshTokenGrant(ctx, client, refreshToken)
|
token, err := service.handleRefreshTokenGrant(ctx, client, refreshToken)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -746,10 +751,8 @@ func TestCoreIntegration(t *testing.T) {
|
||||||
assert.NotEmpty(t, token.RefreshToken)
|
assert.NotEmpty(t, token.RefreshToken)
|
||||||
|
|
||||||
// Store the refresh token for later use
|
// Store the refresh token for later use
|
||||||
service.userProvider.StoreToken(token.RefreshToken, map[string]interface{}{
|
err = service.storeRefreshToken(token.RefreshToken, testClients[0].ClientID)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"type": "refresh_token",
|
|
||||||
}, 24*time.Hour)
|
|
||||||
|
|
||||||
// Step 3: Refresh token
|
// Step 3: Refresh token
|
||||||
refreshResponse, err := service.RefreshToken(ctx, token.RefreshToken, "openid profile")
|
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
|
// Generate multiple tokens and ensure they're unique
|
||||||
for i := 0; i < 10; i++ {
|
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.NoError(t, err)
|
||||||
assert.NotNil(t, token)
|
assert.NotNil(t, token)
|
||||||
assert.NotEmpty(t, token.AccessToken)
|
assert.NotEmpty(t, token.AccessToken)
|
||||||
|
|
@ -870,14 +878,16 @@ func TestCoreEdgeCases(t *testing.T) {
|
||||||
refreshToken := "test-refresh-token-integrity"
|
refreshToken := "test-refresh-token-integrity"
|
||||||
clientID := testClients[0].ClientID
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store refresh token with additional data
|
// Store refresh token with additional data directly in store
|
||||||
service.userProvider.StoreToken(refreshToken, map[string]interface{}{
|
tokenData := map[string]interface{}{
|
||||||
"client_id": clientID,
|
"client_id": clientID,
|
||||||
"type": "refresh_token",
|
"type": "refresh_token",
|
||||||
"user_id": "test-user-123",
|
"user_id": "test-user-123",
|
||||||
"issued_at": time.Now().Unix(),
|
"issued_at": time.Now().Unix(),
|
||||||
"extra_data": "should-be-preserved",
|
"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, "")
|
response, err := service.RefreshToken(ctx, refreshToken, "")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,20 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Introspect returns information about an access token
|
// Introspect returns information about an access token
|
||||||
// This endpoint allows resource servers to validate tokens
|
// This endpoint allows resource servers to validate tokens
|
||||||
func (s *Service) Introspect(ctx context.Context, token string) (*types.TokenIntrospectionResponse, error) {
|
func (s *Service) Introspect(ctx context.Context, token string) (*types.TokenIntrospectionResponse, error) {
|
||||||
// Try to get token data from user provider
|
// Try to get token data from OAuth store
|
||||||
tokenData, err := s.userProvider.GetTokenData(token)
|
tokenInfo, err := s.getAccessTokenData(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &types.TokenIntrospectionResponse{Active: false}, nil
|
return &types.TokenIntrospectionResponse{Active: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token exists and is valid
|
// Check if token exists and is valid
|
||||||
if tokenData == nil {
|
if tokenInfo == nil {
|
||||||
return &types.TokenIntrospectionResponse{Active: false}, 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
|
// 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
|
response.ClientID = clientID
|
||||||
}
|
}
|
||||||
if username, ok := tokenData["username"].(string); ok {
|
if subject, ok := tokenInfo["subject"].(string); ok {
|
||||||
response.Username = username
|
|
||||||
}
|
|
||||||
if subject, ok := tokenData["sub"].(string); ok {
|
|
||||||
response.Subject = subject
|
response.Subject = subject
|
||||||
}
|
}
|
||||||
if tokenType, ok := tokenData["token_type"].(string); ok {
|
if tokenType, ok := tokenInfo["token_type"].(string); ok {
|
||||||
response.TokenType = tokenType
|
response.TokenType = tokenType
|
||||||
} else {
|
} else {
|
||||||
response.TokenType = "Bearer"
|
response.TokenType = "Bearer"
|
||||||
}
|
}
|
||||||
if scope, ok := tokenData["scope"].(string); ok {
|
if scope, ok := tokenInfo["scope"].(string); ok {
|
||||||
response.Scope = scope
|
response.Scope = scope
|
||||||
}
|
}
|
||||||
if exp, ok := tokenData["exp"].(int64); ok {
|
if exp, ok := tokenInfo["expires_at"].(int64); ok {
|
||||||
response.ExpiresAt = exp
|
response.ExpiresAt = exp
|
||||||
}
|
}
|
||||||
if iat, ok := tokenData["iat"].(int64); ok {
|
if iat, ok := tokenInfo["issued_at"].(int64); ok {
|
||||||
response.IssuedAt = iat
|
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
|
// Check if token is expired
|
||||||
if response.ExpiresAt > 0 && time.Now().Unix() > response.ExpiresAt {
|
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)
|
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
|
// generateRefreshToken generates a new refresh token
|
||||||
func (s *Service) generateRefreshToken(clientID string) (string, error) {
|
func (s *Service) generateRefreshToken(clientID string) (string, error) {
|
||||||
return s.generateToken("rfk", clientID)
|
return s.generateToken("rfk", clientID)
|
||||||
|
|
@ -254,7 +319,159 @@ func (s *Service) generateRefreshToken(clientID string) (string, error) {
|
||||||
|
|
||||||
// generateAuthorizationCode generates a new authorization code
|
// generateAuthorizationCode generates a new authorization code
|
||||||
func (s *Service) generateAuthorizationCode(clientID string, state string) (string, error) {
|
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
|
// generateExchangedToken generates a new token for token exchange
|
||||||
|
|
|
||||||
|
|
@ -22,50 +22,36 @@ func TestIntrospect(t *testing.T) {
|
||||||
|
|
||||||
t.Run("valid active token", func(t *testing.T) {
|
t.Run("valid active token", func(t *testing.T) {
|
||||||
token := "test-active-token"
|
token := "test-active-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store token data
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
response, err := service.Introspect(ctx, token)
|
response, err := service.Introspect(ctx, token)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, response)
|
assert.NotNil(t, response)
|
||||||
assert.True(t, response.Active)
|
assert.True(t, response.Active)
|
||||||
assert.Equal(t, testClients[0].ClientID, response.ClientID)
|
assert.Equal(t, clientID, response.ClientID)
|
||||||
assert.Equal(t, testUsers[0].Username, response.Username)
|
assert.Equal(t, subject, response.Subject)
|
||||||
assert.Equal(t, testUsers[0].Subject, response.Subject)
|
|
||||||
assert.Equal(t, "Bearer", response.TokenType)
|
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.ExpiresAt > 0)
|
||||||
assert.True(t, response.IssuedAt > 0)
|
assert.True(t, response.IssuedAt > 0)
|
||||||
assert.True(t, response.NotBefore > 0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("expired token", func(t *testing.T) {
|
t.Run("expired token", func(t *testing.T) {
|
||||||
token := "test-expired-token"
|
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
|
// Store expired token using the helper method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessTokenWithExpiry(token, clientID, scope, subject, expiredTime)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
response, err := service.Introspect(ctx, token)
|
response, err := service.Introspect(ctx, token)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -84,43 +70,36 @@ func TestIntrospect(t *testing.T) {
|
||||||
|
|
||||||
t.Run("token with minimal data", func(t *testing.T) {
|
t.Run("token with minimal data", func(t *testing.T) {
|
||||||
token := "test-minimal-token"
|
token := "test-minimal-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
|
||||||
// Store minimal token data
|
// Store minimal token data using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, "", "")
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
}
|
|
||||||
|
|
||||||
service.userProvider.StoreToken(token, tokenData, time.Hour)
|
|
||||||
|
|
||||||
response, err := service.Introspect(ctx, token)
|
response, err := service.Introspect(ctx, token)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, response)
|
assert.NotNil(t, response)
|
||||||
assert.True(t, response.Active)
|
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.Equal(t, "Bearer", response.TokenType) // Default token type
|
||||||
assert.Empty(t, response.Username)
|
|
||||||
assert.Empty(t, response.Subject)
|
assert.Empty(t, response.Subject)
|
||||||
assert.Empty(t, response.Scope)
|
assert.Empty(t, response.Scope)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token with no expiration", func(t *testing.T) {
|
t.Run("token with no expiration", func(t *testing.T) {
|
||||||
token := "test-no-expiry-token"
|
token := "test-no-expiry-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile"
|
||||||
|
|
||||||
// Store token data without expiration
|
// Store token using the new method (it will still have expiration based on config)
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, "")
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"username": testUsers[0].Username,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"scope": "openid profile",
|
|
||||||
}
|
|
||||||
|
|
||||||
service.userProvider.StoreToken(token, tokenData, time.Hour)
|
|
||||||
|
|
||||||
response, err := service.Introspect(ctx, token)
|
response, err := service.Introspect(ctx, token)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, response)
|
assert.NotNil(t, response)
|
||||||
assert.True(t, response.Active) // Should be active since no expiration
|
assert.True(t, response.Active) // Should be active since not expired yet
|
||||||
assert.Equal(t, int64(0), response.ExpiresAt)
|
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) {
|
t.Run("successful token exchange", func(t *testing.T) {
|
||||||
subjectToken := "test-subject-token"
|
subjectToken := "test-subject-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store subject token
|
// Store subject token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(subjectToken, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
// Test token exchange
|
// Test token exchange
|
||||||
response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "openid profile")
|
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) {
|
t.Run("token exchange with inactive subject token", func(t *testing.T) {
|
||||||
subjectToken := "test-inactive-token"
|
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
|
// Store expired token using the helper method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessTokenWithExpiry(subjectToken, clientID, scope, subject, expiredTime)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "openid profile")
|
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.Error(t, err)
|
||||||
|
|
@ -222,19 +190,13 @@ func TestTokenExchange(t *testing.T) {
|
||||||
|
|
||||||
t.Run("token exchange with invalid audience", func(t *testing.T) {
|
t.Run("token exchange with invalid audience", func(t *testing.T) {
|
||||||
subjectToken := "test-subject-token-aud"
|
subjectToken := "test-subject-token-aud"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store subject token
|
// Store subject token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(subjectToken, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
// Test with valid audience (should succeed since audience validation is not enforced)
|
// 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")
|
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) {
|
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
|
// Store subject token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(subjectToken, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
// Test with empty audience
|
||||||
|
|
||||||
// Test with empty audience (should succeed as audience validation is skipped)
|
|
||||||
response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "", "openid profile")
|
response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "", "openid profile")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, response)
|
assert.NotNil(t, response)
|
||||||
|
|
@ -270,46 +226,49 @@ func TestTokenExchange(t *testing.T) {
|
||||||
|
|
||||||
t.Run("token exchange with invalid scope", func(t *testing.T) {
|
t.Run("token exchange with invalid scope", func(t *testing.T) {
|
||||||
subjectToken := "test-subject-token-scope"
|
subjectToken := "test-subject-token-scope"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store subject token
|
// Store subject token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(subjectToken, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
// Test with invalid scope (should succeed since scope validation is basic)
|
||||||
|
|
||||||
// Test with invalid scope
|
|
||||||
response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "invalid-scope")
|
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.Error(t, err)
|
||||||
assert.Nil(t, response)
|
assert.Nil(t, response)
|
||||||
|
|
||||||
oauthErr, ok := err.(*types.ErrorResponse)
|
oauthErr, ok := err.(*types.ErrorResponse)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, types.ErrorInvalidScope, oauthErr.Code)
|
assert.Equal(t, types.ErrorInvalidGrant, oauthErr.Code)
|
||||||
assert.Equal(t, "Invalid scope", oauthErr.ErrorDescription)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token exchange without audience and scope", func(t *testing.T) {
|
t.Run("token exchange without audience and scope", func(t *testing.T) {
|
||||||
subjectToken := "test-subject-token-minimal"
|
subjectToken := "test-subject-token-minimal"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store subject token
|
// Store subject token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(subjectToken, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
// Test without audience and scope
|
// Test without audience and scope
|
||||||
response, err := service.TokenExchange(ctx, subjectToken, "urn:ietf:params:oauth:token-type:access_token", "https://api.example.com", "")
|
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) {
|
t.Run("valid audience", func(t *testing.T) {
|
||||||
token := "test-audience-token"
|
token := "test-audience-token"
|
||||||
expectedAudience := "https://api.example.com"
|
expectedAudience := "https://api.example.com"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store token without audience field first
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -360,19 +313,13 @@ func TestValidateTokenAudience(t *testing.T) {
|
||||||
t.Run("invalid audience", func(t *testing.T) {
|
t.Run("invalid audience", func(t *testing.T) {
|
||||||
token := "test-audience-token-invalid"
|
token := "test-audience-token-invalid"
|
||||||
expectedAudience := "https://api.example.com"
|
expectedAudience := "https://api.example.com"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store token without audience field
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -384,19 +331,13 @@ func TestValidateTokenAudience(t *testing.T) {
|
||||||
t.Run("no audience in token", func(t *testing.T) {
|
t.Run("no audience in token", func(t *testing.T) {
|
||||||
token := "test-no-audience-token"
|
token := "test-no-audience-token"
|
||||||
expectedAudience := "https://api.example.com"
|
expectedAudience := "https://api.example.com"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store token without audience
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -408,19 +349,14 @@ func TestValidateTokenAudience(t *testing.T) {
|
||||||
t.Run("inactive token", func(t *testing.T) {
|
t.Run("inactive token", func(t *testing.T) {
|
||||||
token := "test-inactive-audience-token"
|
token := "test-inactive-audience-token"
|
||||||
expectedAudience := "https://api.example.com"
|
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
|
// Store expired token using the helper method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessTokenWithExpiry(token, clientID, scope, subject, expiredTime)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
result, err := service.ValidateTokenAudience(ctx, token, expectedAudience)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -473,19 +409,13 @@ func TestValidateTokenBinding(t *testing.T) {
|
||||||
|
|
||||||
t.Run("DPoP token binding", func(t *testing.T) {
|
t.Run("DPoP token binding", func(t *testing.T) {
|
||||||
token := "test-dpop-binding-token"
|
token := "test-dpop-binding-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store active token
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
binding := &types.TokenBinding{
|
binding := &types.TokenBinding{
|
||||||
BindingType: types.TokenBindingTypeDPoP,
|
BindingType: types.TokenBindingTypeDPoP,
|
||||||
|
|
@ -500,19 +430,13 @@ func TestValidateTokenBinding(t *testing.T) {
|
||||||
|
|
||||||
t.Run("mTLS token binding", func(t *testing.T) {
|
t.Run("mTLS token binding", func(t *testing.T) {
|
||||||
token := "test-mtls-binding-token"
|
token := "test-mtls-binding-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store active token
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
binding := &types.TokenBinding{
|
binding := &types.TokenBinding{
|
||||||
BindingType: types.TokenBindingTypeMTLS,
|
BindingType: types.TokenBindingTypeMTLS,
|
||||||
|
|
@ -527,19 +451,13 @@ func TestValidateTokenBinding(t *testing.T) {
|
||||||
|
|
||||||
t.Run("certificate token binding", func(t *testing.T) {
|
t.Run("certificate token binding", func(t *testing.T) {
|
||||||
token := "test-cert-binding-token"
|
token := "test-cert-binding-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store active token
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
binding := &types.TokenBinding{
|
binding := &types.TokenBinding{
|
||||||
BindingType: types.TokenBindingTypeCertificate,
|
BindingType: types.TokenBindingTypeCertificate,
|
||||||
|
|
@ -554,19 +472,13 @@ func TestValidateTokenBinding(t *testing.T) {
|
||||||
|
|
||||||
t.Run("unknown binding type", func(t *testing.T) {
|
t.Run("unknown binding type", func(t *testing.T) {
|
||||||
token := "test-unknown-binding-token"
|
token := "test-unknown-binding-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store active token
|
// Store token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
binding := &types.TokenBinding{
|
binding := &types.TokenBinding{
|
||||||
BindingType: "unknown-binding-type",
|
BindingType: "unknown-binding-type",
|
||||||
|
|
@ -581,19 +493,14 @@ func TestValidateTokenBinding(t *testing.T) {
|
||||||
|
|
||||||
t.Run("inactive token", func(t *testing.T) {
|
t.Run("inactive token", func(t *testing.T) {
|
||||||
token := "test-inactive-binding-token"
|
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
|
// Store expired token using the helper method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessTokenWithExpiry(token, clientID, scope, subject, expiredTime)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
binding := &types.TokenBinding{
|
binding := &types.TokenBinding{
|
||||||
BindingType: types.TokenBindingTypeDPoP,
|
BindingType: types.TokenBindingTypeDPoP,
|
||||||
|
|
@ -767,18 +674,11 @@ func TestTokenIntegration(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, accessToken)
|
assert.NotEmpty(t, accessToken)
|
||||||
|
|
||||||
// Step 2: Store token data
|
// Step 2: Store token data using the new method
|
||||||
tokenData := map[string]interface{}{
|
scope := "openid profile email"
|
||||||
"client_id": clientID,
|
subject := testUsers[0].Subject
|
||||||
"username": testUsers[0].Username,
|
err = service.storeAccessToken(accessToken, clientID, scope, subject)
|
||||||
"sub": testUsers[0].Subject,
|
assert.NoError(t, err)
|
||||||
"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 3: Introspect token
|
// Step 3: Introspect token
|
||||||
introspection, err := service.Introspect(ctx, accessToken)
|
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) {
|
t.Run("introspection with malformed token data", func(t *testing.T) {
|
||||||
token := "test-malformed-token"
|
token := "test-malformed-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store token with mixed data types
|
// Store token using the new method (it will handle data types correctly)
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(token, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
// Should handle gracefully
|
// Should handle gracefully
|
||||||
response, err := service.Introspect(ctx, token)
|
response, err := service.Introspect(ctx, token)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, response)
|
assert.NotNil(t, response)
|
||||||
assert.True(t, response.Active)
|
assert.True(t, response.Active)
|
||||||
assert.Equal(t, testClients[0].ClientID, response.ClientID)
|
assert.Equal(t, clientID, response.ClientID)
|
||||||
assert.Empty(t, response.Username) // Should be empty due to type mismatch
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("token exchange with very long audience", func(t *testing.T) {
|
t.Run("token exchange with very long audience", func(t *testing.T) {
|
||||||
subjectToken := "test-long-audience-token"
|
subjectToken := "test-long-audience-token"
|
||||||
|
clientID := testClients[0].ClientID
|
||||||
|
scope := "openid profile email"
|
||||||
|
subject := testUsers[0].Subject
|
||||||
|
|
||||||
// Store subject token
|
// Store subject token using the new method
|
||||||
tokenData := map[string]interface{}{
|
err := service.storeAccessToken(subjectToken, clientID, scope, subject)
|
||||||
"client_id": testClients[0].ClientID,
|
assert.NoError(t, err)
|
||||||
"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)
|
|
||||||
|
|
||||||
// Very long audience
|
// Very long audience
|
||||||
longAudience := strings.Repeat("https://very-long-audience-name.example.com/", 100)
|
longAudience := strings.Repeat("https://very-long-audience-name.example.com/", 100)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -114,3 +115,210 @@ func TestOAuthRegister(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuthAuthorize(t *testing.T) {
|
||||||
|
serverURL := Prepare(t)
|
||||||
|
defer Clean()
|
||||||
|
|
||||||
|
// Register a test client for realistic testing
|
||||||
|
testClient := RegisterTestClient(t, "OAuth Test Client", []string{"http://localhost/callback"})
|
||||||
|
defer CleanupTestClient(t, testClient.ClientID)
|
||||||
|
|
||||||
|
// Prepare test data
|
||||||
|
endpoint := serverURL + Server.Config.BaseURL + "/oauth/authorize"
|
||||||
|
t.Logf("Testing authorize endpoint: %s", endpoint)
|
||||||
|
|
||||||
|
t.Run("Valid Authorization Request", func(t *testing.T) {
|
||||||
|
// Test valid authorization request with real client
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("client_id", testClient.ClientID) // Use real registered client ID
|
||||||
|
params.Set("response_type", "code")
|
||||||
|
params.Set("redirect_uri", testClient.RedirectURIs[0]) // Use registered redirect URI
|
||||||
|
params.Set("scope", "openid profile")
|
||||||
|
params.Set("state", "test-state-123")
|
||||||
|
|
||||||
|
requestURL := endpoint + "?" + params.Encode()
|
||||||
|
t.Logf("Making GET request to: %s", requestURL)
|
||||||
|
|
||||||
|
// Configure HTTP client to not follow redirects automatically
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(requestURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
t.Logf("Response status code: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
// Should redirect with either success (302) or error (302)
|
||||||
|
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||||
|
|
||||||
|
// Check redirect location
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
assert.NotEmpty(t, location, "Location header should be present")
|
||||||
|
t.Logf("Redirect location: %s", location)
|
||||||
|
|
||||||
|
// Parse redirect URL to check parameters
|
||||||
|
redirectURL, err := url.Parse(location)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Should contain either 'code' (success) or 'error' (failure) parameter
|
||||||
|
query := redirectURL.Query()
|
||||||
|
hasCode := query.Get("code") != ""
|
||||||
|
hasError := query.Get("error") != ""
|
||||||
|
assert.True(t, hasCode || hasError, "Redirect should contain either 'code' or 'error' parameter")
|
||||||
|
|
||||||
|
// State parameter should be preserved
|
||||||
|
assert.Equal(t, "test-state-123", query.Get("state"), "State parameter should be preserved")
|
||||||
|
|
||||||
|
t.Logf("Authorization result - Code: %s, Error: %s", query.Get("code"), query.Get("error"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Invalid Client ID", func(t *testing.T) {
|
||||||
|
// Test with invalid client ID
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("client_id", "invalid-client-id")
|
||||||
|
params.Set("response_type", "code")
|
||||||
|
params.Set("redirect_uri", "http://localhost/callback")
|
||||||
|
params.Set("scope", "openid profile")
|
||||||
|
params.Set("state", "test-state-456")
|
||||||
|
|
||||||
|
requestURL := endpoint + "?" + params.Encode()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(requestURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||||
|
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
redirectURL, err := url.Parse(location)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
query := redirectURL.Query()
|
||||||
|
assert.Equal(t, "invalid_client", query.Get("error"), "Should return invalid_client error")
|
||||||
|
assert.Equal(t, "test-state-456", query.Get("state"), "State should be preserved")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Valid Authorization Request via POST", func(t *testing.T) {
|
||||||
|
// Test valid authorization request with POST method
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("client_id", testClient.ClientID)
|
||||||
|
form.Set("response_type", "code")
|
||||||
|
form.Set("redirect_uri", testClient.RedirectURIs[0])
|
||||||
|
form.Set("scope", "openid profile")
|
||||||
|
form.Set("state", "test-post-state-789")
|
||||||
|
|
||||||
|
t.Logf("Making POST request to: %s", endpoint)
|
||||||
|
|
||||||
|
// Configure HTTP client to not follow redirects automatically
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.PostForm(endpoint, form)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
t.Logf("Response status code: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
// Should redirect with either success (302) or error (302)
|
||||||
|
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||||
|
|
||||||
|
// Check redirect location
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
assert.NotEmpty(t, location, "Location header should be present")
|
||||||
|
t.Logf("Redirect location: %s", location)
|
||||||
|
|
||||||
|
// Parse redirect URL to check parameters
|
||||||
|
redirectURL, err := url.Parse(location)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Should contain either 'code' (success) or 'error' (failure) parameter
|
||||||
|
query := redirectURL.Query()
|
||||||
|
hasCode := query.Get("code") != ""
|
||||||
|
hasError := query.Get("error") != ""
|
||||||
|
assert.True(t, hasCode || hasError, "Redirect should contain either 'code' or 'error' parameter")
|
||||||
|
|
||||||
|
// State parameter should be preserved
|
||||||
|
assert.Equal(t, "test-post-state-789", query.Get("state"), "State parameter should be preserved")
|
||||||
|
|
||||||
|
t.Logf("Authorization result (POST) - Code: %s, Error: %s", query.Get("code"), query.Get("error"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Invalid Response Type via POST", func(t *testing.T) {
|
||||||
|
// Test with invalid response type using POST
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("client_id", testClient.ClientID)
|
||||||
|
form.Set("response_type", "token") // Implicit flow - deprecated in OAuth 2.1
|
||||||
|
form.Set("redirect_uri", testClient.RedirectURIs[0])
|
||||||
|
form.Set("scope", "openid profile")
|
||||||
|
form.Set("state", "test-invalid-response-type")
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.PostForm(endpoint, form)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||||
|
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
redirectURL, err := url.Parse(location)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
query := redirectURL.Query()
|
||||||
|
assert.Equal(t, "unsupported_response_type", query.Get("error"), "Should return unsupported_response_type error")
|
||||||
|
assert.Equal(t, "test-invalid-response-type", query.Get("state"), "State should be preserved")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Missing Required Parameters via POST", func(t *testing.T) {
|
||||||
|
// Test with missing client_id using POST
|
||||||
|
form := url.Values{}
|
||||||
|
// Missing client_id
|
||||||
|
form.Set("response_type", "code")
|
||||||
|
form.Set("redirect_uri", testClient.RedirectURIs[0])
|
||||||
|
form.Set("scope", "openid profile")
|
||||||
|
form.Set("state", "test-missing-client-id")
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.PostForm(endpoint, form)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||||
|
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
redirectURL, err := url.Parse(location)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
query := redirectURL.Query()
|
||||||
|
assert.Equal(t, "invalid_request", query.Get("error"), "Should return invalid_request error")
|
||||||
|
assert.Equal(t, "test-missing-client-id", query.Get("state"), "State should be preserved")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
457
openapi/oauth_token_test.go
Normal file
457
openapi/oauth_token_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -169,6 +170,255 @@ func Clean() {
|
||||||
test.Clean()
|
test.Clean()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterTestClient registers a test OAuth client and returns the client information.
|
||||||
|
//
|
||||||
|
// AI ASSISTANT INSTRUCTIONS:
|
||||||
|
// Use this function to create test OAuth clients for testing OAuth endpoints.
|
||||||
|
// This function provides realistic test clients that can be used for authentication flows.
|
||||||
|
// ALWAYS clean up test clients using CleanupTestClient() to prevent test interference.
|
||||||
|
//
|
||||||
|
// Usage pattern:
|
||||||
|
//
|
||||||
|
// func TestOAuthEndpoint(t *testing.T) {
|
||||||
|
// serverURL := Prepare(t)
|
||||||
|
// defer Clean()
|
||||||
|
//
|
||||||
|
// // Register a test client
|
||||||
|
// client := RegisterTestClient(t, "Test Client", []string{"http://localhost/callback"})
|
||||||
|
// defer CleanupTestClient(t, client.ClientID)
|
||||||
|
//
|
||||||
|
// // Use client.ClientID and client.ClientSecret in your tests
|
||||||
|
// // Example: test OAuth authorize with real client_id
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// PARAMETERS:
|
||||||
|
// - t: The test instance for error reporting
|
||||||
|
// - clientName: Human-readable name for the client (e.g., "Test Web App")
|
||||||
|
// - redirectURIs: List of valid redirect URIs for the client
|
||||||
|
//
|
||||||
|
// RETURN VALUE:
|
||||||
|
// Returns a pointer to types.ClientInfo containing:
|
||||||
|
// - ClientID: Generated unique client identifier
|
||||||
|
// - ClientSecret: Generated client secret (for confidential clients)
|
||||||
|
// - RedirectURIs: The provided redirect URIs
|
||||||
|
// - Other OAuth client metadata
|
||||||
|
//
|
||||||
|
// ERROR HANDLING:
|
||||||
|
// If client registration fails, the test will fail immediately with a descriptive error message.
|
||||||
|
func RegisterTestClient(t *testing.T, clientName string, redirectURIs []string) *types.ClientInfo {
|
||||||
|
if Server == nil || Server.OAuth == nil {
|
||||||
|
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dynamic client registration request
|
||||||
|
req := &types.DynamicClientRegistrationRequest{
|
||||||
|
ClientName: clientName,
|
||||||
|
RedirectURIs: redirectURIs,
|
||||||
|
GrantTypes: []string{
|
||||||
|
"authorization_code",
|
||||||
|
"refresh_token",
|
||||||
|
"client_credentials",
|
||||||
|
},
|
||||||
|
ResponseTypes: []string{
|
||||||
|
"code",
|
||||||
|
},
|
||||||
|
ApplicationType: "web",
|
||||||
|
TokenEndpointAuthMethod: "client_secret_basic",
|
||||||
|
Scope: "openid profile email",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the client using the OAuth service
|
||||||
|
ctx := context.Background()
|
||||||
|
response, err := Server.OAuth.DynamicClientRegistration(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to register test client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert response to ClientInfo for easier usage
|
||||||
|
clientInfo := &types.ClientInfo{
|
||||||
|
ClientID: response.ClientID,
|
||||||
|
ClientSecret: response.ClientSecret,
|
||||||
|
ClientName: response.ClientName,
|
||||||
|
RedirectURIs: response.RedirectURIs,
|
||||||
|
GrantTypes: response.GrantTypes,
|
||||||
|
ResponseTypes: response.ResponseTypes,
|
||||||
|
ApplicationType: response.ApplicationType,
|
||||||
|
TokenEndpointAuthMethod: response.TokenEndpointAuthMethod,
|
||||||
|
Scope: response.Scope,
|
||||||
|
ClientURI: response.ClientURI,
|
||||||
|
LogoURI: response.LogoURI,
|
||||||
|
TosURI: response.TosURI,
|
||||||
|
PolicyURI: response.PolicyURI,
|
||||||
|
Contacts: response.Contacts,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Registered test client: %s (ID: %s)", clientName, clientInfo.ClientID)
|
||||||
|
return clientInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupTestClient removes a test OAuth client from the system.
|
||||||
|
//
|
||||||
|
// AI ASSISTANT INSTRUCTIONS:
|
||||||
|
// ALWAYS call this function to clean up test clients created with RegisterTestClient().
|
||||||
|
// Use defer to ensure cleanup happens even if tests fail or panic.
|
||||||
|
// Proper cleanup prevents test interference and maintains a clean test environment.
|
||||||
|
//
|
||||||
|
// Usage pattern:
|
||||||
|
//
|
||||||
|
// client := RegisterTestClient(t, "Test Client", []string{"http://localhost/callback"})
|
||||||
|
// defer CleanupTestClient(t, client.ClientID)
|
||||||
|
//
|
||||||
|
// PARAMETERS:
|
||||||
|
// - t: The test instance for error reporting
|
||||||
|
// - clientID: The client ID to remove (obtained from RegisterTestClient return value)
|
||||||
|
//
|
||||||
|
// ERROR HANDLING:
|
||||||
|
// If client deletion fails, logs an error but does not fail the test.
|
||||||
|
// This prevents cleanup failures from affecting test results.
|
||||||
|
func CleanupTestClient(t *testing.T, clientID string) {
|
||||||
|
if Server == nil || Server.OAuth == nil {
|
||||||
|
// Server might already be cleaned up, which is OK
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the client using the OAuth service
|
||||||
|
ctx := context.Background()
|
||||||
|
err := Server.OAuth.DeleteClient(ctx, clientID)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail the test - cleanup should be resilient
|
||||||
|
t.Logf("Warning: Failed to cleanup test client %s: %v", clientID, err)
|
||||||
|
} else {
|
||||||
|
t.Logf("Cleaned up test client: %s", clientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestClientCredentials creates a simple test client with just ID and secret for basic testing.
|
||||||
|
//
|
||||||
|
// AI ASSISTANT INSTRUCTIONS:
|
||||||
|
// Use this function when you need a quick test client without full OAuth registration.
|
||||||
|
// This is useful for testing non-OAuth endpoints or when you need predictable client credentials.
|
||||||
|
// This creates an in-memory client that doesn't persist and doesn't need cleanup.
|
||||||
|
//
|
||||||
|
// Usage pattern:
|
||||||
|
//
|
||||||
|
// clientID, clientSecret := CreateTestClientCredentials()
|
||||||
|
// // Use in Basic Auth or client_credentials grant tests
|
||||||
|
//
|
||||||
|
// RETURN VALUES:
|
||||||
|
// - clientID: A predictable test client ID
|
||||||
|
// - clientSecret: A predictable test client secret
|
||||||
|
//
|
||||||
|
// NOTE: This function creates temporary credentials and doesn't register them with the OAuth service.
|
||||||
|
// For full OAuth flow testing, use RegisterTestClient() instead.
|
||||||
|
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) {
|
func TestLoad(t *testing.T) {
|
||||||
serverURL := Prepare(t)
|
serverURL := Prepare(t)
|
||||||
defer Clean()
|
defer Clean()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue