mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-18 04:47:28 +00:00
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:
+60
-9
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user