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) +}