mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
Fix the issue with Google OAuth invalid scopes
This commit is contained in:
@@ -11,6 +11,7 @@ summary: |
|
|||||||
role-based access control, token caching, and more.
|
role-based access control, token caching, and more.
|
||||||
|
|
||||||
The middleware has been tested with Auth0, Logto, Google, and other standard OIDC providers.
|
The middleware has been tested with Auth0, Logto, Google, and other standard OIDC providers.
|
||||||
|
It includes special handling for Google's OAuth implementation to ensure compatibility.
|
||||||
It supports various authentication scenarios including:
|
It supports various authentication scenarios including:
|
||||||
|
|
||||||
- Basic authentication with customizable callback and logout URLs
|
- Basic authentication with customizable callback and logout URLs
|
||||||
@@ -152,6 +153,10 @@ configuration:
|
|||||||
Default: ["openid", "profile", "email"]
|
Default: ["openid", "profile", "email"]
|
||||||
|
|
||||||
Include "roles" or similar scope if you need role/group information.
|
Include "roles" or similar scope if you need role/group information.
|
||||||
|
|
|
||||||
|
| Note: For Google OAuth, the middleware automatically handles the
|
||||||
|
| proper authentication parameters and does NOT require the "offline_access"
|
||||||
|
| scope (which Google rejects as invalid). See documentation for details.
|
||||||
required: false
|
required: false
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ The Traefik OIDC middleware provides a complete OIDC authentication solution wit
|
|||||||
- Rate limiting
|
- Rate limiting
|
||||||
- Excluded paths (public URLs)
|
- Excluded paths (public URLs)
|
||||||
|
|
||||||
The middleware has been tested with Auth0 and Logto, but should work with any standard OIDC provider.
|
The middleware has been tested with Auth0, Logto, Google and other standard OIDC providers. It includes special handling for Google's OAuth implementation.
|
||||||
|
|
||||||
## Traefik Version Compatibility
|
## Traefik Version Compatibility
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ spec:
|
|||||||
|
|
||||||
### Google OIDC Configuration Example
|
### Google OIDC Configuration Example
|
||||||
|
|
||||||
This example shows a configuration specifically tailored for Google OIDC, including necessary scopes for session extension:
|
This example shows a configuration specifically tailored for Google OIDC:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
@@ -318,11 +318,14 @@ spec:
|
|||||||
- openid
|
- openid
|
||||||
- email
|
- email
|
||||||
- profile
|
- profile
|
||||||
- offline_access # Required for refresh tokens / long sessions with Google
|
# Note: DO NOT manually add offline_access scope for Google
|
||||||
|
# The middleware automatically handles Google-specific requirements
|
||||||
refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60)
|
refreshGracePeriodSeconds: 300 # Optional: Start refresh 5 min before expiry (default 60)
|
||||||
# Other optional parameters like allowedUserDomains, etc. can be added here
|
# Other optional parameters like allowedUserDomains, etc. can be added here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The middleware automatically detects Google as the provider and applies the necessary adjustments to ensure proper authentication and token refresh. See the [Google OAuth Fix](#google-oauth-compatibility-fix) section for details.
|
||||||
|
|
||||||
### Keeping Secrets Secret in Kubernetes
|
### Keeping Secrets Secret in Kubernetes
|
||||||
|
|
||||||
For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values:
|
For Kubernetes environments, you can reference secrets instead of hardcoding sensitive values:
|
||||||
@@ -505,6 +508,21 @@ This middleware aims to provide long-lived user sessions, typically up to 24 hou
|
|||||||
- If a refresh attempt fails (e.g., the refresh token is revoked or expired), the user will be required to re-authenticate. The middleware includes enhanced error handling and logging for these scenarios.
|
- If a refresh attempt fails (e.g., the refresh token is revoked or expired), the user will be required to re-authenticate. The middleware includes enhanced error handling and logging for these scenarios.
|
||||||
- Ensure your OIDC provider is configured to issue refresh tokens and allows their use for extending sessions. Check your provider's documentation for details on refresh token validity periods.
|
- Ensure your OIDC provider is configured to issue refresh tokens and allows their use for extending sessions. Check your provider's documentation for details on refresh token validity periods.
|
||||||
|
|
||||||
|
### Google OAuth Compatibility Fix
|
||||||
|
|
||||||
|
The middleware includes a specific fix for Google's OAuth implementation, which differs from the standard OIDC specification in how it handles refresh tokens:
|
||||||
|
|
||||||
|
- **Issue**: Google does not support the standard `offline_access` scope for requesting refresh tokens and instead requires special parameters.
|
||||||
|
|
||||||
|
- **Automatic Solution**: The middleware detects Google as the provider based on the issuer URL and:
|
||||||
|
- Uses `access_type=offline` query parameter instead of the `offline_access` scope
|
||||||
|
- Adds `prompt=consent` to ensure refresh tokens are consistently issued
|
||||||
|
- Properly handles token refresh with Google's implementation
|
||||||
|
|
||||||
|
You do not need any special configuration to use Google OAuth - just set `providerURL` to `https://accounts.google.com` and the middleware will automatically apply the proper parameters.
|
||||||
|
|
||||||
|
For detailed information on the Google OAuth fix, see the [dedicated documentation](docs/google-oauth-fix.md).
|
||||||
|
|
||||||
### Token Caching and Blacklisting
|
### Token Caching and Blacklisting
|
||||||
|
|
||||||
The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens.
|
The middleware automatically caches validated tokens to improve performance and maintains a blacklist of revoked tokens.
|
||||||
@@ -591,9 +609,11 @@ logLevel: debug
|
|||||||
4. **Access denied: Your email domain is not allowed**: The user's email domain is not in the `allowedUserDomains` list.
|
4. **Access denied: Your email domain is not allowed**: The user's email domain is not in the `allowedUserDomains` list.
|
||||||
5. **Access denied: You do not have any of the allowed roles or groups**: The user doesn't have any of the roles or groups specified in `allowedRolesAndGroups`.
|
5. **Access denied: You do not have any of the allowed roles or groups**: The user doesn't have any of the roles or groups specified in `allowedRolesAndGroups`.
|
||||||
6. **Google sessions expire after ~1 hour**: If using Google as the OIDC provider and sessions expire prematurely (around 1 hour instead of longer), ensure:
|
6. **Google sessions expire after ~1 hour**: If using Google as the OIDC provider and sessions expire prematurely (around 1 hour instead of longer), ensure:
|
||||||
- The `offline_access` scope is included in your configuration (the middleware adds this automatically now, but verify if manually configured).
|
- Do NOT manually add the `offline_access` scope. Google rejects this scope as invalid.
|
||||||
|
- The middleware automatically applies the required Google parameters (`access_type=offline` and `prompt=consent`).
|
||||||
- Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity.
|
- Your Google Cloud OAuth consent screen is set to "External" and "Production" mode. "Testing" mode often limits refresh token validity.
|
||||||
- The fix involving automatic `offline_access` scope and `prompt=consent` for Google is active in your middleware version. Check the plugin version corresponds to when this fix was implemented. Enhanced logging around refresh token failures can provide more clues if issues persist.
|
- Verify you're using a version of the middleware that includes the Google OAuth compatibility fix.
|
||||||
|
- For more details, see the [Google OAuth Compatibility Fix](#google-oauth-compatibility-fix) section or the [detailed documentation](docs/google-oauth-fix.md).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Google OAuth Integration Fix
|
||||||
|
|
||||||
|
## Problem Overview
|
||||||
|
|
||||||
|
The Traefik OIDC plugin encountered an authentication issue when using Google as an OAuth provider. Authentication would fail with the following error:
|
||||||
|
|
||||||
|
```
|
||||||
|
Some requested scopes were invalid. {valid=[openid, https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile], invalid=[offline_access]}
|
||||||
|
```
|
||||||
|
|
||||||
|
This occurred because Google's OAuth implementation differs from the standard OIDC specification in how it handles refresh tokens and offline access.
|
||||||
|
|
||||||
|
## Technical Details of the Issue
|
||||||
|
|
||||||
|
### Standard OIDC Provider Behavior
|
||||||
|
|
||||||
|
Most OpenID Connect (OIDC) providers follow the standard specification, where:
|
||||||
|
- To obtain a refresh token, clients include the `offline_access` scope in their authorization request
|
||||||
|
- This allows authenticated sessions to persist beyond the initial access token expiration
|
||||||
|
|
||||||
|
### Google's Non-Standard Approach
|
||||||
|
|
||||||
|
Google's OAuth implementation deviates from the standard by:
|
||||||
|
1. Not supporting the `offline_access` scope, instead rejecting it as an invalid scope
|
||||||
|
2. Requiring the `access_type=offline` query parameter for requesting refresh tokens
|
||||||
|
3. Needing the `prompt=consent` parameter to consistently issue refresh tokens (especially for repeat authentications)
|
||||||
|
|
||||||
|
This difference caused the plugin to fail when configured for Google OAuth, as it was using a standard approach that didn't work with Google's implementation.
|
||||||
|
|
||||||
|
## Solution Implementation
|
||||||
|
|
||||||
|
The fix involved modifying the authentication flow to specifically handle Google providers:
|
||||||
|
|
||||||
|
1. **Google Provider Detection**: Added code to detect if the OIDC provider is Google based on the issuer URL:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Check if we're dealing with a Google OIDC provider
|
||||||
|
isGoogleProvider := strings.Contains(t.issuerURL, "google") ||
|
||||||
|
strings.Contains(t.issuerURL, "accounts.google.com")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Provider-Specific Auth URL Building**: Modified the `buildAuthURL` function to handle Google and non-Google providers differently:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Handle offline access differently for Google vs other providers
|
||||||
|
if isGoogleProvider {
|
||||||
|
// For Google, use access_type=offline parameter instead of offline_access scope
|
||||||
|
params.Set("access_type", "offline")
|
||||||
|
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens")
|
||||||
|
|
||||||
|
// Add prompt=consent for Google to ensure refresh token is issued
|
||||||
|
params.Set("prompt", "consent")
|
||||||
|
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
|
||||||
|
} else {
|
||||||
|
// For non-Google providers, use the offline_access scope
|
||||||
|
hasOfflineAccess := false
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == "offline_access" {
|
||||||
|
hasOfflineAccess = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasOfflineAccess {
|
||||||
|
scopes = append(scopes, "offline_access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Token Refresh Enhancement**: Improved the token refresh logic to better handle Google's behavior, particularly when refresh tokens aren't returned in refresh responses (as Google often uses the same refresh token for multiple requests).
|
||||||
|
|
||||||
|
## Why This Approach Works
|
||||||
|
|
||||||
|
This solution aligns with Google's OAuth 2.0 documentation which specifies:
|
||||||
|
|
||||||
|
1. **Access Type Parameter**: Google's [OAuth 2.0 documentation](https://developers.google.com/identity/protocols/oauth2/web-server#offline) states that to request a refresh token, applications must include `access_type=offline` in the authorization request.
|
||||||
|
|
||||||
|
2. **Prompt Parameter**: The [`prompt=consent`](https://developers.google.com/identity/protocols/oauth2/web-server#forceapprovalprompt) parameter forces the consent screen to appear, ensuring a refresh token is issued even if the user has previously granted access.
|
||||||
|
|
||||||
|
3. **Scope Validation**: Google strictly validates scopes and rejects non-standard ones like `offline_access`, instead relying on the `access_type` parameter to indicate whether a refresh token should be issued.
|
||||||
|
|
||||||
|
By adapting to these Google-specific requirements, the OIDC plugin can now seamlessly work with both standard OIDC providers and Google's OAuth implementation.
|
||||||
|
|
||||||
|
## Testing and Verification
|
||||||
|
|
||||||
|
Comprehensive tests were implemented to verify the solution:
|
||||||
|
|
||||||
|
1. **Provider Detection Test**: Ensures the code correctly identifies Google providers and applies the appropriate parameters.
|
||||||
|
|
||||||
|
2. **Auth URL Parameter Tests**: Verifies that:
|
||||||
|
- For Google providers: `access_type=offline` and `prompt=consent` are included; `offline_access` scope is NOT included
|
||||||
|
- For non-Google providers: `offline_access` scope IS included; `access_type` parameter is NOT added
|
||||||
|
|
||||||
|
3. **Token Refresh Tests**: Validates that Google's token refresh process works correctly, including the preservation of refresh tokens when Google doesn't return a new one.
|
||||||
|
|
||||||
|
4. **Integration Test**: Tests the complete authentication flow with a mocked Google provider to ensure all components work together seamlessly.
|
||||||
|
|
||||||
|
Sample test case (simplified):
|
||||||
|
|
||||||
|
```go
|
||||||
|
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
|
||||||
|
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
|
||||||
|
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
||||||
|
|
||||||
|
// Check that access_type=offline was added (not offline_access scope for Google)
|
||||||
|
if !strings.Contains(authURL, "access_type=offline") {
|
||||||
|
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify offline_access scope is NOT included for Google providers
|
||||||
|
if strings.Contains(authURL, "offline_access") {
|
||||||
|
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that prompt=consent was added
|
||||||
|
if !strings.Contains(authURL, "prompt=consent") {
|
||||||
|
t.Errorf("prompt=consent not added to Google auth URL: %s", authURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Guidance for Developers
|
||||||
|
|
||||||
|
When configuring the Traefik OIDC middleware for Google:
|
||||||
|
|
||||||
|
1. **Provider URL**: Use `https://accounts.google.com` as the `providerURL` value
|
||||||
|
|
||||||
|
2. **Client Configuration**: Create OAuth 2.0 credentials in the Google Cloud Console:
|
||||||
|
- Configure the authorized redirect URI to match your `callbackURL` setting
|
||||||
|
- Ensure your OAuth consent screen is properly configured (especially if you want long-lived refresh tokens)
|
||||||
|
|
||||||
|
3. **Configuration Example**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: oidc-google
|
||||||
|
namespace: traefik
|
||||||
|
spec:
|
||||||
|
plugin:
|
||||||
|
traefikoidc:
|
||||||
|
providerURL: https://accounts.google.com
|
||||||
|
clientID: your-google-client-id.apps.googleusercontent.com
|
||||||
|
clientSecret: your-google-client-secret
|
||||||
|
sessionEncryptionKey: your-secure-encryption-key-min-32-chars
|
||||||
|
callbackURL: /oauth2/callback
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
# Note: DO NOT manually add offline_access scope for Google
|
||||||
|
# The middleware handles this automatically and correctly
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Troubleshooting**: If sessions still expire prematurely with Google (typically after 1 hour):
|
||||||
|
- Ensure your Google Cloud OAuth consent screen is set to "External" and "Production" mode (not "Testing" mode, which limits refresh token validity)
|
||||||
|
- Review your application logs with `logLevel: debug` to check for refresh token errors
|
||||||
|
- Verify you're using a version of the middleware that includes this fix
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This fix ensures that the Traefik OIDC plugin works seamlessly with Google's OAuth implementation without requiring users to make provider-specific configuration changes. The middleware now intelligently adapts to the provider's requirements, making it more robust and user-friendly while maintaining compatibility with the standard OIDC specification for other providers.
|
||||||
+456
-4
@@ -1,11 +1,19 @@
|
|||||||
package traefikoidc
|
package traefikoidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockTokenVerifier implements the TokenVerifier interface for testing
|
// MockTokenVerifier implements the TokenVerifier interface for testing
|
||||||
@@ -13,6 +21,18 @@ type MockTokenVerifier struct {
|
|||||||
VerifyFunc func(token string) error
|
VerifyFunc func(token string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MockJWTVerifier implements the JWTVerifier interface for testing
|
||||||
|
type MockJWTVerifier struct {
|
||||||
|
VerifyJWTFunc func(jwt *JWT, token string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockJWTVerifier) VerifyJWTSignatureAndClaims(jwt *JWT, token string) error {
|
||||||
|
if m.VerifyJWTFunc != nil {
|
||||||
|
return m.VerifyJWTFunc(jwt, token)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockTokenVerifier) VerifyToken(token string) error {
|
func (m *MockTokenVerifier) VerifyToken(token string) error {
|
||||||
if m.VerifyFunc != nil {
|
if m.VerifyFunc != nil {
|
||||||
return m.VerifyFunc(token)
|
return m.VerifyFunc(token)
|
||||||
@@ -39,12 +59,17 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
|
|||||||
tOidc.sessionManager = sessionManager
|
tOidc.sessionManager = sessionManager
|
||||||
|
|
||||||
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
|
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
|
||||||
// Test buildAuthURL to ensure it adds offline_access and prompt=consent for Google
|
// Test buildAuthURL to ensure it adds access_type=offline and prompt=consent for Google
|
||||||
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
||||||
|
|
||||||
// Check that offline_access scope was added
|
// Check that access_type=offline was added (not offline_access scope for Google)
|
||||||
if !strings.Contains(authURL, "scope=") || !strings.Contains(authURL, "offline_access") {
|
if !strings.Contains(authURL, "access_type=offline") {
|
||||||
t.Errorf("offline_access scope not added to Google auth URL: %s", authURL)
|
t.Errorf("access_type=offline not added to Google auth URL: %s", authURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify offline_access scope is NOT included for Google providers
|
||||||
|
if strings.Contains(authURL, "offline_access") {
|
||||||
|
t.Errorf("offline_access scope incorrectly added to Google auth URL: %s", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that prompt=consent was added
|
// Check that prompt=consent was added
|
||||||
@@ -142,6 +167,433 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
|
|||||||
session.GetAccessToken())
|
session.GetAccessToken())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Test that our fix specifically addresses the reported Google error
|
||||||
|
t.Run("Google provider handles offline access correctly", func(t *testing.T) {
|
||||||
|
// Build the auth URL with Google provider detection
|
||||||
|
authURL := tOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
||||||
|
|
||||||
|
// Parse the URL to examine its parameters
|
||||||
|
parsedURL, err := url.Parse(authURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := parsedURL.Query()
|
||||||
|
|
||||||
|
// Verify that access_type=offline is set (Google's way of requesting refresh tokens)
|
||||||
|
if params.Get("access_type") != "offline" {
|
||||||
|
t.Errorf("access_type=offline not set in Google auth URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the scope parameter doesn't contain offline_access
|
||||||
|
// (which Google reports as invalid: {invalid=[offline_access]})
|
||||||
|
scope := params.Get("scope")
|
||||||
|
if strings.Contains(scope, "offline_access") {
|
||||||
|
t.Errorf("offline_access incorrectly included in scope for Google provider: %s", scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the necessary scopes are still included
|
||||||
|
for _, requiredScope := range []string{"openid", "profile", "email"} {
|
||||||
|
if !strings.Contains(scope, requiredScope) {
|
||||||
|
t.Errorf("Required scope '%s' missing from auth URL", requiredScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enhanced test for verifying non-Google provider includes offline_access scope
|
||||||
|
t.Run("Non-Google provider includes offline_access scope", func(t *testing.T) {
|
||||||
|
// Create a test instance with a non-Google issuer URL
|
||||||
|
nonGoogleOidc := &TraefikOidc{
|
||||||
|
issuerURL: "https://auth.example.com",
|
||||||
|
clientID: "test-client-id",
|
||||||
|
clientSecret: "test-client-secret",
|
||||||
|
logger: mockLogger,
|
||||||
|
scopes: []string{"openid", "profile", "email"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test buildAuthURL for a non-Google provider
|
||||||
|
authURL := nonGoogleOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
||||||
|
|
||||||
|
// Parse the URL to examine its parameters
|
||||||
|
parsedURL, err := url.Parse(authURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := parsedURL.Query()
|
||||||
|
|
||||||
|
// Verify that access_type=offline is NOT set for non-Google providers
|
||||||
|
if params.Get("access_type") == "offline" {
|
||||||
|
t.Errorf("access_type=offline incorrectly added to non-Google auth URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that offline_access scope IS included for non-Google providers
|
||||||
|
scope := params.Get("scope")
|
||||||
|
if !strings.Contains(scope, "offline_access") {
|
||||||
|
t.Errorf("offline_access scope missing from non-Google auth URL scope: %s", scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the necessary scopes are still included
|
||||||
|
for _, requiredScope := range []string{"openid", "profile", "email"} {
|
||||||
|
if !strings.Contains(scope, requiredScope) {
|
||||||
|
t.Errorf("Required scope '%s' missing from non-Google auth URL", requiredScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Additional test for complete URL construction for Google provider
|
||||||
|
t.Run("Complete Google auth URL construction", func(t *testing.T) {
|
||||||
|
// Build the auth URL with additional parameters
|
||||||
|
redirectURL := "https://example.com/callback"
|
||||||
|
state := "state123"
|
||||||
|
nonce := "nonce123"
|
||||||
|
codeChallenge := "code_challenge_value" // For PKCE
|
||||||
|
|
||||||
|
// Enable PKCE for this test
|
||||||
|
tOidc.enablePKCE = true
|
||||||
|
|
||||||
|
// Build auth URL
|
||||||
|
authURL := tOidc.buildAuthURL(redirectURL, state, nonce, codeChallenge)
|
||||||
|
|
||||||
|
// Parse the URL to examine its structure and parameters
|
||||||
|
parsedURL, err := url.Parse(authURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse auth URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the base URL
|
||||||
|
expectedBaseURL := "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
if !strings.HasPrefix(authURL, expectedBaseURL) && !strings.Contains(authURL, "accounts.google.com") {
|
||||||
|
t.Errorf("Auth URL doesn't start with expected Google OAuth endpoint: %s", authURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all required parameters
|
||||||
|
params := parsedURL.Query()
|
||||||
|
expectedParams := map[string]string{
|
||||||
|
"client_id": "test-client-id",
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": redirectURL,
|
||||||
|
"state": state,
|
||||||
|
"nonce": nonce,
|
||||||
|
"access_type": "offline",
|
||||||
|
"prompt": "consent",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check PKCE parameters if enabled
|
||||||
|
if tOidc.enablePKCE {
|
||||||
|
expectedParams["code_challenge"] = codeChallenge
|
||||||
|
expectedParams["code_challenge_method"] = "S256"
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, expectedValue := range expectedParams {
|
||||||
|
if value := params.Get(key); value != expectedValue {
|
||||||
|
t.Errorf("Parameter %s has incorrect value. Expected: %s, Got: %s",
|
||||||
|
key, expectedValue, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify scope parameter separately due to it being space-separated values
|
||||||
|
scope := params.Get("scope")
|
||||||
|
if scope == "" {
|
||||||
|
t.Error("Scope parameter missing from Google auth URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all required scopes are present
|
||||||
|
scopeList := strings.Split(scope, " ")
|
||||||
|
expectedScopes := []string{"openid", "profile", "email"}
|
||||||
|
for _, expectedScope := range expectedScopes {
|
||||||
|
found := false
|
||||||
|
for _, actualScope := range scopeList {
|
||||||
|
if actualScope == expectedScope {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected scope '%s' not found in scope parameter: %s", expectedScope, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify offline_access is NOT in the scope list
|
||||||
|
for _, actualScope := range scopeList {
|
||||||
|
if actualScope == "offline_access" {
|
||||||
|
t.Errorf("offline_access scope incorrectly included in Google auth URL: %s", scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Integration test with mocked Google provider
|
||||||
|
t.Run("Integration test with mocked Google provider", func(t *testing.T) {
|
||||||
|
// Generate an RSA key for signing the test JWTs
|
||||||
|
rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWK for the RSA public key
|
||||||
|
jwk := JWK{
|
||||||
|
Kty: "RSA",
|
||||||
|
Kid: "test-key-id",
|
||||||
|
Alg: "RS256",
|
||||||
|
N: base64.RawURLEncoding.EncodeToString(rsaPrivateKey.PublicKey.N.Bytes()),
|
||||||
|
E: base64.RawURLEncoding.EncodeToString(bigIntToBytes(big.NewInt(int64(rsaPrivateKey.PublicKey.E)))),
|
||||||
|
}
|
||||||
|
jwks := &JWKSet{
|
||||||
|
Keys: []JWK{jwk},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock JWK cache
|
||||||
|
mockJWKCache := &MockJWKCache{
|
||||||
|
JWKS: jwks,
|
||||||
|
Err: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a complete test instance with all required fields
|
||||||
|
mockLogger := NewLogger("debug")
|
||||||
|
googleTOidc := &TraefikOidc{
|
||||||
|
issuerURL: "https://accounts.google.com",
|
||||||
|
clientID: "test-client-id",
|
||||||
|
clientSecret: "test-client-secret",
|
||||||
|
logger: mockLogger,
|
||||||
|
scopes: []string{"openid", "profile", "email"},
|
||||||
|
refreshGracePeriod: 60,
|
||||||
|
tokenCache: NewTokenCache(), // Initialize tokenCache
|
||||||
|
tokenBlacklist: NewCache(), // Initialize tokenBlacklist
|
||||||
|
enablePKCE: false,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 0), // No rate limiting for tests
|
||||||
|
jwkCache: mockJWKCache,
|
||||||
|
jwksURL: "https://accounts.google.com/jwks",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a session manager
|
||||||
|
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger)
|
||||||
|
googleTOidc.sessionManager = sessionManager
|
||||||
|
|
||||||
|
// Create a mock token verifier
|
||||||
|
mockTokenVerifier := &MockTokenVerifier{
|
||||||
|
VerifyFunc: func(token string) error {
|
||||||
|
return nil // Always verify successfully for this test
|
||||||
|
},
|
||||||
|
}
|
||||||
|
googleTOidc.tokenVerifier = mockTokenVerifier
|
||||||
|
|
||||||
|
// Create JWT tokens for the test
|
||||||
|
now := time.Now()
|
||||||
|
exp := now.Add(1 * time.Hour).Unix()
|
||||||
|
iat := now.Unix()
|
||||||
|
nbf := now.Unix()
|
||||||
|
|
||||||
|
// Create initial ID token
|
||||||
|
initialIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://accounts.google.com",
|
||||||
|
"aud": "test-client-id",
|
||||||
|
"exp": exp,
|
||||||
|
"iat": iat,
|
||||||
|
"nbf": nbf,
|
||||||
|
"sub": "test-subject",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"nonce": "nonce123", // For initial authentication verification
|
||||||
|
"jti": generateRandomString(16),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create refresh ID token
|
||||||
|
refreshedIDToken, err := createTestJWT(rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
|
||||||
|
"iss": "https://accounts.google.com",
|
||||||
|
"aud": "test-client-id",
|
||||||
|
"exp": exp,
|
||||||
|
"iat": iat,
|
||||||
|
"nbf": nbf,
|
||||||
|
"sub": "test-subject",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"jti": generateRandomString(16),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create refreshed test ID token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up token verifier with mock
|
||||||
|
googleTOidc.tokenVerifier = &MockTokenVerifier{
|
||||||
|
VerifyFunc: func(token string) error {
|
||||||
|
return nil // Always verify successfully for this test
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up JWT verifier with mock
|
||||||
|
googleTOidc.jwtVerifier = &MockJWTVerifier{
|
||||||
|
VerifyJWTFunc: func(jwt *JWT, token string) error {
|
||||||
|
return nil // Always verify successfully for this test
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock token exchanger that simulates Google's OAuth behavior
|
||||||
|
mockTokenExchanger := &MockTokenExchanger{
|
||||||
|
ExchangeCodeFunc: func(ctx context.Context, grantType, codeOrToken, redirectURL, codeVerifier string) (*TokenResponse, error) {
|
||||||
|
// Verify the correct parameters are passed
|
||||||
|
if grantType != "authorization_code" {
|
||||||
|
t.Errorf("Expected grant_type=authorization_code, got %s", grantType)
|
||||||
|
}
|
||||||
|
if codeOrToken != "test_auth_code" {
|
||||||
|
t.Errorf("Expected code=test_auth_code, got %s", codeOrToken)
|
||||||
|
}
|
||||||
|
if redirectURL != "https://example.com/callback" {
|
||||||
|
t.Errorf("Expected redirect_uri=https://example.com/callback, got %s", redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a successful token response with a proper JWT
|
||||||
|
return &TokenResponse{
|
||||||
|
IDToken: initialIDToken,
|
||||||
|
AccessToken: "google_access_token",
|
||||||
|
RefreshToken: "google_refresh_token",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
RefreshTokenFunc: func(refreshToken string) (*TokenResponse, error) {
|
||||||
|
// Verify the correct refresh token is passed
|
||||||
|
if refreshToken != "google_refresh_token" {
|
||||||
|
t.Errorf("Expected refresh_token=google_refresh_token, got %s", refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a successful refresh response with a proper JWT
|
||||||
|
return &TokenResponse{
|
||||||
|
IDToken: refreshedIDToken,
|
||||||
|
AccessToken: "new_google_access_token",
|
||||||
|
RefreshToken: "", // Google doesn't always return a new refresh token
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
googleTOidc.tokenExchanger = mockTokenExchanger
|
||||||
|
|
||||||
|
// Use the real extractClaimsFunc to parse the proper JWT tokens
|
||||||
|
googleTOidc.extractClaimsFunc = extractClaims
|
||||||
|
|
||||||
|
// 1. Test building the authorization URL
|
||||||
|
authURL := googleTOidc.buildAuthURL("https://example.com/callback", "state123", "nonce123", "")
|
||||||
|
|
||||||
|
// Verify Google-specific parameters
|
||||||
|
if !strings.Contains(authURL, "access_type=offline") {
|
||||||
|
t.Errorf("Google auth URL missing access_type=offline: %s", authURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(authURL, "prompt=consent") {
|
||||||
|
t.Errorf("Google auth URL missing prompt=consent: %s", authURL)
|
||||||
|
}
|
||||||
|
if strings.Contains(authURL, "offline_access") {
|
||||||
|
t.Errorf("Google auth URL incorrectly includes offline_access scope: %s", authURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test handling the callback and token exchange
|
||||||
|
// Create a request and response recorder for the callback
|
||||||
|
req := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create a session and set the necessary values
|
||||||
|
session, _ := googleTOidc.sessionManager.GetSession(req)
|
||||||
|
session.SetCSRF("state123") // Must match the state parameter
|
||||||
|
session.SetNonce("nonce123")
|
||||||
|
|
||||||
|
// Save the session to the request
|
||||||
|
if err := session.Save(req, rw); err != nil {
|
||||||
|
t.Fatalf("Failed to save session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cookies from the response and add them to a new request
|
||||||
|
cookies := rw.Result().Cookies()
|
||||||
|
callbackReq := httptest.NewRequest("GET", "/callback?code=test_auth_code&state=state123", nil)
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
callbackReq.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
callbackRw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Handle the callback
|
||||||
|
googleTOidc.handleCallback(callbackRw, callbackReq, "https://example.com/callback")
|
||||||
|
|
||||||
|
// Verify the response is a redirect (302 Found)
|
||||||
|
if callbackRw.Code != 302 {
|
||||||
|
t.Errorf("Expected 302 redirect, got %d", callbackRw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new request to get the updated session
|
||||||
|
newReq := httptest.NewRequest("GET", "/", nil)
|
||||||
|
for _, cookie := range callbackRw.Result().Cookies() {
|
||||||
|
newReq.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the updated session
|
||||||
|
newSession, err := googleTOidc.sessionManager.GetSession(newReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get session after callback: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the session contains the expected values
|
||||||
|
if !newSession.GetAuthenticated() {
|
||||||
|
t.Error("Session not marked as authenticated after callback")
|
||||||
|
}
|
||||||
|
if newSession.GetEmail() != "user@example.com" {
|
||||||
|
t.Errorf("Session email incorrect: got %s, expected user@example.com",
|
||||||
|
newSession.GetEmail())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for non-empty access token that can be parsed as JWT
|
||||||
|
accessToken := newSession.GetAccessToken()
|
||||||
|
if accessToken == "" {
|
||||||
|
t.Error("Session access token is empty")
|
||||||
|
} else {
|
||||||
|
claims, err := extractClaims(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to parse access token as JWT: %v", err)
|
||||||
|
} else if email, ok := claims["email"].(string); !ok || email != "user@example.com" {
|
||||||
|
t.Errorf("Access token JWT doesn't contain expected email claim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check refresh token
|
||||||
|
if newSession.GetRefreshToken() != "google_refresh_token" {
|
||||||
|
t.Errorf("Session refresh token incorrect: got %s, expected google_refresh_token",
|
||||||
|
newSession.GetRefreshToken())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Test token refresh
|
||||||
|
refreshReq := httptest.NewRequest("GET", "/", nil)
|
||||||
|
for _, cookie := range callbackRw.Result().Cookies() {
|
||||||
|
refreshReq.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
refreshRw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Get the session for refresh
|
||||||
|
refreshSession, _ := googleTOidc.sessionManager.GetSession(refreshReq)
|
||||||
|
|
||||||
|
// Refresh the token
|
||||||
|
refreshed := googleTOidc.refreshToken(refreshRw, refreshReq, refreshSession)
|
||||||
|
|
||||||
|
// Verify refresh was successful
|
||||||
|
if !refreshed {
|
||||||
|
t.Error("Token refresh failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the session data after refresh
|
||||||
|
// Check for non-empty refreshed access token that can be parsed as JWT
|
||||||
|
refreshedAccessToken := refreshSession.GetAccessToken()
|
||||||
|
if refreshedAccessToken == "" {
|
||||||
|
t.Error("Session access token is empty after refresh")
|
||||||
|
} else {
|
||||||
|
claims, err := extractClaims(refreshedAccessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to parse refreshed access token as JWT: %v", err)
|
||||||
|
} else if email, ok := claims["email"].(string); !ok || email != "user@example.com" {
|
||||||
|
t.Errorf("Refreshed access token JWT doesn't contain expected email claim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since Google didn't return a new refresh token, the original should be preserved
|
||||||
|
if refreshSession.GetRefreshToken() != "google_refresh_token" {
|
||||||
|
t.Errorf("Original refresh token not preserved: got %s, expected google_refresh_token",
|
||||||
|
refreshSession.GetRefreshToken())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to redefine MockTokenExchanger - it's already defined in main_test.go
|
// No need to redefine MockTokenExchanger - it's already defined in main_test.go
|
||||||
|
|||||||
@@ -1340,29 +1340,34 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
|
|||||||
// Check if we're dealing with a Google OIDC provider
|
// Check if we're dealing with a Google OIDC provider
|
||||||
isGoogleProvider := strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com")
|
isGoogleProvider := strings.Contains(t.issuerURL, "google") || strings.Contains(t.issuerURL, "accounts.google.com")
|
||||||
|
|
||||||
// Add offline_access scope if it's missing
|
// Handle offline access differently for Google vs other providers
|
||||||
hasOfflineAccess := false
|
if isGoogleProvider {
|
||||||
for _, scope := range scopes {
|
// For Google, use access_type=offline parameter instead of offline_access scope
|
||||||
if scope == "offline_access" {
|
params.Set("access_type", "offline")
|
||||||
hasOfflineAccess = true
|
t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens")
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasOfflineAccess {
|
// Add prompt=consent for Google to ensure refresh token is issued
|
||||||
scopes = append(scopes, "offline_access")
|
params.Set("prompt", "consent")
|
||||||
|
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
|
||||||
|
} else {
|
||||||
|
// For non-Google providers, use the offline_access scope
|
||||||
|
hasOfflineAccess := false
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == "offline_access" {
|
||||||
|
hasOfflineAccess = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasOfflineAccess {
|
||||||
|
scopes = append(scopes, "offline_access")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(scopes) > 0 {
|
if len(scopes) > 0 {
|
||||||
params.Set("scope", strings.Join(scopes, " "))
|
params.Set("scope", strings.Join(scopes, " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add prompt=consent for Google to ensure refresh token is issued
|
|
||||||
if isGoogleProvider {
|
|
||||||
params.Set("prompt", "consent")
|
|
||||||
t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use buildURLWithParams which handles potential relative authURL from metadata
|
// Use buildURLWithParams which handles potential relative authURL from metadata
|
||||||
return t.buildURLWithParams(t.authURL, params)
|
return t.buildURLWithParams(t.authURL, params)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user