From 55931bb59b4176c0812dce0e6ec4651ce3209d6c Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 21 Jul 2025 17:57:34 +0800 Subject: [PATCH 1/5] Add OAuth token revocation and introspection tests - Implemented comprehensive tests for the OAuth token revocation and introspection endpoints, ensuring correct handling of valid, invalid, and missing token scenarios. - Enhanced the oauthRevoke and oauthIntrospect methods to comply with RFC specifications, returning appropriate status codes and responses. - Introduced a utility function for obtaining access tokens directly in tests, streamlining the testing process for OAuth endpoints. - Improved error handling and logging for better debugging and verification during tests. --- openapi/oauth.go | 40 ++++- openapi/oauth_token_test.go | 307 ++++++++++++++++++++++++++++++++++++ openapi/openapi_test.go | 113 +++++++++++++ 3 files changed, 455 insertions(+), 5 deletions(-) diff --git a/openapi/oauth.go b/openapi/oauth.go index 44c69b75..880d0d9c 100644 --- a/openapi/oauth.go +++ b/openapi/oauth.go @@ -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) diff --git a/openapi/oauth_token_test.go b/openapi/oauth_token_test.go index 6403c825..95cdfcfc 100644 --- a/openapi/oauth_token_test.go +++ b/openapi/oauth_token_test.go @@ -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) + }) +} diff --git a/openapi/openapi_test.go b/openapi/openapi_test.go index 144fd0f5..3613e8c6 100644 --- a/openapi/openapi_test.go +++ b/openapi/openapi_test.go @@ -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) +} From 8d1174d566939ca483638f955f527de59e7feb16 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 21 Jul 2025 18:49:20 +0800 Subject: [PATCH 2/5] Refactor hello world endpoints and add OAuth protection - Renamed existing hello world endpoints to public and added a new protected endpoint with OAuth guard. - Updated test cases to reflect the new endpoint structure and added tests for protected endpoint access with and without valid tokens. - Enhanced response handling for public and protected endpoints to ensure consistent output and proper status codes. --- openapi/hello.go | 27 +++++- openapi/hello_test.go | 153 +++++++++++++++++++++++++++++- openapi/oauth/core.go | 5 + openapi/oauth/guard.go | 21 ++++ openapi/oauth/types/interfaces.go | 5 + 5 files changed, 202 insertions(+), 9 deletions(-) diff --git a/openapi/hello.go b/openapi/hello.go index f3bd5b7a..b267587a 100644 --- a/openapi/hello.go +++ b/openapi/hello.go @@ -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", diff --git a/openapi/hello_test.go b/openapi/hello_test.go index ab7783c8..11ff2074 100644 --- a/openapi/hello_test.go +++ b/openapi/hello_test.go @@ -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) + }) + } +} diff --git a/openapi/oauth/core.go b/openapi/oauth/core.go index f2bb6dc3..c6925425 100644 --- a/openapi/oauth/core.go +++ b/openapi/oauth/core.go @@ -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) diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index c90af7b3..027df9d1 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -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 +} diff --git a/openapi/oauth/types/interfaces.go b/openapi/oauth/types/interfaces.go index c95be61a..c53bd7b3 100644 --- a/openapi/oauth/types/interfaces.go +++ b/openapi/oauth/types/interfaces.go @@ -3,6 +3,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 +136,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 From e1428551ba7b4f8fecc2e751edd071892ae7b0ad Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 21 Jul 2025 18:59:26 +0800 Subject: [PATCH 3/5] Refactor OAuth token management tests and remove deprecated methods - Removed outdated token management tests from the user provider, including tests for storing, revoking, and retrieving tokens. - Updated the user provider interface to reflect the removal of token management methods, ensuring cleaner code and improved maintainability. - Streamlined the test suite by focusing on relevant user management functionalities, enhancing overall test clarity and effectiveness. --- openapi/oauth/core_test.go | 12 - openapi/oauth/providers/user/default.go | 112 ++--- openapi/oauth/providers/user/default_test.go | 122 ------ openapi/oauth/types/interfaces.go | 9 +- openapi/oauth/user_test.go | 438 ------------------- 5 files changed, 60 insertions(+), 633 deletions(-) diff --git a/openapi/oauth/core_test.go b/openapi/oauth/core_test.go index 6edd15ca..3db677d3 100644 --- a/openapi/oauth/core_test.go +++ b/openapi/oauth/core_test.go @@ -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) { diff --git a/openapi/oauth/providers/user/default.go b/openapi/oauth/providers/user/default.go index 90c9c346..6dbe3156 100644 --- a/openapi/oauth/providers/user/default.go +++ b/openapi/oauth/providers/user/default.go @@ -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) { diff --git a/openapi/oauth/providers/user/default_test.go b/openapi/oauth/providers/user/default_test.go index a9f1c62e..28dfaf03 100644 --- a/openapi/oauth/providers/user/default_test.go +++ b/openapi/oauth/providers/user/default_test.go @@ -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() diff --git a/openapi/oauth/types/interfaces.go b/openapi/oauth/types/interfaces.go index c53bd7b3..2677c615 100644 --- a/openapi/oauth/types/interfaces.go +++ b/openapi/oauth/types/interfaces.go @@ -2,7 +2,6 @@ package types import ( "context" - "time" "github.com/gin-gonic/gin" ) @@ -154,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 diff --git a/openapi/oauth/user_test.go b/openapi/oauth/user_test.go index 299f1145..c90af7b3 100644 --- a/openapi/oauth/user_test.go +++ b/openapi/oauth/user_test.go @@ -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"]) - } - }) -} From 966e0cfd008c165e08c9842704358e5572b2f367 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 21 Jul 2025 19:44:00 +0800 Subject: [PATCH 4/5] Enhance OAuth test setup with reusable certificate management - Introduced global test certificate paths to avoid redundant certificate generation across tests, improving efficiency. - Implemented a function to create temporary certificates once for all tests, ensuring consistent usage of signing certificates. - Updated test configurations to utilize the new certificate management, enhancing clarity and maintainability. - Added cleanup functionality for global test certificates to ensure proper resource management after tests. --- openapi/oauth/oauth.go | 26 ++- openapi/oauth/oauth_test.go | 133 ++++++++--- openapi/oauth/signing.go | 429 ++++++++++++++++++++++++++++++++++ openapi/oauth/types/errors.go | 2 +- 4 files changed, 558 insertions(+), 32 deletions(-) create mode 100644 openapi/oauth/signing.go diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index 6b5227b2..718b2452 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -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 diff --git a/openapi/oauth/oauth_test.go b/openapi/oauth/oauth_test.go index 3152f6f9..900c0a62 100644 --- a/openapi/oauth/oauth_test.go +++ b/openapi/oauth/oauth_test.go @@ -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) }) diff --git a/openapi/oauth/signing.go b/openapi/oauth/signing.go new file mode 100644 index 00000000..2644218f --- /dev/null +++ b/openapi/oauth/signing.go @@ -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() +} diff --git a/openapi/oauth/types/errors.go b/openapi/oauth/types/errors.go index 29ba5d20..e4bee7ed 100644 --- a/openapi/oauth/types/errors.go +++ b/openapi/oauth/types/errors.go @@ -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"} ) From 41c44cb7261adc42fcaafe8355b7a2b31d4b6b19 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 21 Jul 2025 20:07:46 +0800 Subject: [PATCH 5/5] Implement JWKS endpoint and enhance OAuth tests - Added the JWKS endpoint to return JSON Web Key Set in compliance with RFC 7517, including necessary security headers. - Refactored the JWKS generation logic to retrieve signing certificates and construct the JWK from the RSA public key. - Introduced comprehensive tests for the JWKS endpoint, validating response format, compliance, and security headers. - Updated go.mod to include the MongoDB driver as a required dependency. --- go.mod | 2 +- openapi/oauth.go | 18 ++++- openapi/oauth/discovery.go | 39 +++++++++- openapi/oauth_test.go | 152 +++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 6350afbc..2a2eb722 100644 --- a/go.mod +++ b/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 diff --git a/openapi/oauth.go b/openapi/oauth.go index 880d0d9c..96463ca8 100644 --- a/openapi/oauth.go +++ b/openapi/oauth.go @@ -376,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 diff --git a/openapi/oauth/discovery.go b/openapi/oauth/discovery.go index 71cfb216..ad0cb2bc 100644 --- a/openapi/oauth/discovery.go +++ b/openapi/oauth/discovery.go @@ -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 } diff --git a/openapi/oauth_test.go b/openapi/oauth_test.go index 869e40d7..32ce2fa4 100644 --- a/openapi/oauth_test.go +++ b/openapi/oauth_test.go @@ -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) + } + }) +}