Merge pull request #1027 from trheyi/main
Implement JWKS endpoint and enhance OAuth tests
This commit is contained in:
commit
a09ddd70ac
19 changed files with 1478 additions and 687 deletions
2
go.mod
2
go.mod
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
429
openapi/oauth/signing.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue