mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-06 22:49:43 +00:00
9cbca4c4fb
patch-release
The refresh path in token_manager.go hardcoded the "email" claim when
extracting the user identifier from a refreshed ID token, ignoring the
configured userIdentifierClaim. Keycloak users without an email claim
(using sub or another identifier) were kicked out on refresh even
though their initial login worked.
The callback path (auth_flow.go:226-239) already honored
userIdentifierClaim with "sub" fallback; PR #100 (commit a316a98)
added that support but missed the refresh path.
Mirror the callback logic in refreshToken so both paths behave the same.
Cleanup: rename Get/SetEmail to Get/SetUserIdentifier on SessionData
to match the actual semantics. The slot already stored the configured
identifier (email, sub, oid, upn, preferred_username), only the API
name was misleading. Storage key "email" → "user_identifier" and
combinedSessionPayload field E (json:"e") → Ui (json:"ui").
Compat note: existing user sessions invalidate on upgrade — every active
user re-authenticates once after deploying this change.
136 lines
3.9 KiB
Go
136 lines
3.9 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestIssue132_RefreshTokenHonorsUserIdentifierClaim reproduces and verifies
|
|
// the fix for issue #132: token refresh path hardcoded the "email" claim and
|
|
// ignored the configured userIdentifierClaim. Keycloak users without an email
|
|
// claim (using sub or another identifier) were being kicked out on refresh
|
|
// even though their initial login worked.
|
|
//
|
|
// The callback path (auth_flow.go) already honored userIdentifierClaim with
|
|
// "sub" fallback. The refresh path (token_manager.go) had drifted out of sync
|
|
// after PR #100 (commit a316a98).
|
|
func TestIssue132_RefreshTokenHonorsUserIdentifierClaim(t *testing.T) {
|
|
tests := []struct {
|
|
claims map[string]any
|
|
name string
|
|
userIdentifierClaim string
|
|
expectedIdentifier string
|
|
expectSuccess bool
|
|
}{
|
|
{
|
|
name: "sub claim configured, only sub present (Keycloak no-email case)",
|
|
userIdentifierClaim: "sub",
|
|
claims: map[string]any{
|
|
"sub": "user-uuid-keycloak-12345",
|
|
"exp": float64(9999999999),
|
|
},
|
|
expectSuccess: true,
|
|
expectedIdentifier: "user-uuid-keycloak-12345",
|
|
},
|
|
{
|
|
name: "preferred_username configured, claim present",
|
|
userIdentifierClaim: "preferred_username",
|
|
claims: map[string]any{
|
|
"sub": "user-uuid-12345",
|
|
"preferred_username": "alice",
|
|
"exp": float64(9999999999),
|
|
},
|
|
expectSuccess: true,
|
|
expectedIdentifier: "alice",
|
|
},
|
|
{
|
|
name: "configured claim missing, falls back to sub",
|
|
userIdentifierClaim: "preferred_username",
|
|
claims: map[string]any{
|
|
"sub": "fallback-sub-id",
|
|
"exp": float64(9999999999),
|
|
},
|
|
expectSuccess: true,
|
|
expectedIdentifier: "fallback-sub-id",
|
|
},
|
|
{
|
|
name: "email default, email present (backward compatibility)",
|
|
userIdentifierClaim: "email",
|
|
claims: map[string]any{
|
|
"sub": "user-uuid-12345",
|
|
"email": "user@example.com",
|
|
"exp": float64(9999999999),
|
|
},
|
|
expectSuccess: true,
|
|
expectedIdentifier: "user@example.com",
|
|
},
|
|
{
|
|
name: "email default, no email and no sub - refresh fails",
|
|
userIdentifierClaim: "email",
|
|
claims: map[string]any{
|
|
"exp": float64(9999999999),
|
|
},
|
|
expectSuccess: false,
|
|
expectedIdentifier: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sessionManager, err := NewSessionManager(
|
|
"test-encryption-key-32-bytes-long!!",
|
|
false,
|
|
"",
|
|
"",
|
|
0,
|
|
NewLogger("error"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("session manager: %v", err)
|
|
}
|
|
defer sessionManager.Shutdown()
|
|
|
|
capturedClaims := tt.claims
|
|
tOidc := &TraefikOidc{
|
|
logger: NewLogger("error"),
|
|
userIdentifierClaim: tt.userIdentifierClaim,
|
|
sessionManager: sessionManager,
|
|
tokenExchanger: &EnhancedMockTokenExchanger{
|
|
RefreshResponse: &TokenResponse{
|
|
AccessToken: "new-access-token",
|
|
RefreshToken: "new-refresh-token",
|
|
IDToken: "new-id-token-jwt",
|
|
ExpiresIn: 3600,
|
|
},
|
|
},
|
|
tokenVerifier: &EnhancedMockTokenVerifier{Err: nil},
|
|
extractClaimsFunc: func(token string) (map[string]any, error) {
|
|
return capturedClaims, nil
|
|
},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
rw := httptest.NewRecorder()
|
|
|
|
session, err := sessionManager.GetSession(req)
|
|
if err != nil {
|
|
t.Fatalf("get session: %v", err)
|
|
}
|
|
defer session.returnToPoolSafely()
|
|
|
|
session.SetRefreshToken("initial-refresh-token")
|
|
|
|
refreshed := tOidc.refreshToken(rw, req, session)
|
|
|
|
if refreshed != tt.expectSuccess {
|
|
t.Fatalf("refreshToken() = %v, want %v", refreshed, tt.expectSuccess)
|
|
}
|
|
|
|
if got := session.GetUserIdentifier(); got != tt.expectedIdentifier {
|
|
t.Errorf("session.GetUserIdentifier() = %q, want %q", got, tt.expectedIdentifier)
|
|
}
|
|
})
|
|
}
|
|
}
|