fix: reduce yaegi CPU footprint + require auth on SSE/WebSocket bypass

minor-release

Behaviour changes (potentially breaking for operators relying on the prior
unauthenticated SSE bypass):

* SSE (`Accept: text/event-stream`) and WebSocket upgrade requests now
  return 401 when no authenticated session is present. Previously the
  bypass forwarded unconditionally, which let any caller reach the
  backend by setting the right header. Excluded URLs are unchanged.
  Operators relying on unauthenticated SSE/WS access must move the path
  into ExcludedURLs.

Performance fixes (target: long-running dashboards like Grafana / ArgoCD
where many panels poll concurrently while the page stays open):

* Stop honouring isTestMode() for the singleton-token-cleanup interval
  under yaegi (the Traefik plugin runtime). In production the plugin was
  running a 20 Hz no-op cleanup ticker because runtime.Compiler ==
  "yaegi" tripped the test-mode branch.
* processAuthorizedRequest now resolves ID-token claims at most once per
  request via SessionData.GetIDTokenClaims (already cached on the
  session) and reuses them for both groups/roles extraction and
  header-template rendering. Previously every authenticated request
  parsed the JWT twice.
* Added extractGroupsAndRolesFromClaims to drive groups/roles off
  pre-parsed claims; extractGroupsAndRoles still works for tests.
* Removed the unconditional session.MarkDirty() in the header-templates
  branch. Templates only mutate request headers, not session state, so
  the prior MarkDirty was re-encrypting and rewriting all session
  cookies on every authenticated request that used header templates.

Other:

* Added isWebSocketUpgrade (RFC 6455 handshake detection — Connection:
  Upgrade + Upgrade: websocket, tolerant of multi-token Connection
  headers and case).
* Renamed applySSEUserHeaders -> applyBypassUserHeaders; it now returns
  bool so the dispatcher can reject unauthenticated SSE/WS with 401.
* Added tests for SSE and WS bypass covering both the auth-rejection
  path and the authenticated forward path.
This commit is contained in:
2026-05-02 03:12:20 +01:00
parent 1b6c8616fd
commit 684a990f59
3 changed files with 327 additions and 96 deletions
+161 -9
View File
@@ -79,24 +79,33 @@ func TestServeHTTP_ExcludedURLs(t *testing.T) {
} }
} }
// TestServeHTTP_EventStream tests the event-stream bypass functionality // TestServeHTTP_EventStream tests the event-stream (SSE) bypass: the
// handshake must skip the OIDC redirect dance (clients can't follow it
// mid-stream) but it must STILL require an authenticated session, otherwise
// any caller could reach the backend by setting Accept: text/event-stream.
func TestServeHTTP_EventStream(t *testing.T) { func TestServeHTTP_EventStream(t *testing.T) {
nextCalled := false sessionManager := createTestSessionManager(t)
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
newOidc := func(next http.Handler) *TraefikOidc {
oidc := &TraefikOidc{ oidc := &TraefikOidc{
next: next, next: next,
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: sessionManager,
firstRequestReceived: true, firstRequestReceived: true,
metadataRefreshStarted: true, metadataRefreshStarted: true,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
} }
close(oidc.initComplete) close(oidc.initComplete)
return oidc
}
t.Run("unauthenticated_request_is_rejected", func(t *testing.T) {
nextCalled := false
oidc := newOidc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/events", nil) req := httptest.NewRequest("GET", "/events", nil)
req.Header.Set("Accept", "text/event-stream") req.Header.Set("Accept", "text/event-stream")
@@ -104,9 +113,152 @@ func TestServeHTTP_EventStream(t *testing.T) {
oidc.ServeHTTP(rw, req) oidc.ServeHTTP(rw, req)
if !nextCalled { if rw.Code != http.StatusUnauthorized {
t.Error("expected event-stream request to bypass OIDC") t.Errorf("expected 401 for unauthenticated SSE request, got %d", rw.Code)
} }
if nextCalled {
t.Error("backend handler must NOT be called for unauthenticated SSE bypass")
}
})
t.Run("authenticated_request_bypasses_to_backend", func(t *testing.T) {
nextCalled := false
var forwardedUser string
oidc := newOidc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
forwardedUser = r.Header.Get("X-Forwarded-User")
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/events", nil)
req.Header.Set("Accept", "text/event-stream")
// Build an authenticated session and inject its cookies onto req.
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to create test session: %v", err)
}
session.SetEmail("user@example.com")
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("failed to mark session authenticated: %v", err)
}
setupRW := httptest.NewRecorder()
if err := session.Save(req, setupRW); err != nil {
t.Fatalf("failed to save session: %v", err)
}
for _, c := range setupRW.Result().Cookies() {
req.AddCookie(c)
}
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled {
t.Fatal("expected authenticated SSE request to be forwarded to backend")
}
if forwardedUser != "user@example.com" {
t.Errorf("expected X-Forwarded-User=user@example.com, got %q", forwardedUser)
}
})
}
// TestServeHTTP_WebSocketUpgrade mirrors the SSE behavior: WebSocket
// handshake bypasses the OIDC redirect (clients can't follow it) but the
// session must already be authenticated, otherwise the backend is exposed
// to any caller setting `Connection: Upgrade` + `Upgrade: websocket`.
func TestServeHTTP_WebSocketUpgrade(t *testing.T) {
sessionManager := createTestSessionManager(t)
newOidc := func(next http.Handler) *TraefikOidc {
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestReceived: true,
metadataRefreshStarted: true,
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
return oidc
}
t.Run("unauthenticated_upgrade_is_rejected", func(t *testing.T) {
nextCalled := false
oidc := newOidc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
}))
req := httptest.NewRequest("GET", "/ws", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for unauthenticated WS upgrade, got %d", rw.Code)
}
if nextCalled {
t.Error("backend handler must NOT be called for unauthenticated WS bypass")
}
})
t.Run("authenticated_upgrade_bypasses_to_backend", func(t *testing.T) {
nextCalled := false
var forwardedUser string
oidc := newOidc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
forwardedUser = r.Header.Get("X-Forwarded-User")
}))
req := httptest.NewRequest("GET", "/ws", nil)
// Mixed-case + multi-token Connection header to exercise parsing.
req.Header.Set("Connection", "keep-alive, Upgrade")
req.Header.Set("Upgrade", "WebSocket")
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to create test session: %v", err)
}
session.SetEmail("ws-user@example.com")
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("failed to mark session authenticated: %v", err)
}
setupRW := httptest.NewRecorder()
if err := session.Save(req, setupRW); err != nil {
t.Fatalf("failed to save session: %v", err)
}
for _, c := range setupRW.Result().Cookies() {
req.AddCookie(c)
}
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled {
t.Fatal("expected authenticated WS handshake to be forwarded to backend")
}
if forwardedUser != "ws-user@example.com" {
t.Errorf("expected X-Forwarded-User=ws-user@example.com, got %q", forwardedUser)
}
})
t.Run("plain_http_does_not_bypass", func(t *testing.T) {
// Sanity: requests without Upgrade headers must NOT hit the WS
// bypass branch (otherwise the new code path could short-circuit
// normal authentication).
oidc := newOidc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("backend must not be called for unauthenticated plain HTTP")
}))
req := httptest.NewRequest("GET", "/ws", nil)
req.Header.Set("Connection", "keep-alive")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code == http.StatusOK {
t.Errorf("expected redirect or 401 for plain HTTP without auth, got 200")
}
})
} }
// TestServeHTTP_InitializationTimeout tests initialization timeout handling // TestServeHTTP_InitializationTimeout tests initialization timeout handling
+122 -50
View File
@@ -14,21 +14,40 @@ import (
) )
// bypassReason describes why a request is being forwarded without OIDC auth. // bypassReason describes why a request is being forwarded without OIDC auth.
// It is only used for logging and to decide whether extra SSE-specific work // It is only used for logging and to decide whether extra side-effects
// (propagating the user header from an existing session) should run. // (propagating the user header from an existing session) should run.
const ( const (
bypassReasonExcluded = "excluded-url" bypassReasonExcluded = "excluded-url"
bypassReasonSSE = "sse" bypassReasonSSE = "sse"
bypassReasonWebSocket = "websocket"
) )
// isWebSocketUpgrade reports whether req is a WebSocket upgrade handshake
// (RFC 6455). The middleware can only see the handshake; once Traefik
// completes the upgrade it forwards frames directly, so we never re-process
// per-frame traffic. We bypass auth on the handshake the same way we do for
// SSE, because browser WebSocket clients cannot follow an OIDC redirect.
func isWebSocketUpgrade(req *http.Request) bool {
if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
return false
}
for _, token := range strings.Split(req.Header.Get("Connection"), ",") {
if strings.EqualFold(strings.TrimSpace(token), "upgrade") {
return true
}
}
return false
}
// shouldBypassAuth decides whether a request must skip OIDC authentication // shouldBypassAuth decides whether a request must skip OIDC authentication
// entirely. It returns (true, reason) when either the request path matches a // entirely. It returns (true, reason) when either the request path matches a
// configured excluded URL or the Accept header asks for a text/event-stream // configured excluded URL, the Accept header asks for a text/event-stream
// response (SSE). The reason lets ServeHTTP apply any side-effects that are // response (SSE), or the request is a WebSocket upgrade handshake. The
// unique to the bypass kind (e.g. propagating user headers for SSE). // reason lets ServeHTTP apply any side-effects that are unique to the bypass
// kind (e.g. propagating user headers).
// //
// This must be called BEFORE waiting on t.initComplete so excluded and SSE // This must be called BEFORE waiting on t.initComplete so excluded, SSE and
// traffic is never blocked by a slow/broken provider. // WebSocket traffic is never blocked by a slow/broken provider.
func (t *TraefikOidc) shouldBypassAuth(req *http.Request) (bool, string) { func (t *TraefikOidc) shouldBypassAuth(req *http.Request) (bool, string) {
if t.determineExcludedURL(req.URL.Path) { if t.determineExcludedURL(req.URL.Path) {
return true, bypassReasonExcluded return true, bypassReasonExcluded
@@ -36,38 +55,55 @@ func (t *TraefikOidc) shouldBypassAuth(req *http.Request) (bool, string) {
if strings.Contains(req.Header.Get("Accept"), "text/event-stream") { if strings.Contains(req.Header.Get("Accept"), "text/event-stream") {
return true, bypassReasonSSE return true, bypassReasonSSE
} }
if isWebSocketUpgrade(req) {
return true, bypassReasonWebSocket
}
return false, "" return false, ""
} }
// applySSEUserHeaders attempts to copy the authenticated user's identity from // applyBypassUserHeaders enforces authentication on SSE / WebSocket bypass
// an existing session onto the outgoing SSE request so downstream services // requests and, on success, copies the authenticated user's identity onto
// can still see who the user is. Failures are logged (not silenced) because // the outgoing request so downstream services can see who the user is.
// they indicate either a corrupt cookie or a misconfigured session manager //
// and are useful for debugging, but they never block the bypass itself. // Returns true when the request carries a valid authenticated session and
func (t *TraefikOidc) applySSEUserHeaders(req *http.Request) { // the bypass should proceed. Returns false when no usable session is
// present; callers must then reject the request (typically with 401) to
// prevent unauthenticated traffic from reaching the backend just by setting
// `Accept: text/event-stream` or sending a WebSocket upgrade.
//
// The check is cookie-only: the session cookie is sealed by our encryption
// key, so the authenticated flag cannot be forged. We do NOT run full token
// signature verification here so that SSE/WS keeps working when the OIDC
// provider is briefly unavailable for JWK fetches.
func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) bool {
if t.sessionManager == nil { if t.sessionManager == nil {
return return false
} }
session, err := t.sessionManager.GetSession(req) session, err := t.sessionManager.GetSession(req)
if err != nil { if err != nil {
// Intentionally not fatal: SSE requests bypass auth, we just lose the t.logger.Debugf("%s bypass: unable to load session: %v", reason, err)
// forwarded-user header for this request. return false
t.logger.Debugf("SSE bypass: unable to load session for user header propagation: %v", err)
return
} }
defer session.returnToPoolSafely() defer session.returnToPoolSafely()
if !session.GetAuthenticated() {
t.logger.Debugf("%s bypass: rejecting request without authenticated session", reason)
return false
}
email := session.GetEmail() email := session.GetEmail()
if email == "" { if email == "" {
return t.logger.Debugf("%s bypass: rejecting request, session has no user identifier", reason)
return false
} }
req.Header.Set("X-Forwarded-User", email) req.Header.Set("X-Forwarded-User", email)
if !t.minimalHeaders { if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-User", email) req.Header.Set("X-Auth-Request-User", email)
} }
t.logger.Debugf("SSE bypass: forwarded user %s from session", email) t.logger.Debugf("%s bypass: forwarded user %s from session", reason, email)
return true
} }
// ServeHTTP implements the main middleware logic for processing HTTP requests. // ServeHTTP implements the main middleware logic for processing HTTP requests.
@@ -124,16 +160,32 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
t.firstRequestMutex.Unlock() t.firstRequestMutex.Unlock()
} }
// Evaluate auth-bypass once, before waiting for initialization. Excluded URLs // Evaluate auth-bypass once, before waiting for initialization. Excluded
// and SSE requests must not block on provider init. For SSE we additionally // URLs, SSE and WebSocket upgrade requests must not block on provider
// attempt to forward the user identity from an existing session (best // init. For SSE/WebSocket we ALSO require an authenticated session
// effort) so downstream handlers still see X-Forwarded-User. // (cookie-only check, no JWK fetch) and otherwise return 401 — clients
// of in-flight streams can't follow an OIDC redirect, so forwarding
// unauthenticated traffic would silently expose the backend.
if bypass, reason := t.shouldBypassAuth(req); bypass { if bypass, reason := t.shouldBypassAuth(req); bypass {
t.logger.Debugf("Bypassing OIDC for %s (%s)", req.URL.Path, reason) t.logger.Debugf("Bypassing OIDC for %s (%s)", req.URL.Path, reason)
if reason == bypassReasonSSE { switch reason {
t.applySSEUserHeaders(req) case bypassReasonExcluded:
// Operator-declared excluded URLs forward unconditionally.
t.next.ServeHTTP(rw, req)
case bypassReasonSSE, bypassReasonWebSocket:
// Skip the OIDC redirect dance (clients can't follow it
// mid-stream) but still require an authenticated session.
// Otherwise an unauthenticated client could hit the backend
// just by setting Accept: text/event-stream or sending a
// WebSocket upgrade.
if !t.applyBypassUserHeaders(req, reason) {
t.sendErrorResponse(rw, req, "Authentication required", http.StatusUnauthorized)
return
} }
t.next.ServeHTTP(rw, req) t.next.ServeHTTP(rw, req)
default:
t.next.ServeHTTP(rw, req)
}
return return
} }
@@ -386,31 +438,52 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
} }
} }
tokenForClaims := session.GetIDToken() // Resolve ID-token claims at most once per request. SessionData caches
if tokenForClaims == "" { // the parsed claims keyed on the raw ID token, so concurrent dashboard
tokenForClaims = session.GetAccessToken() // panel requests on the same session don't repeatedly base64-decode and
if tokenForClaims == "" && len(t.allowedRolesAndGroups) > 0 { // JSON-unmarshal the same JWT (a real cost under the yaegi interpreter
// that hosts Traefik plugins). idClaims is reused below by the
// header-templates branch.
idToken := session.GetIDToken()
var (
idClaims map[string]interface{}
idClaimsErr error
)
if idToken != "" {
idClaims, idClaimsErr = session.GetIDTokenClaims(t.extractClaimsFunc)
}
// Choose which claims drive groups/roles extraction. Prefer the ID
// token (cached) and fall back to the access token if there is no ID
// token in the session — matching the prior behavior for opaque
// ID-token providers.
var (
groupClaims map[string]interface{}
groupClaimsErr error
)
if idToken != "" {
groupClaims, groupClaimsErr = idClaims, idClaimsErr
} else if accessToken := session.GetAccessToken(); accessToken != "" {
groupClaims, groupClaimsErr = t.extractClaimsFunc(accessToken)
} else if len(t.allowedRolesAndGroups) > 0 {
t.logger.Error("No token available but roles/groups checks are required") t.logger.Error("No token available but roles/groups checks are required")
// Reset redirect count to prevent loops when token is missing
session.ResetRedirectCount() session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL) t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return return
} }
}
// Initialize empty slices
var groups, roles []string var groups, roles []string
if tokenForClaims != "" { if groupClaimsErr == nil && groupClaims != nil {
var err error var err error
groups, roles, err = t.extractGroupsAndRoles(tokenForClaims) groups, roles, err = t.extractGroupsAndRolesFromClaims(groupClaims)
if err != nil && len(t.allowedRolesAndGroups) > 0 { if err != nil && len(t.allowedRolesAndGroups) > 0 {
t.logger.Errorf("Failed to extract groups and roles: %v", err) t.logger.Errorf("Failed to extract groups and roles: %v", err)
// Reset redirect count to prevent loops when claim extraction fails
session.ResetRedirectCount() session.ResetRedirectCount()
t.defaultInitiateAuthentication(rw, req, session, redirectURL) t.defaultInitiateAuthentication(rw, req, session, redirectURL)
return return
} else if err == nil { }
if err == nil {
if len(groups) > 0 { if len(groups) > 0 {
req.Header.Set("X-User-Groups", strings.Join(groups, ",")) req.Header.Set("X-User-Groups", strings.Join(groups, ","))
} }
@@ -442,41 +515,40 @@ func (t *TraefikOidc) processAuthorizedRequest(rw http.ResponseWriter, req *http
if !t.minimalHeaders { if !t.minimalHeaders {
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI()) req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
req.Header.Set("X-Auth-Request-User", email) req.Header.Set("X-Auth-Request-User", email)
if idToken := session.GetIDToken(); idToken != "" { if idToken != "" {
req.Header.Set("X-Auth-Request-Token", idToken) req.Header.Set("X-Auth-Request-Token", idToken)
} }
} }
if len(t.headerTemplates) > 0 { if len(t.headerTemplates) > 0 {
// Reuse claims parsed earlier in this request if the ID token has not if idClaimsErr != nil {
// changed. Saves an unnecessary JWT parse on every authenticated t.logger.Errorf("Failed to extract claims from ID Token for template headers: %v", idClaimsErr)
// request that uses headerTemplates.
claims, err := session.GetIDTokenClaims(t.extractClaimsFunc)
if err != nil {
t.logger.Errorf("Failed to extract claims from ID Token for template headers: %v", err)
} else { } else {
// idClaims may be nil when no ID token is present; templates
// referencing .Claims.* will simply produce empty values, which
// matches the prior behavior.
templateData := map[string]interface{}{ templateData := map[string]interface{}{
"AccessToken": session.GetAccessToken(), "AccessToken": session.GetAccessToken(),
"IDToken": session.GetIDToken(), "IDToken": idToken,
"RefreshToken": session.GetRefreshToken(), "RefreshToken": session.GetRefreshToken(),
"Claims": claims, "Claims": idClaims,
} }
for headerName, tmpl := range t.headerTemplates { for headerName, tmpl := range t.headerTemplates {
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil { if err := tmpl.Execute(&buf, templateData); err != nil {
t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err) t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err)
continue continue
} }
headerValue := buf.String() headerValue := buf.String()
req.Header.Set(headerName, headerValue) req.Header.Set(headerName, headerValue)
t.logger.Debugf("Set templated header %s = %s", headerName, headerValue) t.logger.Debugf("Set templated header %s = %s", headerName, headerValue)
} }
session.MarkDirty() // NOTE: templates only mutate request headers (not session state),
t.logger.Debugf("Session marked dirty after templated header processing.") // so we deliberately do NOT MarkDirty / Save here. Previously every
// authenticated request with header templates re-encrypted and
// rewrote all session cookies, which was a measurable CPU and
// Set-Cookie tax on dashboards that poll many panels per second.
} }
} }
+19 -12
View File
@@ -11,6 +11,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"runtime"
"strings" "strings"
"time" "time"
) )
@@ -1193,9 +1194,14 @@ func (t *TraefikOidc) startTokenCleanup() {
sessionManager := t.sessionManager sessionManager := t.sessionManager
logger := t.logger logger := t.logger
// Only use the fast cleanup interval when actually running under `go test`.
// runtime.Compiler == "yaegi" makes isTestMode() return true in production
// (Traefik interprets the plugin via yaegi), which would otherwise pin this
// ticker to 20 Hz on a real cluster despite tokenCache.Cleanup and
// jwkCache.Cleanup both being no-ops there.
cleanupInterval := 1 * time.Minute cleanupInterval := 1 * time.Minute
if isTestMode() { if isTestMode() && runtime.Compiler != "yaegi" {
cleanupInterval = 50 * time.Millisecond // Fast interval for tests cleanupInterval = 50 * time.Millisecond
} }
// Create cleanup function // Create cleanup function
@@ -1237,25 +1243,27 @@ func (t *TraefikOidc) startTokenCleanup() {
} }
// extractGroupsAndRoles extracts group and role information from token claims. // extractGroupsAndRoles extracts group and role information from token claims.
// It parses the 'groups' and 'roles' claims from the ID token and validates their format. // It parses the configured group/role claims from the supplied ID token.
// Parameters:
// - idToken: The ID token containing claims to extract.
// //
// Returns: // Most callers should prefer extractGroupsAndRolesFromClaims when claims have
// - groups: Array of group names from the 'groups' claim. // already been parsed for the request (e.g. via SessionData.GetIDTokenClaims),
// - roles: Array of role names from the 'roles' claim. // to avoid re-parsing the JWT.
// - An error if claim extraction fails or if the 'groups' or 'roles' claims are present
// but not arrays of strings.
func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string, error) { func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string, error) {
claims, err := t.extractClaimsFunc(idToken) claims, err := t.extractClaimsFunc(idToken)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to extract claims: %w", err) return nil, nil, fmt.Errorf("failed to extract claims: %w", err)
} }
return t.extractGroupsAndRolesFromClaims(claims)
}
// extractGroupsAndRolesFromClaims extracts group and role information from
// already-parsed claims. Hot path: callers that have a cached claims map (such
// as SessionData.GetIDTokenClaims) should use this to skip a redundant
// base64+JSON decode of the JWT on every authenticated request.
func (t *TraefikOidc) extractGroupsAndRolesFromClaims(claims map[string]interface{}) ([]string, []string, error) {
var groups []string var groups []string
var roles []string var roles []string
// Extract groups using configurable claim name (defaults to "groups")
if groupsClaim, exists := claims[t.groupClaimName]; exists { if groupsClaim, exists := claims[t.groupClaimName]; exists {
groupsSlice, ok := groupsClaim.([]interface{}) groupsSlice, ok := groupsClaim.([]interface{})
if !ok { if !ok {
@@ -1271,7 +1279,6 @@ func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string,
} }
} }
// Extract roles using configurable claim name (defaults to "roles")
if rolesClaim, exists := claims[t.roleClaimName]; exists { if rolesClaim, exists := claims[t.roleClaimName]; exists {
rolesSlice, ok := rolesClaim.([]interface{}) rolesSlice, ok := rolesClaim.([]interface{})
if !ok { if !ok {