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.
This commit is contained in:
Max 2025-07-21 17:57:34 +08:00
parent 2b171c3540
commit 55931bb59b
3 changed files with 455 additions and 5 deletions

View file

@ -316,14 +316,26 @@ func (openapi *OpenAPI) handleTokenExchangeGrant(c *gin.Context) {
// oauthRevoke handles token revocation - RFC 7009
func (openapi *OpenAPI) oauthRevoke(c *gin.Context) {
token := c.PostForm("token")
tokenTypeHint := c.PostForm("token_type_hint") // Optional hint about token type
if token == "" {
openapi.respondWithError(c, StatusBadRequest, ErrInvalidRequest)
return
}
// TODO: Implement token revocation logic
c.Status(StatusNoContent)
// Call OAuth service to revoke the token
err := openapi.OAuth.Revoke(c, token, tokenTypeHint)
if err != nil {
// OAuth spec requires returning 200 even for invalid tokens to prevent information leakage
// Only return error for server errors
if oauthErr, ok := err.(*ErrorResponse); ok && oauthErr.Code == ErrServerError.Code {
openapi.respondWithError(c, StatusInternalServerError, ErrServerError)
return
}
}
// RFC 7009: Return 200 OK for successful revocation (or invalid tokens)
c.Status(StatusOK)
}
// oauthIntrospect handles token introspection - RFC 7662
@ -335,10 +347,28 @@ func (openapi *OpenAPI) oauthIntrospect(c *gin.Context) {
return
}
// TODO: Implement token introspection logic
// Return inactive token for now
// Call OAuth service to introspect the token
introspectionResult, err := openapi.OAuth.Introspect(c, token)
if err != nil {
// Return inactive token response on error (RFC 7662)
response := &TokenIntrospectionResponse{
Active: false,
}
openapi.respondWithSuccess(c, StatusOK, response)
return
}
// Convert OAuth service response to API response format
response := &TokenIntrospectionResponse{
Active: false,
Active: introspectionResult.Active,
Scope: introspectionResult.Scope,
ClientID: introspectionResult.ClientID,
Username: introspectionResult.Username,
TokenType: introspectionResult.TokenType,
ExpiresAt: introspectionResult.ExpiresAt,
IssuedAt: introspectionResult.IssuedAt,
Subject: introspectionResult.Subject,
Audience: introspectionResult.Audience,
}
openapi.respondWithSuccess(c, StatusOK, response)

View file

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

View file

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