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:
parent
2b171c3540
commit
55931bb59b
3 changed files with 455 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -455,3 +455,310 @@ func base64Encode(data []byte) string {
|
|||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func TestOAuthRevoke(t *testing.T) {
|
||||
serverURL := Prepare(t)
|
||||
defer Clean()
|
||||
|
||||
// Get base URL from server config
|
||||
baseURL := ""
|
||||
if Server != nil && Server.Config != nil {
|
||||
baseURL = Server.Config.BaseURL
|
||||
}
|
||||
|
||||
// Register a test client
|
||||
client := RegisterTestClient(t, "Revoke Test Client", []string{"https://localhost/callback"})
|
||||
defer CleanupTestClient(t, client.ClientID)
|
||||
|
||||
// Obtain access token directly using the utility function
|
||||
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
t.Run("Valid Access Token Revocation", func(t *testing.T) {
|
||||
// Prepare revocation request
|
||||
data := url.Values{}
|
||||
data.Set("token", tokenInfo.AccessToken)
|
||||
data.Set("token_type_hint", "access_token")
|
||||
|
||||
// Make revocation request
|
||||
endpoint := serverURL + baseURL + "/oauth/revoke"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 200 OK for successful revocation
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
t.Logf("Access token revoked successfully")
|
||||
})
|
||||
|
||||
t.Run("Valid Refresh Token Revocation", func(t *testing.T) {
|
||||
// Prepare revocation request for refresh token
|
||||
data := url.Values{}
|
||||
data.Set("token", tokenInfo.RefreshToken)
|
||||
data.Set("token_type_hint", "refresh_token")
|
||||
|
||||
// Make revocation request
|
||||
endpoint := serverURL + baseURL + "/oauth/revoke"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 200 OK for successful revocation
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
t.Logf("Refresh token revoked successfully")
|
||||
})
|
||||
|
||||
t.Run("Invalid Token Revocation", func(t *testing.T) {
|
||||
// Prepare revocation request with invalid token
|
||||
data := url.Values{}
|
||||
data.Set("token", "invalid-token-12345")
|
||||
data.Set("token_type_hint", "access_token")
|
||||
|
||||
// Make revocation request
|
||||
endpoint := serverURL + baseURL + "/oauth/revoke"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 200 OK even for invalid tokens (RFC 7009)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
t.Logf("Invalid token revocation handled correctly")
|
||||
})
|
||||
|
||||
t.Run("Missing Token Parameter", func(t *testing.T) {
|
||||
// Prepare revocation request without token parameter
|
||||
data := url.Values{}
|
||||
// Missing token parameter
|
||||
|
||||
// Make revocation request
|
||||
endpoint := serverURL + baseURL + "/oauth/revoke"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 400 Bad Request for missing token
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOAuthIntrospect(t *testing.T) {
|
||||
serverURL := Prepare(t)
|
||||
defer Clean()
|
||||
|
||||
// Get base URL from server config
|
||||
baseURL := ""
|
||||
if Server != nil && Server.Config != nil {
|
||||
baseURL = Server.Config.BaseURL
|
||||
}
|
||||
|
||||
// Register a test client
|
||||
client := RegisterTestClient(t, "Introspect Test Client", []string{"https://localhost/callback"})
|
||||
defer CleanupTestClient(t, client.ClientID)
|
||||
|
||||
// Obtain access token directly using the utility function
|
||||
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
t.Run("Valid Access Token Introspection", func(t *testing.T) {
|
||||
// Prepare introspection request
|
||||
data := url.Values{}
|
||||
data.Set("token", tokenInfo.AccessToken)
|
||||
data.Set("token_type_hint", "access_token")
|
||||
|
||||
// Make introspection request
|
||||
endpoint := serverURL + baseURL + "/oauth/introspect"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 200 OK
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Parse the wrapped response structure
|
||||
var wrappedResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data TokenIntrospectionResponse `json:"data"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
err = json.Unmarshal(body, &wrappedResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the wrapped response
|
||||
assert.True(t, wrappedResp.Success)
|
||||
|
||||
// Get the actual introspection data
|
||||
introspectResp := wrappedResp.Data
|
||||
|
||||
// Verify introspection response
|
||||
assert.True(t, introspectResp.Active)
|
||||
assert.Equal(t, client.ClientID, introspectResp.ClientID)
|
||||
assert.Equal(t, "Bearer", introspectResp.TokenType)
|
||||
// Note: Scope and ExpiresAt might not be included in the response
|
||||
|
||||
t.Logf("Token introspection result: Active=%v, ClientID=%s, TokenType=%s, Scope=%s",
|
||||
introspectResp.Active, introspectResp.ClientID, introspectResp.TokenType, introspectResp.Scope)
|
||||
})
|
||||
|
||||
t.Run("Invalid Token Introspection", func(t *testing.T) {
|
||||
// Prepare introspection request with invalid token
|
||||
data := url.Values{}
|
||||
data.Set("token", "invalid-token-12345")
|
||||
data.Set("token_type_hint", "access_token")
|
||||
|
||||
// Make introspection request
|
||||
endpoint := serverURL + baseURL + "/oauth/introspect"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 200 OK
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Parse the wrapped response structure
|
||||
var wrappedResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data TokenIntrospectionResponse `json:"data"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
err = json.Unmarshal(body, &wrappedResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the wrapped response
|
||||
assert.True(t, wrappedResp.Success)
|
||||
|
||||
// Get the actual introspection data
|
||||
introspectResp := wrappedResp.Data
|
||||
|
||||
// Should indicate token is inactive
|
||||
assert.False(t, introspectResp.Active)
|
||||
|
||||
t.Logf("Invalid token introspection handled correctly: Active=%v", introspectResp.Active)
|
||||
})
|
||||
|
||||
t.Run("Missing Token Parameter", func(t *testing.T) {
|
||||
// Prepare introspection request without token parameter
|
||||
data := url.Values{}
|
||||
// Missing token parameter
|
||||
|
||||
// Make introspection request
|
||||
endpoint := serverURL + baseURL + "/oauth/introspect"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 400 Bad Request for missing token
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Revoked Token Introspection", func(t *testing.T) {
|
||||
// First revoke the token
|
||||
revokeData := url.Values{}
|
||||
revokeData.Set("token", tokenInfo.AccessToken)
|
||||
|
||||
revokeEndpoint := serverURL + baseURL + "/oauth/revoke"
|
||||
revokeReq, err := http.NewRequest("POST", revokeEndpoint, bytes.NewBufferString(revokeData.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
revokeReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
revokeReq.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
revokeResp, err := http.DefaultClient.Do(revokeReq)
|
||||
assert.NoError(t, err)
|
||||
defer revokeResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, revokeResp.StatusCode)
|
||||
|
||||
// Now try to introspect the revoked token
|
||||
data := url.Values{}
|
||||
data.Set("token", tokenInfo.AccessToken)
|
||||
|
||||
endpoint := serverURL + baseURL + "/oauth/introspect"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode()))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth(client.ClientID, client.ClientSecret))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 200 OK
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Parse the wrapped response structure
|
||||
var wrappedResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data TokenIntrospectionResponse `json:"data"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
err = json.Unmarshal(body, &wrappedResp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the wrapped response
|
||||
assert.True(t, wrappedResp.Success)
|
||||
|
||||
// Get the actual introspection data
|
||||
introspectResp := wrappedResp.Data
|
||||
|
||||
// Revoked token should be inactive
|
||||
assert.False(t, introspectResp.Active)
|
||||
|
||||
t.Logf("Revoked token introspection handled correctly: Active=%v", introspectResp.Active)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -419,6 +419,96 @@ func ObtainAuthorizationCode(t *testing.T, serverURL, clientID, redirectURI, sco
|
|||
return authInfo
|
||||
}
|
||||
|
||||
// ObtainAccessToken directly obtains an access token for testing OAuth endpoints that require authentication.
|
||||
//
|
||||
// AI ASSISTANT INSTRUCTIONS:
|
||||
// Use this function to get a real access token for testing OAuth endpoints like introspect, revoke, etc.
|
||||
// This function handles the complete OAuth flow (authorization + token exchange) and returns a ready-to-use token.
|
||||
//
|
||||
// Usage pattern:
|
||||
//
|
||||
// func TestOAuthIntrospect(t *testing.T) {
|
||||
// serverURL := Prepare(t)
|
||||
// defer Clean()
|
||||
//
|
||||
// // Register a test client
|
||||
// client := RegisterTestClient(t, "Test Client", []string{"https://localhost/callback"})
|
||||
// defer CleanupTestClient(t, client.ClientID)
|
||||
//
|
||||
// // Obtain access token directly
|
||||
// tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
//
|
||||
// // Now test introspect endpoint with real access token
|
||||
// // POST to /oauth/introspect with token=tokenInfo.AccessToken
|
||||
// }
|
||||
//
|
||||
// PARAMETERS:
|
||||
// - t: The test instance for error reporting
|
||||
// - serverURL: The test server URL (from Prepare function)
|
||||
// - clientID: The OAuth client ID (from RegisterTestClient)
|
||||
// - clientSecret: The OAuth client secret (from RegisterTestClient)
|
||||
// - redirectURI: The redirect URI (must match client registration)
|
||||
// - scope: The requested OAuth scope (e.g., "openid profile email")
|
||||
//
|
||||
// RETURN VALUE:
|
||||
// Returns TokenInfo struct containing:
|
||||
// - AccessToken: The access token for API calls
|
||||
// - RefreshToken: The refresh token for token renewal
|
||||
// - TokenType: The token type (usually "Bearer")
|
||||
// - ExpiresIn: Token expiration time in seconds
|
||||
// - Scope: The granted scope
|
||||
// - ClientID: The client ID used to obtain the token
|
||||
//
|
||||
// WHAT THIS FUNCTION DOES:
|
||||
// 1. Calls ObtainAuthorizationCode to get an authorization code
|
||||
// 2. Exchanges the authorization code for an access token using the OAuth service
|
||||
// 3. Returns all token information needed for authenticated API testing
|
||||
//
|
||||
// ERROR HANDLING:
|
||||
// If token exchange fails, the test will fail immediately with a descriptive error message.
|
||||
type TokenInfo struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
TokenType string
|
||||
ExpiresIn int
|
||||
Scope string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
func ObtainAccessToken(t *testing.T, serverURL, clientID, clientSecret, redirectURI, scope string) *TokenInfo {
|
||||
if Server == nil || Server.OAuth == nil {
|
||||
t.Fatal("OpenAPI server not initialized. Call Prepare(t) first.")
|
||||
}
|
||||
|
||||
// Step 1: Get authorization code
|
||||
authInfo := ObtainAuthorizationCode(t, serverURL, clientID, redirectURI, scope)
|
||||
|
||||
// Step 2: Exchange authorization code for access token
|
||||
ctx := context.Background()
|
||||
token, err := Server.OAuth.Token(ctx, "authorization_code", authInfo.Code, clientID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to exchange authorization code for token: %v", err)
|
||||
}
|
||||
|
||||
// Verify we got a valid token
|
||||
if token.AccessToken == "" {
|
||||
t.Fatal("Token response missing access token")
|
||||
}
|
||||
|
||||
tokenInfo := &TokenInfo{
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
TokenType: token.TokenType,
|
||||
ExpiresIn: token.ExpiresIn,
|
||||
Scope: token.Scope,
|
||||
ClientID: clientID,
|
||||
}
|
||||
|
||||
t.Logf("Obtained access token: %s (type: %s, expires_in: %d)",
|
||||
tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn)
|
||||
return tokenInfo
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
serverURL := Prepare(t)
|
||||
defer Clean()
|
||||
|
|
@ -427,3 +517,26 @@ func TestLoad(t *testing.T) {
|
|||
assert.NotEmpty(t, serverURL)
|
||||
assert.Contains(t, serverURL, "http://127.0.0.1:")
|
||||
}
|
||||
|
||||
func TestObtainAccessToken(t *testing.T) {
|
||||
serverURL := Prepare(t)
|
||||
defer Clean()
|
||||
|
||||
// Register a test client
|
||||
client := RegisterTestClient(t, "Token Utility Test Client", []string{"https://localhost/callback"})
|
||||
defer CleanupTestClient(t, client.ClientID)
|
||||
|
||||
// Test the ObtainAccessToken utility function
|
||||
tokenInfo := ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile email")
|
||||
|
||||
// Verify token information
|
||||
assert.NotEmpty(t, tokenInfo.AccessToken, "Access token should not be empty")
|
||||
assert.NotEmpty(t, tokenInfo.RefreshToken, "Refresh token should not be empty")
|
||||
assert.Equal(t, "Bearer", tokenInfo.TokenType, "Token type should be Bearer")
|
||||
assert.Greater(t, tokenInfo.ExpiresIn, 0, "ExpiresIn should be greater than 0")
|
||||
assert.Equal(t, client.ClientID, tokenInfo.ClientID, "Client ID should match")
|
||||
// Note: Scope might be empty in token response, which is valid
|
||||
|
||||
t.Logf("Successfully obtained token: AccessToken=%s, TokenType=%s, ExpiresIn=%d, Scope=%s",
|
||||
tokenInfo.AccessToken, tokenInfo.TokenType, tokenInfo.ExpiresIn, tokenInfo.Scope)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue