fix(auth): harden OIDC flow, token validation, session, and bearer handling

- replay entry covers exp + clock-skew window; callback GET-only (RFC 6749)
- callback rate-limit returns 429 + Retry-After; panic-recovery sets no-store
- cross-chunk integrity for chunked tokens; bounded chunked-token read scan
- HTTPError classification so 5xx/429 token-endpoint responses retry
- redaction of token/code/state in logs; per-source external auth gate
This commit is contained in:
2026-08-14 16:16:26 +01:00
parent 3eb11b07e9
commit 8640451877
19 changed files with 2002 additions and 508 deletions
+60 -9
View File
@@ -107,10 +107,13 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
data := url.Values{
"grant_type": {grantType},
}
// Snapshot client credentials under metadataMu: DCR rewrites clientID/
// clientSecret/audience at runtime (R137).
clientID, clientSecret, clientAuthMethod, _, clientAssertion := t.clientCredentials()
// client_id is sent in the body for every method except client_secret_basic,
// where it is carried in the Authorization header per RFC 6749 §2.3.1.
if t.clientAuthMethod != "client_secret_basic" || t.clientAssertion != nil {
data.Set("client_id", t.clientID)
if clientAuthMethod != "client_secret_basic" || clientAssertion != nil {
data.Set("client_id", clientID)
}
if grantType == "authorization_code" {
@@ -148,17 +151,17 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
t.metadataMu.RUnlock()
useBasicAuth := false
if t.clientAssertion != nil {
assertion, err := t.clientAssertion.Sign(tokenURL, t.clientID)
if clientAssertion != nil {
assertion, err := clientAssertion.Sign(tokenURL, clientID)
if err != nil {
return nil, fmt.Errorf("failed to sign client assertion: %w", err)
}
data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
data.Set("client_assertion", assertion)
} else if t.clientAuthMethod == "client_secret_basic" {
} else if clientAuthMethod == "client_secret_basic" {
useBasicAuth = true
} else {
data.Set("client_secret", t.clientSecret)
data.Set("client_secret", clientSecret)
}
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) //nolint:gosec // tokenURL is the operator-trusted discovery endpoint (metadataMu)
@@ -167,7 +170,7 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if useBasicAuth {
setOAuthBasicAuth(req, t.clientID, t.clientSecret)
setOAuthBasicAuth(req, clientID, clientSecret)
}
resp, err := client.Do(req) //nolint:gosec // req above targets the trusted tokenURL
@@ -182,7 +185,14 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
if resp.StatusCode != http.StatusOK {
limitReader := io.LimitReader(resp.Body, 1024*10)
bodyBytes, _ := io.ReadAll(limitReader) // Safe to ignore: reading error body for diagnostics
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes))
// Return a *HTTPError so the retry executor's isRetryableError can
// classify transient 5xx/429 responses and actually retry (R118).
// A plain error here made the 5xx/429 retry branch dead in
// production — every token-endpoint status was treated as permanent.
return nil, &HTTPError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("token endpoint returned status %d: %s", resp.StatusCode, string(bodyBytes)),
}
}
var tokenResponse TokenResponse
@@ -192,6 +202,16 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
// RFC 6749 §5.1: a token response MUST include an access_token. A 200
// that omitted it is a protocol violation, not a success — treat it as an
// error here so no caller authenticates with a blank token (R158).
// Previously this seam returned success with an empty access_token and
// each caller had to re-check "AccessToken == \"\"", which several
// paths missed.
if tokenResponse.AccessToken == "" {
return nil, fmt.Errorf("token endpoint returned 200 without an access_token (RFC 6749 §5.1)")
}
return &tokenResponse, nil
}
@@ -204,6 +224,17 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
// Returns:
// - *TokenResponse: New token set from the authorization server
// - An error if the refresh operation fails
//
// redactToken returns a short, stable hash of a token credential suitable for
// debug logging without leaking the credential itself (R138).
func redactToken(token string) string {
if token == "" {
return "(empty)"
}
sum := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", sum[:4])
}
func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenResponse, error) {
ctx := context.Background()
@@ -218,7 +249,8 @@ func (t *TraefikOidc) getNewTokenWithRefreshToken(refreshToken string) (*TokenRe
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
t.logger.Debugf("Token response: %+v", tokenResponse)
t.logger.Debugf("Refresh token response: token_type=%s expires_in=%d access_token=%s",
tokenResponse.TokenType, tokenResponse.ExpiresIn, redactToken(tokenResponse.AccessToken))
return tokenResponse, nil
}
@@ -399,6 +431,20 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
}
}
// Best-effort provider-side revocation (single logout): the local
// blacklist only stops reuse in-process, but a token captured before
// logout (e.g. a bearer-mode access token or the refresh token) stays
// valid at the IdP and a captured refresh token could keep minting new
// tokens. Revoke both access and refresh tokens at the provider when a
// revocation endpoint is available; ignore errors (the endpoint may be
// absent or the provider already cleaned up on the end-session call).
if accessToken != "" {
_ = t.RevokeTokenWithProvider(accessToken, "access_token")
}
if refreshToken != "" {
_ = t.RevokeTokenWithProvider(refreshToken, "refresh_token")
}
postLogoutRedirectURI := t.postLogoutRedirectURI
// localRedirect is used when there is no provider end-session endpoint and
// the plugin redirects the browser itself. It must never be an absolute URL
@@ -437,10 +483,15 @@ func (t *TraefikOidc) handleLogout(rw http.ResponseWriter, req *http.Request) {
http.Error(rw, "Logout error", http.StatusInternalServerError)
return
}
// Logout redirects must never be cached: a heuristic-caching proxy
// replaying the 302 would re-trigger logout for an already
// logged-out session (R147 cache-directive contract).
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, logoutURL, http.StatusFound)
return
}
rw.Header().Set("Cache-Control", "no-store")
http.Redirect(rw, req, localRedirect, http.StatusFound)
}