Merge pull request #1027 from trheyi/main

Implement JWKS endpoint and enhance OAuth tests
This commit is contained in:
Max 2025-07-21 20:08:28 +08:00 committed by GitHub
commit a09ddd70ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1478 additions and 687 deletions

2
go.mod
View file

@ -34,6 +34,7 @@ require (
github.com/yaoapp/gou v0.10.3
github.com/yaoapp/kun v0.9.0
github.com/yaoapp/xun v0.9.0
go.mongodb.org/mongo-driver v1.17.3
golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0
golang.org/x/text v0.27.0
@ -134,7 +135,6 @@ require (
github.com/xuri/nfp v0.0.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect

View file

@ -15,12 +15,31 @@ func (openapi *OpenAPI) attachHelloWorld(base *gin.RouterGroup) {
hello := base.Group("/helloworld")
// Health check
hello.GET("/hello", openapi.helloWorldHello)
hello.POST("/hello", openapi.helloWorldHello)
hello.GET("/public", openapi.helloWorldPublic)
hello.POST("/public", openapi.helloWorldPublic)
// OAuth Protected Resource
hello.GET("/protected", openapi.OAuth.Guard, openapi.helloWorldProtected)
hello.POST("/protected", openapi.OAuth.Guard, openapi.helloWorldProtected)
}
// helloWorldHello is the handler for the hello world endpoint
func (openapi *OpenAPI) helloWorldHello(c *gin.Context) {
// helloWorldPublic is the handler for the hello world endpoint
func (openapi *OpenAPI) helloWorldPublic(c *gin.Context) {
serverTime := time.Now().Format(time.RFC3339)
c.JSON(http.StatusOK, gin.H{
"MESSAGE": "HELLO, WORLD",
"SERVER_TIME": serverTime,
"VERSION": share.VERSION,
"PRVERSION": share.PRVERSION,
"CUI": share.CUI,
"PRCUI": share.PRCUI,
"APP": share.App.Name,
"APP_VERSION": share.App.Version,
})
}
// helloWorldHello is the handler for the hello world endpoint
func (openapi *OpenAPI) helloWorldProtected(c *gin.Context) {
serverTime := time.Now().Format(time.RFC3339)
c.JSON(http.StatusOK, gin.H{
"MESSAGE": "HELLO, WORLD",

View file

@ -9,7 +9,7 @@ import (
"github.com/yaoapp/yao/share"
)
func TestHelloWorldHello(t *testing.T) {
func TestHelloWorldPublic(t *testing.T) {
serverURL := Prepare(t)
defer Clean()
@ -25,14 +25,14 @@ func TestHelloWorldHello(t *testing.T) {
path string
}{
{
name: "GET hello endpoint",
name: "GET public endpoint",
method: "GET",
path: baseURL + "/helloworld/hello",
path: baseURL + "/helloworld/public",
},
{
name: "POST hello endpoint",
name: "POST public endpoint",
method: "POST",
path: baseURL + "/helloworld/hello",
path: baseURL + "/helloworld/public",
},
}
@ -77,3 +77,146 @@ func TestHelloWorldHello(t *testing.T) {
})
}
}
func TestHelloWorldProtected(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 authentication
client := RegisterTestClient(t, "Hello World Protected Test Client", []string{"https://localhost/callback"})
defer CleanupTestClient(t, client.ClientID)
// Obtain access token for authentication
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
tests := []struct {
name string
method string
path string
}{
{
name: "GET protected endpoint with valid token",
method: "GET",
path: baseURL + "/helloworld/protected",
},
{
name: "POST protected endpoint with valid token",
method: "POST",
path: baseURL + "/helloworld/protected",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create HTTP request with Bearer token
var req *http.Request
var err error
if tt.method == "GET" {
req, err = http.NewRequest("GET", serverURL+tt.path, nil)
} else {
req, err = http.NewRequest("POST", serverURL+tt.path, nil)
req.Header.Set("Content-Type", "application/json")
}
assert.NoError(t, err)
// Add Bearer token for authentication
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
// Make HTTP request
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Check status code
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Parse JSON response
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// Verify response structure and content (same as public endpoint)
assert.Equal(t, "HELLO, WORLD", response["MESSAGE"])
assert.NotEmpty(t, response["SERVER_TIME"])
assert.Equal(t, share.VERSION, response["VERSION"])
assert.Equal(t, share.PRVERSION, response["PRVERSION"])
assert.Equal(t, share.CUI, response["CUI"])
assert.Equal(t, share.PRCUI, response["PRCUI"])
assert.Equal(t, share.App.Name, response["APP"])
assert.Equal(t, share.App.Version, response["APP_VERSION"])
// Check that SERVER_TIME is a valid timestamp format
serverTime, ok := response["SERVER_TIME"].(string)
assert.True(t, ok)
assert.NotEmpty(t, serverTime)
t.Logf("Protected endpoint accessed successfully with token: %s", tokenInfo.AccessToken[:20]+"...")
})
}
}
func TestHelloWorldProtectedUnauthorized(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
}
tests := []struct {
name string
method string
path string
description string
}{
{
name: "GET protected endpoint without token",
method: "GET",
path: baseURL + "/helloworld/protected",
description: "No Authorization header",
},
{
name: "POST protected endpoint without token",
method: "POST",
path: baseURL + "/helloworld/protected",
description: "No Authorization header",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create HTTP request without Authorization header
var req *http.Request
var err error
if tt.method == "GET" {
req, err = http.NewRequest("GET", serverURL+tt.path, nil)
} else {
req, err = http.NewRequest("POST", serverURL+tt.path, nil)
req.Header.Set("Content-Type", "application/json")
}
assert.NoError(t, err)
// Make HTTP request (no Authorization header)
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
// Should return 401 Unauthorized for protected endpoint without token
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
t.Logf("Protected endpoint correctly rejected unauthorized request: %s", tt.description)
})
}
}

View file

@ -316,14 +316,26 @@ func (openapi *OpenAPI) handleTokenExchangeGrant(c *gin.Context) {
// oauthRevoke handles token revocation - RFC 7009
func (openapi *OpenAPI) oauthRevoke(c *gin.Context) {
token := c.PostForm("token")
tokenTypeHint := c.PostForm("token_type_hint") // Optional hint about token type
if token == "" {
openapi.respondWithError(c, StatusBadRequest, ErrInvalidRequest)
return
}
// TODO: Implement token revocation logic
c.Status(StatusNoContent)
// Call OAuth service to revoke the token
err := openapi.OAuth.Revoke(c, token, tokenTypeHint)
if err != nil {
// OAuth spec requires returning 200 even for invalid tokens to prevent information leakage
// Only return error for server errors
if oauthErr, ok := err.(*ErrorResponse); ok && oauthErr.Code == ErrServerError.Code {
openapi.respondWithError(c, StatusInternalServerError, ErrServerError)
return
}
}
// RFC 7009: Return 200 OK for successful revocation (or invalid tokens)
c.Status(StatusOK)
}
// oauthIntrospect handles token introspection - RFC 7662
@ -335,10 +347,28 @@ func (openapi *OpenAPI) oauthIntrospect(c *gin.Context) {
return
}
// TODO: Implement token introspection logic
// Return inactive token for now
// Call OAuth service to introspect the token
introspectionResult, err := openapi.OAuth.Introspect(c, token)
if err != nil {
// Return inactive token response on error (RFC 7662)
response := &TokenIntrospectionResponse{
Active: false,
}
openapi.respondWithSuccess(c, StatusOK, response)
return
}
// Convert OAuth service response to API response format
response := &TokenIntrospectionResponse{
Active: false,
Active: introspectionResult.Active,
Scope: introspectionResult.Scope,
ClientID: introspectionResult.ClientID,
Username: introspectionResult.Username,
TokenType: introspectionResult.TokenType,
ExpiresAt: introspectionResult.ExpiresAt,
IssuedAt: introspectionResult.IssuedAt,
Subject: introspectionResult.Subject,
Audience: introspectionResult.Audience,
}
openapi.respondWithSuccess(c, StatusOK, response)
@ -346,12 +376,22 @@ func (openapi *OpenAPI) oauthIntrospect(c *gin.Context) {
// oauthJWKS returns JSON Web Key Set - RFC 7517
func (openapi *OpenAPI) oauthJWKS(c *gin.Context) {
// TODO: Implement JWKS generation
jwks := &JWKSResponse{
Keys: []JWK{},
jwks, err := openapi.OAuth.JWKS(c)
if err != nil {
openapi.respondWithError(c, StatusInternalServerError, ErrServerError)
return
}
openapi.respondWithSuccess(c, StatusOK, jwks)
// RFC 7517 compliance: Return JWKS directly as JSON without wrapper
// Set security headers for JWKS endpoint
c.Header("Cache-Control", "no-store")
c.Header("Pragma", "no-cache")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "no-referrer")
// Return JWKS directly as per RFC 7517
c.JSON(StatusOK, jwks)
}
// oauthUserInfo returns user information - OpenID Connect Core 1.0

View file

@ -71,6 +71,11 @@ func (s *Service) Authorize(ctx context.Context, request *types.AuthorizationReq
}
// Validate scope if provided
// TODO:
// 1. Should validate scope, if not provide, use the default scope
// 2. If scope has "openid", should be redirect to the login page/mobile app authentication
// 3. If scope not has "openid", can't visit the userinfo endpoint
// 4. Security check
if request.Scope != "" {
scopes := strings.Fields(request.Scope)
scopeValidation, err := s.clientProvider.ValidateScope(ctx, request.ClientID, scopes)

View file

@ -400,9 +400,6 @@ func TestRefreshToken(t *testing.T) {
assert.NotEmpty(t, response.RefreshToken)
assert.NotEqual(t, refreshToken, response.RefreshToken) // Should be different
// Old refresh token should be revoked
exists := service.userProvider.TokenExists(refreshToken)
assert.False(t, exists)
})
t.Run("invalid refresh token", func(t *testing.T) {
@ -499,9 +496,6 @@ func TestRotateRefreshToken(t *testing.T) {
assert.Equal(t, "Bearer", response.TokenType)
assert.Equal(t, 3600, response.ExpiresIn)
// Old token should be revoked
exists := service.userProvider.TokenExists(oldToken)
assert.False(t, exists)
})
t.Run("rotation with disabled feature", func(t *testing.T) {
@ -661,9 +655,6 @@ func TestHandleRefreshTokenGrant(t *testing.T) {
assert.NotEmpty(t, token.RefreshToken)
assert.NotEqual(t, refreshToken, token.RefreshToken) // Should be different
// Old refresh token should be revoked
exists := service.userProvider.TokenExists(refreshToken)
assert.False(t, exists)
})
t.Run("refresh token grant without rotation", func(t *testing.T) {
@ -693,9 +684,6 @@ func TestHandleRefreshTokenGrant(t *testing.T) {
assert.Equal(t, 3600, token.ExpiresIn)
assert.Equal(t, refreshToken, token.RefreshToken) // Should be the same
// Old refresh token should still exist
exists := service.userProvider.TokenExists(refreshToken)
assert.True(t, exists)
})
t.Run("refresh token grant with invalid token", func(t *testing.T) {

View file

@ -2,7 +2,10 @@ package oauth
import (
"context"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
"github.com/yaoapp/yao/openapi/oauth/types"
)
@ -10,10 +13,40 @@ import (
// JWKS returns the JSON Web Key Set for token verification
// This endpoint provides public keys for validating JWT tokens
func (s *Service) JWKS(ctx context.Context) (*types.JWKSResponse, error) {
// TODO: Implement JWKS endpoint - this requires certificate/key management
// For now, return empty JWKS
var jwks []types.JWK
// Get signing certificates from the service
signingCerts := s.GetSigningCertificates()
if signingCerts == nil || signingCerts.SigningCert == nil {
return nil, fmt.Errorf("no signing certificate available")
}
// Get public key from certificate
publicKey := signingCerts.GetPublicKey()
if publicKey == nil {
return nil, fmt.Errorf("no public key available")
}
// Convert to RSA public key (assuming RSA for now)
rsaPublicKey, ok := publicKey.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("only RSA public keys are supported")
}
// Build JWK from RSA public key
jwk := types.JWK{
Kty: "RSA",
Use: "sig",
Kid: signingCerts.GetKeyID(),
Alg: s.GetSigningAlgorithm(),
N: base64.RawURLEncoding.EncodeToString(rsaPublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPublicKey.E)).Bytes()),
}
jwks = append(jwks, jwk)
return &types.JWKSResponse{
Keys: []types.JWK{},
Keys: jwks,
}, nil
}

View file

@ -1 +1,22 @@
package oauth
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Guard is the OAuth guard middleware
func (s *Service) Guard(c *gin.Context) {
// Get the token from the request
token := c.GetHeader("Authorization")
// Validate the token
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
// Validate the token
}

View file

@ -19,6 +19,8 @@ type Service struct {
userProvider types.UserProvider
clientProvider types.ClientProvider
prefix string
// Signing certificates for JWT token signing and verification
signingCerts *SigningCertificates
}
// Config OAuth service configuration
@ -124,6 +126,17 @@ func NewService(config *Config) (*Service, error) {
}
}
// Load Certificates
signingCerts, err := LoadSigningCertificates(&config.Signing)
if err != nil {
return nil, fmt.Errorf("failed to load signing certificates: %w", err)
}
// Validate the loaded certificates
if err := signingCerts.ValidateCertificate(); err != nil {
return nil, fmt.Errorf("certificate validation failed: %w", err)
}
service := &Service{
config: config,
store: config.Store,
@ -131,6 +144,7 @@ func NewService(config *Config) (*Service, error) {
userProvider: userProvider,
clientProvider: clientProvider,
prefix: keyPrefix,
signingCerts: signingCerts,
}
return service, nil
@ -231,11 +245,17 @@ func validateConfig(config *Config) error {
return types.ErrIssuerURLMissing
}
// Validate certificate configuration
if config.Signing.SigningCertPath == "" || config.Signing.SigningKeyPath == "" {
return types.ErrCertificateMissing
// Certificate configuration validation
// If both cert and key paths are provided, they must both exist or be empty
certPathProvided := config.Signing.SigningCertPath != ""
keyPathProvided := config.Signing.SigningKeyPath != ""
if certPathProvided != keyPathProvided {
return types.ErrCertificateMissing // Both paths must be provided together or not at all
}
// If paths are not provided, temporary certificates will be generated automatically
// Validate token configuration
if config.Token.AccessTokenLifetime <= 0 {
return types.ErrInvalidTokenLifetime

View file

@ -28,6 +28,12 @@ import (
// $YAO_SOURCE_ROOT is the root directory of the Yao source code.
// source $YAO_SOURCE_ROOT/env.local.sh
// Test certificate paths - created once and reused across tests
var (
testCertPath string
testKeyPath string
)
// Store configuration for parameterized tests
type StoreConfig struct {
Name string
@ -311,14 +317,19 @@ func setupOAuthTestEnvironment(t *testing.T) (*Service, store.Store, store.Store
// Create cache
cache := getLRUCache(t)
// Create test certificates once if not already created
if testCertPath == "" || testKeyPath == "" {
createTestCertificatesOnce(t)
}
// Create OAuth service configuration
oauthConfig := &Config{
Store: mainStore,
Cache: cache,
Signing: types.SigningConfig{
SigningAlgorithm: "RS256",
SigningCertPath: "/tmp/test-cert.pem",
SigningKeyPath: "/tmp/test-key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
Token: types.TokenConfig{
AccessTokenLifetime: time.Hour,
@ -498,6 +509,26 @@ func cleanupTestData(t *testing.T, service *Service) {
}
}
// createTestCertificatesOnce creates temporary certificate pair for all tests
func createTestCertificatesOnce(t *testing.T) {
// Generate temporary certificates (auto-generate with empty paths)
config := &types.SigningConfig{
SigningAlgorithm: "RS256",
SigningCertPath: "",
SigningKeyPath: "",
}
certs, err := LoadSigningCertificates(config)
if err != nil {
t.Fatalf("Failed to generate test certificates: %v", err)
}
testCertPath = certs.SigningCertPath
testKeyPath = certs.SigningKeyPath
t.Logf("Created test certificates: cert=%s, key=%s", testCertPath, testKeyPath)
}
// Helper functions for store setup (same as in other test files)
func getMongoStore(t *testing.T) store.Store {
@ -561,9 +592,27 @@ func getStoreConfigs() []StoreConfig {
func TestMain(m *testing.M) {
// Run tests
code := m.Run()
// Cleanup global test certificates
cleanupGlobalTestCertificates()
os.Exit(code)
}
// cleanupGlobalTestCertificates removes global test certificates
func cleanupGlobalTestCertificates() {
if testCertPath != "" {
if _, err := os.Stat(testCertPath); !os.IsNotExist(err) {
os.Remove(testCertPath)
}
}
if testKeyPath != "" {
if _, err := os.Stat(testKeyPath); !os.IsNotExist(err) {
os.Remove(testKeyPath)
}
}
}
func TestNewService(t *testing.T) {
t.Run("create service with valid config", func(t *testing.T) {
service, _, _, cleanup := setupOAuthTestEnvironment(t)
@ -601,8 +650,8 @@ func TestNewService(t *testing.T) {
config := &Config{
Store: store,
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
}
@ -645,8 +694,8 @@ func TestConfigDefaults(t *testing.T) {
Store: store,
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
}
@ -725,8 +774,8 @@ func TestProviderInitialization(t *testing.T) {
Cache: cache,
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
}
@ -753,8 +802,8 @@ func TestProviderInitialization(t *testing.T) {
Cache: cache,
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
}
@ -772,8 +821,8 @@ func TestProviderInitialization(t *testing.T) {
ClientProvider: customClientProvider,
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
}
@ -829,8 +878,8 @@ func TestConfigValidation(t *testing.T) {
Store: getBadgerStore(t),
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
Token: types.TokenConfig{
AccessTokenLifetime: time.Hour,
@ -839,7 +888,12 @@ func TestConfigValidation(t *testing.T) {
},
}
err := validateConfig(config)
// Set defaults first (like NewService does)
err := setConfigDefaults(config)
assert.NoError(t, err)
// Then validate
err = validateConfig(config)
assert.NoError(t, err)
})
@ -847,12 +901,17 @@ func TestConfigValidation(t *testing.T) {
config := &Config{
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
}
err := validateConfig(config)
// Set defaults first (like NewService does)
err := setConfigDefaults(config)
assert.NoError(t, err)
// Then validate
err = validateConfig(config)
assert.Error(t, err)
assert.Equal(t, types.ErrStoreMissing, err)
})
@ -861,24 +920,37 @@ func TestConfigValidation(t *testing.T) {
config := &Config{
Store: getBadgerStore(t),
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
}
err := validateConfig(config)
// Set defaults first (like NewService does)
err := setConfigDefaults(config)
assert.NoError(t, err)
// Then validate
err = validateConfig(config)
assert.Error(t, err)
assert.Equal(t, types.ErrIssuerURLMissing, err)
})
t.Run("missing certificate configuration", func(t *testing.T) {
t.Run("partial certificate configuration", func(t *testing.T) {
config := &Config{
Store: getBadgerStore(t),
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{},
Signing: types.SigningConfig{
SigningCertPath: testCertPath, // Only cert path, missing key path
SigningKeyPath: "",
},
}
err := validateConfig(config)
// Set defaults first (like NewService does)
err := setConfigDefaults(config)
assert.NoError(t, err)
// Then validate
err = validateConfig(config)
assert.Error(t, err)
assert.Equal(t, types.ErrCertificateMissing, err)
})
@ -888,15 +960,20 @@ func TestConfigValidation(t *testing.T) {
Store: getBadgerStore(t),
IssuerURL: "https://test.example.com",
Signing: types.SigningConfig{
SigningCertPath: "/tmp/cert.pem",
SigningKeyPath: "/tmp/key.pem",
SigningCertPath: testCertPath,
SigningKeyPath: testKeyPath,
},
Token: types.TokenConfig{
AccessTokenLifetime: -1 * time.Hour,
AccessTokenLifetime: -1 * time.Hour, // Invalid negative lifetime
},
}
err := validateConfig(config)
// Set defaults first (like NewService does)
err := setConfigDefaults(config)
assert.NoError(t, err)
// Then validate
err = validateConfig(config)
assert.Error(t, err)
assert.Equal(t, types.ErrInvalidTokenLifetime, err)
})

View file

@ -284,67 +284,67 @@ func (u *DefaultUser) ValidateUserScope(ctx context.Context, userID string, scop
return true, nil
}
// StoreToken stores a token in the token store with expiration time
func (u *DefaultUser) StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error {
return u.tokenStore.Set(u.tokenKey(accessToken), tokenData, expiration)
}
// // StoreToken stores a token in the token store with expiration time
// func (u *DefaultUser) StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error {
// return u.tokenStore.Set(u.tokenKey(accessToken), tokenData, expiration)
// }
// RevokeToken revokes a token by removing it from the token store
func (u *DefaultUser) RevokeToken(accessToken string) error {
u.tokenStore.Del(u.tokenKey(accessToken))
return nil
}
// // RevokeToken revokes a token by removing it from the token store
// func (u *DefaultUser) RevokeToken(accessToken string) error {
// u.tokenStore.Del(u.tokenKey(accessToken))
// return nil
// }
// TokenExists checks if a token exists in the token store
func (u *DefaultUser) TokenExists(accessToken string) bool {
_, exists := u.tokenStore.Get(u.tokenKey(accessToken))
return exists
}
// // TokenExists checks if a token exists in the token store
// func (u *DefaultUser) TokenExists(accessToken string) bool {
// _, exists := u.tokenStore.Get(u.tokenKey(accessToken))
// return exists
// }
// GetTokenData retrieves token data from the token store
func (u *DefaultUser) GetTokenData(accessToken string) (map[string]interface{}, error) {
tokenData, exists := u.tokenStore.Get(u.tokenKey(accessToken))
if !exists {
return nil, fmt.Errorf("token not found")
}
// // GetTokenData retrieves token data from the token store
// func (u *DefaultUser) GetTokenData(accessToken string) (map[string]interface{}, error) {
// tokenData, exists := u.tokenStore.Get(u.tokenKey(accessToken))
// if !exists {
// return nil, fmt.Errorf("token not found")
// }
// Try to convert to map[string]interface{} directly
if tokenInfo, ok := tokenData.(map[string]interface{}); ok {
return tokenInfo, nil
}
// // Try to convert to map[string]interface{} directly
// if tokenInfo, ok := tokenData.(map[string]interface{}); ok {
// return tokenInfo, nil
// }
// If direct conversion fails, try to handle other possible types
// This handles cases where MongoDB might return different types
switch v := tokenData.(type) {
case map[string]interface{}:
return v, nil
case map[interface{}]interface{}:
// Convert map[interface{}]interface{} to map[string]interface{}
result := make(map[string]interface{})
for key, val := range v {
if keyStr, ok := key.(string); ok {
result[keyStr] = val
}
}
return result, nil
default:
// Try to convert using map[string]interface{} casting
// This handles primitive.M and other MongoDB types
if reflect.TypeOf(v).Kind() == reflect.Map {
result := make(map[string]interface{})
rv := reflect.ValueOf(v)
for _, key := range rv.MapKeys() {
if keyStr, ok := key.Interface().(string); ok {
result[keyStr] = rv.MapIndex(key).Interface()
}
}
if len(result) > 0 {
return result, nil
}
}
return nil, fmt.Errorf("invalid token data format: %T", tokenData)
}
}
// // If direct conversion fails, try to handle other possible types
// // This handles cases where MongoDB might return different types
// switch v := tokenData.(type) {
// case map[string]interface{}:
// return v, nil
// case map[interface{}]interface{}:
// // Convert map[interface{}]interface{} to map[string]interface{}
// result := make(map[string]interface{})
// for key, val := range v {
// if keyStr, ok := key.(string); ok {
// result[keyStr] = val
// }
// }
// return result, nil
// default:
// // Try to convert using map[string]interface{} casting
// // This handles primitive.M and other MongoDB types
// if reflect.TypeOf(v).Kind() == reflect.Map {
// result := make(map[string]interface{})
// rv := reflect.ValueOf(v)
// for _, key := range rv.MapKeys() {
// if keyStr, ok := key.Interface().(string); ok {
// result[keyStr] = rv.MapIndex(key).Interface()
// }
// }
// if len(result) > 0 {
// return result, nil
// }
// }
// return nil, fmt.Errorf("invalid token data format: %T", tokenData)
// }
// }
// CreateUser creates a new user in the database
func (u *DefaultUser) CreateUser(userData map[string]interface{}) (interface{}, error) {

View file

@ -350,64 +350,6 @@ func TestKeyGeneration(t *testing.T) {
}
}
func TestTokenOperations(t *testing.T) {
storeConfigs := getStoreConfigs()
for _, config := range storeConfigs {
t.Run(config.Name, func(t *testing.T) {
tokenStore := config.GetFunc(t)
cache := getLRUCache(t)
user := NewDefaultUser(&DefaultUserOptions{
Prefix: "test:",
Cache: cache,
TokenStore: tokenStore,
})
// Clean up
tokenStore.Clear()
t.Run("store and get token", func(t *testing.T) {
tokenData := createTestToken("test-subject")
err := user.StoreToken("test-token", tokenData, 1*time.Hour)
assert.NoError(t, err)
exists := user.TokenExists("test-token")
assert.True(t, exists)
retrievedData, err := user.GetTokenData("test-token")
assert.NoError(t, err)
assert.Equal(t, tokenData["subject"], retrievedData["subject"])
})
t.Run("revoke token", func(t *testing.T) {
tokenData := createTestToken("test-subject")
err := user.StoreToken("test-token-revoke", tokenData, 1*time.Hour)
assert.NoError(t, err)
exists := user.TokenExists("test-token-revoke")
assert.True(t, exists)
err = user.RevokeToken("test-token-revoke")
assert.NoError(t, err)
exists = user.TokenExists("test-token-revoke")
assert.False(t, exists)
})
t.Run("non-existent token", func(t *testing.T) {
exists := user.TokenExists("non-existent")
assert.False(t, exists)
_, err := user.GetTokenData("non-existent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "token not found")
})
})
}
}
func TestGetUserBySubject(t *testing.T) {
storeConfigs := getStoreConfigs()
@ -557,70 +499,6 @@ func TestGetUserByEmail(t *testing.T) {
}
}
func TestGetUserByAccessToken(t *testing.T) {
storeConfigs := getStoreConfigs()
for _, config := range storeConfigs {
t.Run(config.Name, func(t *testing.T) {
cleanupTestData(t)
defer cleanupTestData(t)
tokenStore := config.GetFunc(t)
cache := getLRUCache(t)
user := NewDefaultUser(&DefaultUserOptions{
Prefix: "test:",
Cache: cache,
TokenStore: tokenStore,
})
// Clean up
tokenStore.Clear()
// Create test user
testUser := createTestUser("token1")
setupTestUser(t, testUser)
ctx := context.Background()
t.Run("get user by access token", func(t *testing.T) {
// Store token
tokenData := createTestToken(testUser.Subject)
err := user.StoreToken("test-access-token", tokenData, 1*time.Hour)
require.NoError(t, err)
// Get user by token
retrievedUser, err := user.GetUserByAccessToken(ctx, "test-access-token")
assert.NoError(t, err)
assert.NotNil(t, retrievedUser)
userMap := convertToStringMap(t, retrievedUser)
assert.Equal(t, testUser.Subject, userMap["subject"])
assert.Equal(t, testUser.Username, userMap["username"])
})
t.Run("non-existent token", func(t *testing.T) {
retrievedUser, err := user.GetUserByAccessToken(ctx, "non-existent-token")
assert.Error(t, err)
assert.Nil(t, retrievedUser)
assert.Contains(t, err.Error(), "token not found")
})
t.Run("invalid token format", func(t *testing.T) {
// Store invalid token data
invalidTokenData := "invalid-token-data"
tokenStore.Set(user.tokenKey("invalid-token"), invalidTokenData, 1*time.Hour)
retrievedUser, err := user.GetUserByAccessToken(ctx, "invalid-token")
assert.Error(t, err)
assert.Nil(t, retrievedUser)
assert.Contains(t, err.Error(), "invalid token data format")
})
})
}
}
func TestValidateUserScope(t *testing.T) {
storeConfigs := getStoreConfigs()

429
openapi/oauth/signing.go Normal file
View file

@ -0,0 +1,429 @@
package oauth
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/share"
)
// SigningCertificates holds the loaded signing certificates and keys
type SigningCertificates struct {
// Primary signing certificate and private key
SigningCert *x509.Certificate `json:"-"`
SigningKey interface{} `json:"-"` // *rsa.PrivateKey, *ecdsa.PrivateKey, etc.
SigningKeyPair *tls.Certificate `json:"-"`
// Verification certificates for token validation
VerificationCerts []*x509.Certificate `json:"-"`
// mTLS CA certificate for client validation
MTLSClientCACert *x509.Certificate `json:"-"`
// Signing algorithm
Algorithm string `json:"algorithm"`
// Certificate paths for reference
SigningCertPath string `json:"signing_cert_path"`
SigningKeyPath string `json:"signing_key_path"`
// Auto-generated flag
IsAutoGenerated bool `json:"is_auto_generated"`
}
// LoadSigningCertificates loads or generates signing certificates based on configuration
func LoadSigningCertificates(config *types.SigningConfig) (*SigningCertificates, error) {
certs := &SigningCertificates{
Algorithm: config.SigningAlgorithm,
}
// Check if certificate files exist
signingCertExists := fileExists(config.SigningCertPath)
signingKeyExists := fileExists(config.SigningKeyPath)
// If both files exist, try to load them
if signingCertExists && signingKeyExists {
err := loadExistingCertificates(certs, config)
if err != nil {
// If loading fails, log warning and generate new certificates
fmt.Printf("Warning: Failed to load existing certificates (%v), generating new temporary certificates\n", err)
return generateTemporaryCertificates(config)
}
return certs, nil
}
// If certificates don't exist, generate temporary ones
return generateTemporaryCertificates(config)
}
// loadExistingCertificates loads certificates from the configured paths
func loadExistingCertificates(certs *SigningCertificates, config *types.SigningConfig) error {
// Load signing certificate
certPEM, err := os.ReadFile(config.SigningCertPath)
if err != nil {
return fmt.Errorf("failed to read signing certificate: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return fmt.Errorf("failed to decode signing certificate PEM")
}
signingCert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse signing certificate: %w", err)
}
// Load signing key
keyPEM, err := os.ReadFile(config.SigningKeyPath)
if err != nil {
return fmt.Errorf("failed to read signing key: %w", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return fmt.Errorf("failed to decode signing key PEM")
}
var signingKey interface{}
if config.SigningKeyPassword != "" {
// Decrypt encrypted key
keyBytes, err := x509.DecryptPEMBlock(keyBlock, []byte(config.SigningKeyPassword))
if err != nil {
return fmt.Errorf("failed to decrypt signing key: %w", err)
}
signingKey, err = parsePrivateKey(keyBytes)
if err != nil {
return fmt.Errorf("failed to parse decrypted signing key: %w", err)
}
} else {
// Parse unencrypted key
var err error
signingKey, err = parsePrivateKey(keyBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse signing key: %w", err)
}
}
// Create TLS certificate pair
keyPair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return fmt.Errorf("failed to create key pair: %w", err)
}
certs.SigningCert = signingCert
certs.SigningKey = signingKey
certs.SigningKeyPair = &keyPair
certs.SigningCertPath = config.SigningCertPath
certs.SigningKeyPath = config.SigningKeyPath
certs.IsAutoGenerated = false
// Load verification certificates if configured
if len(config.VerificationCerts) > 0 {
verificationCerts, err := loadVerificationCertificates(config.VerificationCerts)
if err != nil {
return fmt.Errorf("failed to load verification certificates: %w", err)
}
certs.VerificationCerts = verificationCerts
}
// Load mTLS CA certificate if configured
if config.MTLSClientCACertPath != "" {
mtlsCACert, err := loadCertificateFromFile(config.MTLSClientCACertPath)
if err != nil {
return fmt.Errorf("failed to load mTLS CA certificate: %w", err)
}
certs.MTLSClientCACert = mtlsCACert
}
return nil
}
// generateTemporaryCertificates generates temporary self-signed certificates
func generateTemporaryCertificates(config *types.SigningConfig) (*SigningCertificates, error) {
// Generate RSA private key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{share.App.Name},
Country: []string{"US"},
Province: []string{""},
Locality: []string{""},
StreetAddress: []string{""},
PostalCode: []string{""},
CommonName: fmt.Sprintf("%s OAuth Signing Certificate", share.App.Name),
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour), // Valid for 1 year
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
// Generate certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
// Parse the generated certificate
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse generated certificate: %w", err)
}
// Create PEM blocks
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("failed to marshal private key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: keyDER,
})
// Create TLS certificate pair
keyPair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("failed to create key pair: %w", err)
}
// Determine system directory for storing temporary certificates
systemDir := getSystemCertificateDirectory()
// Create directory if it doesn't exist
if err := os.MkdirAll(systemDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create system certificate directory: %w", err)
}
// Generate unique filenames
timestamp := time.Now().Format("20060102150405")
certPath := filepath.Join(systemDir, fmt.Sprintf("oauth_signing_cert_%s.pem", timestamp))
keyPath := filepath.Join(systemDir, fmt.Sprintf("oauth_signing_key_%s.pem", timestamp))
// Save certificate and key to system directory
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
return nil, fmt.Errorf("failed to write certificate file: %w", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return nil, fmt.Errorf("failed to write key file: %w", err)
}
fmt.Printf("Generated temporary OAuth signing certificate at: %s\n", certPath)
fmt.Printf("Generated temporary OAuth signing key at: %s\n", keyPath)
return &SigningCertificates{
SigningCert: cert,
SigningKey: privateKey,
SigningKeyPair: &keyPair,
Algorithm: config.SigningAlgorithm,
SigningCertPath: certPath,
SigningKeyPath: keyPath,
IsAutoGenerated: true,
}, nil
}
// loadVerificationCertificates loads additional verification certificates
func loadVerificationCertificates(certPaths []string) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
for _, certPath := range certPaths {
cert, err := loadCertificateFromFile(certPath)
if err != nil {
return nil, fmt.Errorf("failed to load verification certificate %s: %w", certPath, err)
}
certs = append(certs, cert)
}
return certs, nil
}
// loadCertificateFromFile loads a certificate from a PEM file
func loadCertificateFromFile(certPath string) (*x509.Certificate, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("failed to read certificate file: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return nil, fmt.Errorf("failed to decode certificate PEM")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
// parsePrivateKey parses a private key from DER bytes
func parsePrivateKey(der []byte) (interface{}, error) {
// Try PKCS#8 first
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
return key, nil
}
// Try PKCS#1 RSA
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return key, nil
}
// Try EC private key
if key, err := x509.ParseECPrivateKey(der); err == nil {
return key, nil
}
return nil, fmt.Errorf("unable to parse private key")
}
// fileExists checks if a file exists
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return !os.IsNotExist(err)
}
// getSystemCertificateDirectory returns the appropriate system directory for storing certificates
func getSystemCertificateDirectory() string {
// Use different directories based on the operating system
homeDir, err := os.UserHomeDir()
if err != nil {
// Fallback to temporary directory
return filepath.Join(os.TempDir(), "yao-oauth-certs")
}
// Create a hidden directory in user's home
return filepath.Join(homeDir, ".yao", "oauth", "certs")
}
// ValidateCertificate validates a certificate for OAuth signing
func (c *SigningCertificates) ValidateCertificate() error {
if c.SigningCert == nil {
return fmt.Errorf("signing certificate is nil")
}
// Check if certificate is expired
now := time.Now()
if now.Before(c.SigningCert.NotBefore) {
return fmt.Errorf("signing certificate is not yet valid")
}
if now.After(c.SigningCert.NotAfter) {
return fmt.Errorf("signing certificate has expired")
}
// Check if certificate has appropriate key usage
if c.SigningCert.KeyUsage&x509.KeyUsageDigitalSignature == 0 {
return fmt.Errorf("signing certificate does not have digital signature key usage")
}
return nil
}
// GetPublicKey returns the public key from the signing certificate
func (c *SigningCertificates) GetPublicKey() interface{} {
if c.SigningCert == nil {
return nil
}
return c.SigningCert.PublicKey
}
// GetKeyID returns a key identifier for the signing certificate
func (c *SigningCertificates) GetKeyID() string {
if c.SigningCert == nil {
return ""
}
// Use the certificate's serial number as key ID
return c.SigningCert.SerialNumber.String()
}
// CleanupTemporaryCertificates removes auto-generated temporary certificates
func (c *SigningCertificates) CleanupTemporaryCertificates() error {
if !c.IsAutoGenerated {
return nil // Don't delete user-provided certificates
}
var errs []error
if c.SigningCertPath != "" && fileExists(c.SigningCertPath) {
if err := os.Remove(c.SigningCertPath); err != nil {
errs = append(errs, fmt.Errorf("failed to remove certificate file %s: %w", c.SigningCertPath, err))
}
}
if c.SigningKeyPath != "" && fileExists(c.SigningKeyPath) {
if err := os.Remove(c.SigningKeyPath); err != nil {
errs = append(errs, fmt.Errorf("failed to remove key file %s: %w", c.SigningKeyPath, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("cleanup errors: %v", errs)
}
return nil
}
// Service signing certificate methods
// GetSigningCertificates returns the signing certificates for the service
func (s *Service) GetSigningCertificates() *SigningCertificates {
return s.signingCerts
}
// GetSigningKey returns the signing private key
func (s *Service) GetSigningKey() interface{} {
if s.signingCerts == nil {
return nil
}
return s.signingCerts.SigningKey
}
// GetSigningCertificate returns the signing certificate
func (s *Service) GetSigningCertificate() interface{} {
if s.signingCerts == nil {
return nil
}
return s.signingCerts.SigningCert
}
// GetSigningAlgorithm returns the signing algorithm
func (s *Service) GetSigningAlgorithm() string {
if s.signingCerts == nil {
return "RS256" // default
}
return s.signingCerts.Algorithm
}
// GetKeyID returns the key identifier for JWT token signing
func (s *Service) GetKeyID() string {
if s.signingCerts == nil {
return ""
}
return s.signingCerts.GetKeyID()
}

View file

@ -5,7 +5,7 @@ var (
ErrInvalidConfiguration = &ErrorResponse{Code: "invalid_configuration", ErrorDescription: "Invalid OAuth service configuration"}
ErrStoreMissing = &ErrorResponse{Code: "store_missing", ErrorDescription: "Store is required for OAuth service"}
ErrIssuerURLMissing = &ErrorResponse{Code: "issuer_url_missing", ErrorDescription: "Issuer URL is required for OAuth service"}
ErrCertificateMissing = &ErrorResponse{Code: "certificate_missing", ErrorDescription: "JWT signing certificate and key are required"}
ErrCertificateMissing = &ErrorResponse{Code: "certificate_missing", ErrorDescription: "JWT signing certificate and key paths must both be provided or both be empty"}
ErrInvalidTokenLifetime = &ErrorResponse{Code: "invalid_token_lifetime", ErrorDescription: "Token lifetime must be greater than 0"}
ErrPKCEConfigurationInvalid = &ErrorResponse{Code: "pkce_configuration_invalid", ErrorDescription: "PKCE configuration is invalid"}
)

View file

@ -2,7 +2,8 @@ package types
import (
"context"
"time"
"github.com/gin-gonic/gin"
)
// OAuth interface defines the complete OAuth 2.1 and MCP authorization server functionality
@ -134,6 +135,9 @@ type OAuth interface {
// ValidateTokenBinding validates token binding information
// This ensures tokens are bound to the correct client or device
ValidateTokenBinding(ctx context.Context, token string, binding *TokenBinding) (*ValidationResult, error)
// Guard is the OAuth guard middleware
Guard(c *gin.Context)
}
// UserProvider interface for user information retrieval
@ -149,16 +153,16 @@ type UserProvider interface {
// Token management methods
// StoreToken stores a token with expiration time
StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error
// StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error
// RevokeToken revokes a token by removing it from storage
RevokeToken(accessToken string) error
// RevokeToken(accessToken string) error
// TokenExists checks if a token exists in storage
TokenExists(accessToken string) bool
// TokenExists(accessToken string) bool
// GetTokenData retrieves token data from storage
GetTokenData(accessToken string) (map[string]interface{}, error)
// GetTokenData(accessToken string) (map[string]interface{}, error)
// User management methods
// CreateUser creates a new user in the database

View file

@ -1,439 +1 @@
package oauth
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// UserInfo Tests
// =============================================================================
func TestUserInfo(t *testing.T) {
service, _, _, cleanup := setupOAuthTestEnvironment(t)
defer cleanup()
ctx := context.Background()
userProvider := service.GetUserProvider()
t.Run("get user info with valid access token", func(t *testing.T) {
// Create a valid access token for the first test user
testUser := testUsers[0]
accessToken := "valid_access_token_123"
// Store token data in user provider
tokenData := map[string]interface{}{
"token": accessToken,
"user_id": testUser.ID,
"subject": testUser.Subject,
"username": testUser.Username,
"email": testUser.Email,
"first_name": testUser.FirstName,
"last_name": testUser.LastName,
"full_name": testUser.FullName,
"scopes": testUser.Scopes,
"status": testUser.Status,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
// Store the token data
err := userProvider.StoreToken(accessToken, tokenData, time.Hour)
require.NoError(t, err)
// Get user info using the access token
userInfo, err := service.UserInfo(ctx, accessToken)
assert.NoError(t, err)
assert.NotNil(t, userInfo)
// Verify the user info contains expected data
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
assert.Equal(t, testUser.Subject, userInfoMap["subject"])
assert.Equal(t, testUser.Username, userInfoMap["username"])
assert.Equal(t, testUser.Email, userInfoMap["email"])
}
})
t.Run("get user info with invalid access token", func(t *testing.T) {
invalidToken := "invalid_access_token_xyz"
userInfo, err := service.UserInfo(ctx, invalidToken)
assert.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("get user info with non-existent access token", func(t *testing.T) {
nonExistentToken := "non_existent_token_abc"
userInfo, err := service.UserInfo(ctx, nonExistentToken)
assert.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("get user info with empty access token", func(t *testing.T) {
emptyToken := ""
userInfo, err := service.UserInfo(ctx, emptyToken)
assert.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("get user info with expired access token", func(t *testing.T) {
testUser := testUsers[1]
expiredToken := "expired_access_token_456"
// Store expired token data
tokenData := map[string]interface{}{
"token": expiredToken,
"user_id": testUser.ID,
"subject": testUser.Subject,
"username": testUser.Username,
"email": testUser.Email,
"scopes": testUser.Scopes,
"status": testUser.Status,
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
"iat": time.Now().Add(-2 * time.Hour).Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(expiredToken, tokenData, time.Hour)
require.NoError(t, err)
userInfo, err := service.UserInfo(ctx, expiredToken)
// UserInfo method returns user data regardless of token expiry
assert.NoError(t, err)
assert.NotNil(t, userInfo)
// Verify user info contains expected data
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
assert.Equal(t, testUser.Subject, userInfoMap["subject"])
assert.Equal(t, testUser.Username, userInfoMap["username"])
}
})
t.Run("get user info with inactive user", func(t *testing.T) {
// Use the inactive test user
inactiveUser := testUsers[4] // inactive.user
inactiveToken := "inactive_user_token_789"
tokenData := map[string]interface{}{
"token": inactiveToken,
"user_id": inactiveUser.ID,
"subject": inactiveUser.Subject,
"username": inactiveUser.Username,
"email": inactiveUser.Email,
"scopes": inactiveUser.Scopes,
"status": inactiveUser.Status, // inactive
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(inactiveToken, tokenData, time.Hour)
require.NoError(t, err)
userInfo, err := service.UserInfo(ctx, inactiveToken)
// UserInfo method returns user data regardless of user status
assert.NoError(t, err)
assert.NotNil(t, userInfo)
// Verify user info contains expected data
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
assert.Equal(t, inactiveUser.Subject, userInfoMap["subject"])
assert.Equal(t, inactiveUser.Username, userInfoMap["username"])
assert.Equal(t, inactiveUser.Status, userInfoMap["status"])
}
})
t.Run("get user info with limited scope user", func(t *testing.T) {
// Use the limited scope test user
limitedUser := testUsers[5] // limited.user
limitedToken := "limited_scope_token_101"
tokenData := map[string]interface{}{
"token": limitedToken,
"user_id": limitedUser.ID,
"subject": limitedUser.Subject,
"username": limitedUser.Username,
"email": limitedUser.Email,
"scopes": limitedUser.Scopes, // Only openid
"status": limitedUser.Status,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(limitedToken, tokenData, time.Hour)
require.NoError(t, err)
userInfo, err := service.UserInfo(ctx, limitedToken)
assert.NoError(t, err)
assert.NotNil(t, userInfo)
// Verify limited user info
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
assert.Equal(t, limitedUser.Subject, userInfoMap["subject"])
assert.Equal(t, limitedUser.Username, userInfoMap["username"])
// Should only have basic scopes
if scopes, ok := userInfoMap["scopes"].([]string); ok {
assert.Contains(t, scopes, "openid")
assert.Len(t, scopes, 1)
}
}
})
t.Run("get user info with admin user", func(t *testing.T) {
// Use the admin test user
adminUser := testUsers[0] // admin
adminToken := "admin_token_202"
tokenData := map[string]interface{}{
"token": adminToken,
"user_id": adminUser.ID,
"subject": adminUser.Subject,
"username": adminUser.Username,
"email": adminUser.Email,
"first_name": adminUser.FirstName,
"last_name": adminUser.LastName,
"full_name": adminUser.FullName,
"scopes": adminUser.Scopes,
"status": adminUser.Status,
"email_verified": adminUser.EmailVerified,
"mobile_verified": adminUser.MobileVerified,
"two_factor_enabled": adminUser.TwoFactorEnabled,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(adminToken, tokenData, time.Hour)
require.NoError(t, err)
userInfo, err := service.UserInfo(ctx, adminToken)
assert.NoError(t, err)
assert.NotNil(t, userInfo)
// Verify admin user info
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
assert.Equal(t, adminUser.Subject, userInfoMap["subject"])
assert.Equal(t, adminUser.Username, userInfoMap["username"])
assert.Equal(t, adminUser.Email, userInfoMap["email"])
assert.True(t, userInfoMap["email_verified"].(bool))
assert.True(t, userInfoMap["two_factor_enabled"].(bool))
// Should have admin scopes
if scopes, ok := userInfoMap["scopes"].([]string); ok {
assert.Contains(t, scopes, "admin")
assert.Contains(t, scopes, "openid")
assert.Contains(t, scopes, "profile")
assert.Contains(t, scopes, "email")
}
}
})
}
// =============================================================================
// Integration Tests
// =============================================================================
func TestUserInfoIntegration(t *testing.T) {
service, _, _, cleanup := setupOAuthTestEnvironment(t)
defer cleanup()
ctx := context.Background()
userProvider := service.GetUserProvider()
t.Run("complete user info flow", func(t *testing.T) {
// Use different test users for comprehensive testing
testCases := []struct {
name string
user *TestUser
tokenSuffix string
}{
{"regular_user", testUsers[1], "regular"},
{"verified_user", testUsers[2], "verified"},
{"secure_user", testUsers[6], "secure"},
{"api_user", testUsers[7], "api"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
token := "integration_token_" + tc.tokenSuffix
tokenData := map[string]interface{}{
"token": token,
"user_id": tc.user.ID,
"subject": tc.user.Subject,
"username": tc.user.Username,
"email": tc.user.Email,
"scopes": tc.user.Scopes,
"status": tc.user.Status,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(token, tokenData, time.Hour)
require.NoError(t, err)
userInfo, err := service.UserInfo(ctx, token)
assert.NoError(t, err)
assert.NotNil(t, userInfo)
// Verify basic user info structure
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
assert.Equal(t, tc.user.Subject, userInfoMap["subject"])
assert.Equal(t, tc.user.Username, userInfoMap["username"])
assert.Equal(t, tc.user.Email, userInfoMap["email"])
assert.Equal(t, tc.user.Status, userInfoMap["status"])
}
})
}
})
t.Run("concurrent user info requests", func(t *testing.T) {
// Test concurrent access to user info
const numRequests = 10
// Create tokens for concurrent testing
tokens := make([]string, numRequests)
for i := 0; i < numRequests; i++ {
tokens[i] = fmt.Sprintf("concurrent_token_%d", i)
testUser := testUsers[i%len(testUsers)]
tokenData := map[string]interface{}{
"token": tokens[i],
"user_id": testUser.ID,
"subject": testUser.Subject,
"username": testUser.Username,
"email": testUser.Email,
"scopes": testUser.Scopes,
"status": testUser.Status,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(tokens[i], tokenData, time.Hour)
require.NoError(t, err)
}
// Make concurrent requests
results := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
go func(token string) {
userInfo, err := service.UserInfo(ctx, token)
if err != nil {
results <- err
return
}
if userInfo == nil {
results <- fmt.Errorf("user info is nil")
return
}
results <- nil
}(tokens[i])
}
// Collect results
for i := 0; i < numRequests; i++ {
err := <-results
assert.NoError(t, err)
}
})
}
// =============================================================================
// Edge Cases and Error Handling
// =============================================================================
func TestUserInfoEdgeCases(t *testing.T) {
service, _, _, cleanup := setupOAuthTestEnvironment(t)
defer cleanup()
ctx := context.Background()
userProvider := service.GetUserProvider()
t.Run("malformed token data", func(t *testing.T) {
malformedToken := "malformed_token_data"
// Store malformed token data
tokenData := map[string]interface{}{
"token": malformedToken,
"user_id": "invalid_user_id",
"subject": nil, // Invalid subject
"username": "", // Empty username
"exp": "not_a_number", // Invalid expiration
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(malformedToken, tokenData, time.Hour)
require.NoError(t, err)
userInfo, err := service.UserInfo(ctx, malformedToken)
assert.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("very long access token", func(t *testing.T) {
// Create a very long token
longToken := "very_long_token_" + strings.Repeat("a", 1000)
userInfo, err := service.UserInfo(ctx, longToken)
assert.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("special characters in token", func(t *testing.T) {
specialToken := "special_token_!@#$%^&*()_+{}[]|\\:;\"'<>?,./`~"
userInfo, err := service.UserInfo(ctx, specialToken)
assert.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("token with only whitespace", func(t *testing.T) {
whitespaceToken := " \t\n\r "
userInfo, err := service.UserInfo(ctx, whitespaceToken)
assert.Error(t, err)
assert.Nil(t, userInfo)
})
t.Run("token with minimal valid data", func(t *testing.T) {
minimalToken := "minimal_token_999"
testUser := testUsers[9] // test.user
// Store minimal token data
tokenData := map[string]interface{}{
"token": minimalToken,
"user_id": testUser.ID,
"subject": testUser.Subject,
"username": testUser.Username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"token_type": "Bearer",
}
err := userProvider.StoreToken(minimalToken, tokenData, time.Hour)
require.NoError(t, err)
userInfo, err := service.UserInfo(ctx, minimalToken)
assert.NoError(t, err)
assert.NotNil(t, userInfo)
// Verify minimal user info
if userInfoMap, ok := userInfo.(map[string]interface{}); ok {
assert.Equal(t, testUser.Subject, userInfoMap["subject"])
assert.Equal(t, testUser.Username, userInfoMap["username"])
}
})
}

View file

@ -322,3 +322,155 @@ func TestOAuthAuthorize(t *testing.T) {
assert.Equal(t, "test-missing-client-id", query.Get("state"), "State should be preserved")
})
}
func TestOAuthJWKS(t *testing.T) {
serverURL := Prepare(t)
defer Clean()
// Debug: Check if Server is properly initialized
if Server == nil {
t.Fatal("OpenAPI Server is nil")
}
if Server.Config == nil {
t.Fatal("OpenAPI Server.Config is nil")
}
if Server.OAuth == nil {
t.Fatal("OpenAPI Server.OAuth is nil")
}
t.Logf("Server initialized with BaseURL: %s", Server.Config.BaseURL)
// Get base URL from server config
baseURL := ""
if Server != nil && Server.Config != nil {
baseURL = Server.Config.BaseURL
}
endpoint := serverURL + baseURL + "/oauth/jwks"
t.Logf("Testing JWKS endpoint: %s", endpoint)
t.Run("Valid JWKS Request", func(t *testing.T) {
// Make GET request to JWKS endpoint
resp, err := http.Get(endpoint)
assert.NoError(t, err)
assert.NotNil(t, resp)
defer resp.Body.Close()
t.Logf("Response status code: %d", resp.StatusCode)
// Should return 200 OK
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Verify Content-Type header (case-insensitive comparison)
contentType := resp.Header.Get("Content-Type")
assert.Contains(t, contentType, "application/json", "Content-Type should be JSON")
assert.Contains(t, contentType, "charset=utf", "Content-Type should specify charset")
// Verify OAuth 2.1 security headers are present
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"), "Cache-Control header should be set")
assert.Equal(t, "no-cache", resp.Header.Get("Pragma"), "Pragma header should be set")
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"), "X-Content-Type-Options header should be set")
assert.Equal(t, "DENY", resp.Header.Get("X-Frame-Options"), "X-Frame-Options header should be set")
assert.Equal(t, "no-referrer", resp.Header.Get("Referrer-Policy"), "Referrer-Policy header should be set")
// Read and parse response body
bodyBytes, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
t.Logf("JWKS response body: %s", string(bodyBytes))
// Parse JWKS response directly as per RFC 7517
var jwks types.JWKSResponse
err = json.Unmarshal(bodyBytes, &jwks)
assert.NoError(t, err, "Response should be valid JWKS JSON")
// Verify JWKS structure
assert.NotNil(t, jwks.Keys, "JWKS should have keys array")
assert.Equal(t, 1, len(jwks.Keys), "Should have exactly 1 key (matching 1 certificate pair)")
// Verify the single JWK entry
jwk := jwks.Keys[0]
// Verify required JWK fields
assert.Equal(t, "RSA", jwk.Kty, "Key type should be RSA")
assert.Equal(t, "sig", jwk.Use, "Key use should be sig (signature)")
assert.NotEmpty(t, jwk.Kid, "Key ID should not be empty")
assert.Equal(t, "RS256", jwk.Alg, "Algorithm should be RS256")
assert.NotEmpty(t, jwk.N, "RSA modulus (n) should not be empty")
assert.NotEmpty(t, jwk.E, "RSA exponent (e) should not be empty")
t.Logf("JWK Details - Kty: %s, Use: %s, Kid: %s, Alg: %s", jwk.Kty, jwk.Use, jwk.Kid, jwk.Alg)
t.Logf("RSA Modulus length: %d, Exponent: %s", len(jwk.N), jwk.E)
// Verify base64url encoding (basic validation)
// Base64URL should not contain padding or invalid characters
assert.NotContains(t, jwk.N, "=", "RSA modulus should be base64url encoded (no padding)")
assert.NotContains(t, jwk.E, "=", "RSA exponent should be base64url encoded (no padding)")
assert.NotContains(t, jwk.N, "+", "RSA modulus should be base64url encoded (no + chars)")
assert.NotContains(t, jwk.E, "+", "RSA exponent should be base64url encoded (no + chars)")
assert.NotContains(t, jwk.N, "/", "RSA modulus should be base64url encoded (no / chars)")
assert.NotContains(t, jwk.E, "/", "RSA exponent should be base64url encoded (no / chars)")
// Verify optional JWK fields are not present (as they're not needed for basic JWT signing)
assert.Empty(t, jwk.D, "Private key components should not be exposed in JWKS")
assert.Empty(t, jwk.P, "Private key components should not be exposed in JWKS")
assert.Empty(t, jwk.Q, "Private key components should not be exposed in JWKS")
assert.Empty(t, jwk.DP, "Private key components should not be exposed in JWKS")
assert.Empty(t, jwk.DQ, "Private key components should not be exposed in JWKS")
assert.Empty(t, jwk.QI, "Private key components should not be exposed in JWKS")
})
t.Run("JWKS Response Format Compliance", func(t *testing.T) {
// Test that JWKS response is RFC 7517 compliant
resp, err := http.Get(endpoint)
assert.NoError(t, err)
defer resp.Body.Close()
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
assert.NoError(t, err)
// RFC 7517: JWKS MUST have "keys" member
keys, exists := response["keys"]
assert.True(t, exists, "JWKS must have 'keys' member")
// Keys should be an array
keysArray, ok := keys.([]interface{})
assert.True(t, ok, "Keys should be an array")
assert.Equal(t, 1, len(keysArray), "Should have exactly one key")
// Verify the key is a JSON object
keyObj, ok := keysArray[0].(map[string]interface{})
assert.True(t, ok, "Key should be a JSON object")
// Verify required RSA JWK parameters are present
requiredParams := []string{"kty", "use", "kid", "alg", "n", "e"}
for _, param := range requiredParams {
_, exists := keyObj[param]
assert.True(t, exists, "JWK should have required parameter: %s", param)
}
})
t.Run("JWKS Endpoint Security Headers", func(t *testing.T) {
// Test that security headers are properly set for JWKS endpoint
resp, err := http.Get(endpoint)
assert.NoError(t, err)
defer resp.Body.Close()
// Verify all required security headers for OAuth 2.1 compliance
expectedHeaders := map[string]string{
"Cache-Control": "no-store",
"Pragma": "no-cache",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Content-Type": "application/json; charset=utf-8",
}
for header, expectedValue := range expectedHeaders {
actualValue := resp.Header.Get(header)
assert.Equal(t, expectedValue, actualValue, "Header %s should be set correctly", header)
}
})
}

View file

@ -455,3 +455,310 @@ func base64Encode(data []byte) string {
return string(result)
}
func TestOAuthRevoke(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, "Revoke Test Client", []string{"https://localhost/callback"})
defer CleanupTestClient(t, client.ClientID)
// Obtain access token directly using the utility function
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("Valid Access Token Revocation", func(t *testing.T) {
// Prepare revocation request
data := url.Values{}
data.Set("token", tokenInfo.AccessToken)
data.Set("token_type_hint", "access_token")
// Make revocation request
endpoint := serverURL + baseURL + "/oauth/revoke"
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 200 OK for successful revocation
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Access token revoked successfully")
})
t.Run("Valid Refresh Token Revocation", func(t *testing.T) {
// Prepare revocation request for refresh token
data := url.Values{}
data.Set("token", tokenInfo.RefreshToken)
data.Set("token_type_hint", "refresh_token")
// Make revocation request
endpoint := serverURL + baseURL + "/oauth/revoke"
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 200 OK for successful revocation
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Refresh token revoked successfully")
})
t.Run("Invalid Token Revocation", func(t *testing.T) {
// Prepare revocation request with invalid token
data := url.Values{}
data.Set("token", "invalid-token-12345")
data.Set("token_type_hint", "access_token")
// Make revocation request
endpoint := serverURL + baseURL + "/oauth/revoke"
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 200 OK even for invalid tokens (RFC 7009)
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Invalid token revocation handled correctly")
})
t.Run("Missing Token Parameter", func(t *testing.T) {
// Prepare revocation request without token parameter
data := url.Values{}
// Missing token parameter
// Make revocation request
endpoint := serverURL + baseURL + "/oauth/revoke"
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 400 Bad Request for missing token
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
}
func TestOAuthIntrospect(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, "Introspect Test Client", []string{"https://localhost/callback"})
defer CleanupTestClient(t, client.ClientID)
// Obtain access token directly using the utility function
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("Valid Access Token Introspection", func(t *testing.T) {
// Prepare introspection request
data := url.Values{}
data.Set("token", tokenInfo.AccessToken)
data.Set("token_type_hint", "access_token")
// Make introspection request
endpoint := serverURL + baseURL + "/oauth/introspect"
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 200 OK
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Read response body
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
// Parse the wrapped response structure
var wrappedResp struct {
Success bool `json:"success"`
Data TokenIntrospectionResponse `json:"data"`
Timestamp string `json:"timestamp"`
}
err = json.Unmarshal(body, &wrappedResp)
assert.NoError(t, err)
// Verify the wrapped response
assert.True(t, wrappedResp.Success)
// Get the actual introspection data
introspectResp := wrappedResp.Data
// Verify introspection response
assert.True(t, introspectResp.Active)
assert.Equal(t, client.ClientID, introspectResp.ClientID)
assert.Equal(t, "Bearer", introspectResp.TokenType)
// Note: Scope and ExpiresAt might not be included in the response
t.Logf("Token introspection result: Active=%v, ClientID=%s, TokenType=%s, Scope=%s",
introspectResp.Active, introspectResp.ClientID, introspectResp.TokenType, introspectResp.Scope)
})
t.Run("Invalid Token Introspection", func(t *testing.T) {
// Prepare introspection request with invalid token
data := url.Values{}
data.Set("token", "invalid-token-12345")
data.Set("token_type_hint", "access_token")
// Make introspection request
endpoint := serverURL + baseURL + "/oauth/introspect"
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 200 OK
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Read response body
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
// Parse the wrapped response structure
var wrappedResp struct {
Success bool `json:"success"`
Data TokenIntrospectionResponse `json:"data"`
Timestamp string `json:"timestamp"`
}
err = json.Unmarshal(body, &wrappedResp)
assert.NoError(t, err)
// Verify the wrapped response
assert.True(t, wrappedResp.Success)
// Get the actual introspection data
introspectResp := wrappedResp.Data
// Should indicate token is inactive
assert.False(t, introspectResp.Active)
t.Logf("Invalid token introspection handled correctly: Active=%v", introspectResp.Active)
})
t.Run("Missing Token Parameter", func(t *testing.T) {
// Prepare introspection request without token parameter
data := url.Values{}
// Missing token parameter
// Make introspection request
endpoint := serverURL + baseURL + "/oauth/introspect"
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 400 Bad Request for missing token
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Revoked Token Introspection", func(t *testing.T) {
// First revoke the token
revokeData := url.Values{}
revokeData.Set("token", tokenInfo.AccessToken)
revokeEndpoint := serverURL + baseURL + "/oauth/revoke"
revokeReq, err := http.NewRequest("POST", revokeEndpoint, bytes.NewBufferString(revokeData.Encode()))
assert.NoError(t, err)
revokeReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
revokeReq.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
revokeResp, err := http.DefaultClient.Do(revokeReq)
assert.NoError(t, err)
defer revokeResp.Body.Close()
assert.Equal(t, http.StatusOK, revokeResp.StatusCode)
// Now try to introspect the revoked token
data := url.Values{}
data.Set("token", tokenInfo.AccessToken)
endpoint := serverURL + baseURL + "/oauth/introspect"
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 200 OK
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Read response body
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
// Parse the wrapped response structure
var wrappedResp struct {
Success bool `json:"success"`
Data TokenIntrospectionResponse `json:"data"`
Timestamp string `json:"timestamp"`
}
err = json.Unmarshal(body, &wrappedResp)
assert.NoError(t, err)
// Verify the wrapped response
assert.True(t, wrappedResp.Success)
// Get the actual introspection data
introspectResp := wrappedResp.Data
// Revoked token should be inactive
assert.False(t, introspectResp.Active)
t.Logf("Revoked token introspection handled correctly: Active=%v", introspectResp.Active)
})
}

View file

@ -419,6 +419,96 @@ func ObtainAuthorizationCode(t *testing.T, serverURL, clientID, redirectURI, sco
return authInfo
}
// ObtainAccessToken directly obtains an access token for testing OAuth endpoints that require authentication.
//
// AI ASSISTANT INSTRUCTIONS:
// Use this function to get a real access token for testing OAuth endpoints like introspect, revoke, etc.
// This function handles the complete OAuth flow (authorization + token exchange) and returns a ready-to-use token.
//
// Usage pattern:
//
// func TestOAuthIntrospect(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 access token directly
// tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
//
// // Now test introspect endpoint with real access token
// // POST to /oauth/introspect with token=tokenInfo.AccessToken
// }
//
// PARAMETERS:
// - t: The test instance for error reporting
// - serverURL: The test server URL (from Prepare function)
// - clientID: The OAuth client ID (from RegisterTestClient)
// - clientSecret: The OAuth client secret (from RegisterTestClient)
// - redirectURI: The redirect URI (must match client registration)
// - scope: The requested OAuth scope (e.g., "openid profile email")
//
// RETURN VALUE:
// Returns TokenInfo struct containing:
// - AccessToken: The access token for API calls
// - RefreshToken: The refresh token for token renewal
// - TokenType: The token type (usually "Bearer")
// - ExpiresIn: Token expiration time in seconds
// - Scope: The granted scope
// - ClientID: The client ID used to obtain the token
//
// WHAT THIS FUNCTION DOES:
// 1. Calls ObtainAuthorizationCode to get an authorization code
// 2. Exchanges the authorization code for an access token using the OAuth service
// 3. Returns all token information needed for authenticated API testing
//
// ERROR HANDLING:
// If token exchange fails, the test will fail immediately with a descriptive error message.
type TokenInfo struct {
AccessToken string
RefreshToken string
TokenType string
ExpiresIn int
Scope string
ClientID string
}
func ObtainAccessToken(t *testing.T, serverURL, clientID, clientSecret, redirectURI, scope string) *TokenInfo {
if Server == nil || Server.OAuth == nil {
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
}
// Step 1: Get authorization code
authInfo := ObtainAuthorizationCode(t, serverURL, clientID, redirectURI, scope)
// Step 2: Exchange authorization code for access token
ctx := context.Background()
token, err := Server.OAuth.Token(ctx, "authorization_code", authInfo.Code, clientID, "")
if err != nil {
t.Fatalf("Failed to exchange authorization code for token: %v", err)
}
// Verify we got a valid token
if token.AccessToken == "" {
t.Fatal("Token response missing access token")
}
tokenInfo := &TokenInfo{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
TokenType: token.TokenType,
ExpiresIn: token.ExpiresIn,
Scope: token.Scope,
ClientID: clientID,
}
t.Logf("Obtained access token: %s (type: %s, expires_in: %d)",
tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn)
return tokenInfo
}
func TestLoad(t *testing.T) {
serverURL := Prepare(t)
defer Clean()
@ -427,3 +517,26 @@ func TestLoad(t *testing.T) {
assert.NotEmpty(t, serverURL)
assert.Contains(t, serverURL, "http://127.0.0.1:")
}
func TestObtainAccessToken(t *testing.T) {
serverURL := Prepare(t)
defer Clean()
// Register a test client
client := RegisterTestClient(t, "Token Utility Test Client", []string{"https://localhost/callback"})
defer CleanupTestClient(t, client.ClientID)
// Test the ObtainAccessToken utility function
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile email")
// Verify token information
assert.NotEmpty(t, tokenInfo.AccessToken, "Access token should not be empty")
assert.NotEmpty(t, tokenInfo.RefreshToken, "Refresh token should not be empty")
assert.Equal(t, "Bearer", tokenInfo.TokenType, "Token type should be Bearer")
assert.Greater(t, tokenInfo.ExpiresIn, 0, "ExpiresIn should be greater than 0")
assert.Equal(t, client.ClientID, tokenInfo.ClientID, "Client ID should match")
// Note: Scope might be empty in token response, which is valid
t.Logf("Successfully obtained token: AccessToken=%s, TokenType=%s, ExpiresIn=%d, Scope=%s",
tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn, tokenInfo.Scope)
}