mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-19 04:47:48 +00:00
Add support for PKCE (#31)
* Add PKCE support. * Add option to toggle PKCE checks feature. * GoFMT
This commit is contained in:
+215
-31
@@ -118,7 +118,7 @@ func (ts *TestSuite) Setup() {
|
||||
}
|
||||
|
||||
// Helper functions used by TraefikOidc
|
||||
func (ts *TestSuite) exchangeCodeForTokenFunc(code string, redirectURL string) (*TokenResponse, error) {
|
||||
func (ts *TestSuite) exchangeCodeForTokenFunc(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
RefreshToken: "test-refresh-token",
|
||||
@@ -489,7 +489,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryParams string
|
||||
exchangeCodeForToken func(code string, redirectURL string) (*TokenResponse, error)
|
||||
exchangeCodeForToken func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error)
|
||||
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
||||
sessionSetupFunc func(*SessionData)
|
||||
expectedStatus int
|
||||
@@ -497,7 +497,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
{
|
||||
name: "Success",
|
||||
queryParams: "?code=test-code&state=test-csrf-token",
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
RefreshToken: "test-refresh-token",
|
||||
@@ -527,7 +527,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
{
|
||||
name: "Exchange Code Error",
|
||||
queryParams: "?code=test-code&state=test-csrf-token",
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return nil, fmt.Errorf("exchange code error")
|
||||
},
|
||||
sessionSetupFunc: func(session *SessionData) {
|
||||
@@ -539,7 +539,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
{
|
||||
name: "Missing ID Token",
|
||||
queryParams: "?code=test-code&state=test-csrf-token",
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return &TokenResponse{}, nil
|
||||
},
|
||||
sessionSetupFunc: func(session *SessionData) {
|
||||
@@ -551,7 +551,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
{
|
||||
name: "Disallowed Email",
|
||||
queryParams: "?code=test-code&state=test-csrf-token",
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
RefreshToken: "test-refresh-token",
|
||||
@@ -572,7 +572,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
{
|
||||
name: "Invalid State Parameter",
|
||||
queryParams: "?code=test-code&state=invalid-csrf-token",
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
RefreshToken: "test-refresh-token",
|
||||
@@ -593,7 +593,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
{
|
||||
name: "Nonce Mismatch",
|
||||
queryParams: "?code=test-code&state=test-csrf-token",
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
RefreshToken: "test-refresh-token",
|
||||
@@ -614,7 +614,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
{
|
||||
name: "Missing Nonce in Claims",
|
||||
queryParams: "?code=test-code&state=test-csrf-token",
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
RefreshToken: "test-refresh-token",
|
||||
@@ -730,7 +730,7 @@ func TestOIDCHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryParams string
|
||||
exchangeCodeForToken func(code string, redirectURL string) (*TokenResponse, error)
|
||||
exchangeCodeForToken func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error)
|
||||
extractClaimsFunc func(tokenString string) (map[string]interface{}, error)
|
||||
sessionSetupFunc func(session *sessions.Session)
|
||||
expectedStatus int
|
||||
@@ -746,7 +746,7 @@ func TestOIDCHandler(t *testing.T) {
|
||||
session.Values["csrf"] = "test-csrf-token"
|
||||
session.Values["nonce"] = "test-nonce"
|
||||
},
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
// Simulate token exchange
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
@@ -770,7 +770,7 @@ func TestOIDCHandler(t *testing.T) {
|
||||
session.Values["csrf"] = "test-csrf-token"
|
||||
session.Values["nonce"] = "test-nonce"
|
||||
},
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
// Simulate token exchange
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
@@ -793,7 +793,7 @@ func TestOIDCHandler(t *testing.T) {
|
||||
session.Values["csrf"] = "test-csrf-token"
|
||||
session.Values["nonce"] = "test-nonce"
|
||||
},
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
// Simulate token exchange
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
@@ -817,7 +817,7 @@ func TestOIDCHandler(t *testing.T) {
|
||||
session.Values["csrf"] = "test-csrf-token"
|
||||
session.Values["nonce"] = "test-nonce"
|
||||
},
|
||||
exchangeCodeForToken: func(code string, redirectURL string) (*TokenResponse, error) {
|
||||
exchangeCodeForToken: func(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||
// Simulate token exchange
|
||||
return &TokenResponse{
|
||||
IDToken: ts.token,
|
||||
@@ -1664,6 +1664,17 @@ func TestServeHTTPRolesAndGroups(t *testing.T) {
|
||||
}
|
||||
|
||||
// Helper function to compare string slices
|
||||
func stringSliceEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestExchangeTokensWithRedirects tests the token exchange process with redirects
|
||||
func TestExchangeTokensWithRedirects(t *testing.T) {
|
||||
@@ -1748,7 +1759,7 @@ func TestExchangeTokensWithRedirects(t *testing.T) {
|
||||
tOidc.tokenURL = server.URL
|
||||
|
||||
// Test token exchange
|
||||
response, err := tOidc.exchangeTokens(context.Background(), "authorization_code", "test-code", "http://callback")
|
||||
response, err := tOidc.exchangeTokens(context.Background(), "authorization_code", "test-code", "http://callback", "test-code-verifier")
|
||||
|
||||
if tc.expectError {
|
||||
if err == nil {
|
||||
@@ -1770,18 +1781,6 @@ func TestExchangeTokensWithRedirects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestBuildAuthURL tests the buildAuthURL function with various URL scenarios
|
||||
func TestBuildAuthURL(t *testing.T) {
|
||||
ts := &TestSuite{t: t}
|
||||
@@ -1794,7 +1793,10 @@ func TestBuildAuthURL(t *testing.T) {
|
||||
redirectURL string
|
||||
state string
|
||||
nonce string
|
||||
enablePKCE bool
|
||||
codeChallenge string
|
||||
expectedPrefix string
|
||||
checkPKCE bool
|
||||
}{
|
||||
{
|
||||
name: "Absolute Auth URL",
|
||||
@@ -1803,7 +1805,10 @@ func TestBuildAuthURL(t *testing.T) {
|
||||
redirectURL: "https://app.example.com/callback",
|
||||
state: "test-state",
|
||||
nonce: "test-nonce",
|
||||
enablePKCE: false,
|
||||
codeChallenge: "",
|
||||
expectedPrefix: "https://auth.example.com/oauth/authorize?",
|
||||
checkPKCE: false,
|
||||
},
|
||||
{
|
||||
name: "Relative Auth URL",
|
||||
@@ -1812,7 +1817,10 @@ func TestBuildAuthURL(t *testing.T) {
|
||||
redirectURL: "https://app.example.com/callback",
|
||||
state: "test-state",
|
||||
nonce: "test-nonce",
|
||||
enablePKCE: false,
|
||||
codeChallenge: "",
|
||||
expectedPrefix: "https://logto.example.com/oidc/auth?",
|
||||
checkPKCE: false,
|
||||
},
|
||||
{
|
||||
name: "Relative Auth URL with Different Issuer",
|
||||
@@ -1821,7 +1829,46 @@ func TestBuildAuthURL(t *testing.T) {
|
||||
redirectURL: "https://app.example.com/callback",
|
||||
state: "test-state",
|
||||
nonce: "test-nonce",
|
||||
enablePKCE: false,
|
||||
codeChallenge: "",
|
||||
expectedPrefix: "https://auth.example.com:8443/sign-in?",
|
||||
checkPKCE: false,
|
||||
},
|
||||
{
|
||||
name: "With PKCE Enabled",
|
||||
authURL: "https://auth.example.com/oauth/authorize",
|
||||
issuerURL: "https://auth.example.com",
|
||||
redirectURL: "https://app.example.com/callback",
|
||||
state: "test-state",
|
||||
nonce: "test-nonce",
|
||||
enablePKCE: true,
|
||||
codeChallenge: "test-code-challenge",
|
||||
expectedPrefix: "https://auth.example.com/oauth/authorize?",
|
||||
checkPKCE: true,
|
||||
},
|
||||
{
|
||||
name: "With PKCE Enabled but No Challenge",
|
||||
authURL: "https://auth.example.com/oauth/authorize",
|
||||
issuerURL: "https://auth.example.com",
|
||||
redirectURL: "https://app.example.com/callback",
|
||||
state: "test-state",
|
||||
nonce: "test-nonce",
|
||||
enablePKCE: true,
|
||||
codeChallenge: "",
|
||||
expectedPrefix: "https://auth.example.com/oauth/authorize?",
|
||||
checkPKCE: false,
|
||||
},
|
||||
{
|
||||
name: "With PKCE Disabled but Challenge Provided",
|
||||
authURL: "https://auth.example.com/oauth/authorize",
|
||||
issuerURL: "https://auth.example.com",
|
||||
redirectURL: "https://app.example.com/callback",
|
||||
state: "test-state",
|
||||
nonce: "test-nonce",
|
||||
enablePKCE: false,
|
||||
codeChallenge: "test-code-challenge",
|
||||
expectedPrefix: "https://auth.example.com/oauth/authorize?",
|
||||
checkPKCE: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1831,9 +1878,10 @@ func TestBuildAuthURL(t *testing.T) {
|
||||
tOidc := ts.tOidc
|
||||
tOidc.authURL = tc.authURL
|
||||
tOidc.issuerURL = tc.issuerURL
|
||||
tOidc.enablePKCE = tc.enablePKCE
|
||||
|
||||
// Call buildAuthURL
|
||||
result := tOidc.buildAuthURL(tc.redirectURL, tc.state, tc.nonce)
|
||||
// Call buildAuthURL with code challenge
|
||||
result := tOidc.buildAuthURL(tc.redirectURL, tc.state, tc.nonce, tc.codeChallenge)
|
||||
|
||||
// Verify the URL starts with the expected prefix
|
||||
if !strings.HasPrefix(result, tc.expectedPrefix) {
|
||||
@@ -1861,6 +1909,23 @@ func TestBuildAuthURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify PKCE parameters
|
||||
if tc.checkPKCE {
|
||||
if got := query.Get("code_challenge"); got != tc.codeChallenge {
|
||||
t.Errorf("Expected code_challenge=%q, got %q", tc.codeChallenge, got)
|
||||
}
|
||||
if got := query.Get("code_challenge_method"); got != "S256" {
|
||||
t.Errorf("Expected code_challenge_method=%q, got %q", "S256", got)
|
||||
}
|
||||
} else {
|
||||
if got := query.Get("code_challenge"); got != "" {
|
||||
t.Errorf("Expected no code_challenge, but got %q", got)
|
||||
}
|
||||
if got := query.Get("code_challenge_method"); got != "" {
|
||||
t.Errorf("Expected no code_challenge_method, but got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify scopes are present and correct
|
||||
if len(tOidc.scopes) > 0 {
|
||||
expectedScopes := strings.Join(tOidc.scopes, " ")
|
||||
@@ -1872,6 +1937,125 @@ func TestBuildAuthURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeCodeForToken tests the exchangeCodeForToken function with PKCE support
|
||||
func TestExchangeCodeForToken(t *testing.T) {
|
||||
ts := &TestSuite{t: t}
|
||||
ts.Setup()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
enablePKCE bool
|
||||
codeVerifier string
|
||||
setupMock func(t *testing.T) *httptest.Server
|
||||
}{
|
||||
{
|
||||
name: "With PKCE Enabled and Code Verifier",
|
||||
enablePKCE: true,
|
||||
codeVerifier: "test-code-verifier",
|
||||
setupMock: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify code_verifier is included
|
||||
if codeVerifier := r.Form.Get("code_verifier"); codeVerifier != "test-code-verifier" {
|
||||
t.Errorf("Expected code_verifier=test-code-verifier, got %s", codeVerifier)
|
||||
}
|
||||
|
||||
// Return successful token response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
IDToken: "test.id.token",
|
||||
AccessToken: "test-access-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: "test-refresh-token",
|
||||
})
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With PKCE Disabled but Code Verifier Provided",
|
||||
enablePKCE: false,
|
||||
codeVerifier: "test-code-verifier",
|
||||
setupMock: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify code_verifier is NOT included
|
||||
if codeVerifier := r.Form.Get("code_verifier"); codeVerifier != "" {
|
||||
t.Errorf("Expected no code_verifier, got %s", codeVerifier)
|
||||
}
|
||||
|
||||
// Return successful token response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
IDToken: "test.id.token",
|
||||
AccessToken: "test-access-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: "test-refresh-token",
|
||||
})
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With PKCE Enabled but No Code Verifier",
|
||||
enablePKCE: true,
|
||||
codeVerifier: "",
|
||||
setupMock: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("Failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify code_verifier is NOT included
|
||||
if codeVerifier := r.Form.Get("code_verifier"); codeVerifier != "" {
|
||||
t.Errorf("Expected no code_verifier, got %s", codeVerifier)
|
||||
}
|
||||
|
||||
// Return successful token response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(TokenResponse{
|
||||
IDToken: "test.id.token",
|
||||
AccessToken: "test-access-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
RefreshToken: "test-refresh-token",
|
||||
})
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := tc.setupMock(t)
|
||||
defer server.Close()
|
||||
|
||||
// Configure the test instance
|
||||
tOidc := ts.tOidc
|
||||
tOidc.tokenURL = server.URL
|
||||
tOidc.enablePKCE = tc.enablePKCE
|
||||
|
||||
// Test exchangeCodeForToken
|
||||
response, err := tOidc.exchangeCodeForToken("test-code", "http://callback", tc.codeVerifier)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if response == nil {
|
||||
t.Error("Expected token response but got nil")
|
||||
} else if response.IDToken != "test.id.token" {
|
||||
t.Errorf("Expected ID token %q, got %q", "test.id.token", response.IDToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultInitiateAuthentication_PreservesQueryParameters tests that defaultInitiateAuthentication preserves query parameters in the incoming path.
|
||||
func TestDefaultInitiateAuthentication_PreservesQueryParameters(t *testing.T) {
|
||||
ts := &TestSuite{t: t}
|
||||
@@ -1879,7 +2063,7 @@ func TestDefaultInitiateAuthentication_PreservesQueryParameters(t *testing.T) {
|
||||
|
||||
// Create a request with query parameters
|
||||
req := httptest.NewRequest("GET", "/protected/resource?param1=value1¶m2=value2", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
|
||||
// Get session
|
||||
session, err := ts.sessionManager.GetSession(req)
|
||||
@@ -1889,7 +2073,7 @@ func TestDefaultInitiateAuthentication_PreservesQueryParameters(t *testing.T) {
|
||||
|
||||
// Call defaultInitiateAuthentication
|
||||
redirectURL := "http://example.com/callback"
|
||||
ts.tOidc.defaultInitiateAuthentication(rw, req, session, redirectURL)
|
||||
ts.tOidc.defaultInitiateAuthentication(responseRecorder, req, session, redirectURL)
|
||||
|
||||
// Verify that the incoming path includes query parameters
|
||||
incomingPath := session.GetIncomingPath()
|
||||
|
||||
Reference in New Issue
Block a user