feat(auth): login code exchange instead of a token in the callback URL
Merged
MI
3d ago+5899+5899+0123456789012345678901234567890123456789-96-96-01234567890123456789
At the end of an OIDC login, the control plane sends the session token to the client in the callback URL (?t=<token>). This change implements the design in docs/specs/issue-3626-login-code-exchange.md (phase 1 of its rollout): a client that sends a PKCE-style code challenge gets a short-lived, single-use handoff code instead, and exchanges it for the token over an API call. Existing clients keep working, because the legacy callback stays allowed by default.
What changed, by requirement
- R-001, code instead of token. A login request with
code_challengeandcode_challenge_method=S256and a callback is redirected to the callback with?handoff=<code>and no token (parameter name per D-011). - R-002, bound, short-lived, single-use code. Codes expire 60 seconds after they are issued and are stored in a new
login_handoffstable. The exchange marks the code used in one atomic update, then checks expiry, the verifier against the challenge, and the callback. Every failure returns the same401 LOGIN_HANDOFF_INVALIDerror to the client. The server log records the reason: unknown code, expired, already used, wrong verifier, or wrong callback. - R-003, token only in the exchange response. New unauthenticated RPC
AuthService/ExchangeHandoff, served over gRPC and gRPC-web only (D-013). It creates the session token at exchange time with the same lifetime as the legacy flow, and returns it only in the response body. No token is stored at rest. - R-004, legacy callback unchanged while allowed. A login with no code challenge keeps the
?t=callback. Each such delivery to an external callback is logged and counted in thechainloop_controlplane_auth_legacy_callback_deliveries_totalmetric. - R-005, CLI uses the exchange. When the control plane reports support,
chainloop auth loginsends a challenge and exchanges the handoff code. While the control plane reports that legacy is allowed, the CLI accepts a?t=callback and prints a deprecation warning. When legacy is denied, the CLI rejects a?t=callback. Against an older control plane, the CLI uses the legacy flow. Headless login does not change. - R-006, capability discovery.
Infozreportslogin_code_exchange_supportedandlogin_legacy_callback_allowed. - R-007, operator switch. A new config setting
auth.allow_legacy_login_callback(Helm:controlplane.auth.allowLegacyLoginCallback) controls the legacy callback. When it is not set, legacy is allowed (phase 1). When it is set tofalse, a login with a callback and no challenge fails with a message that tells the user to upgrade the client. The headless login and the same-origin download flow (D-008) are not affected. - R-008, relative-only post-login redirect. The login handler rejects a callback whose
returnTois not a path that starts with a single/. Absolute URLs, scheme-relative values, backslashes and control characters are rejected. The check runs at login start and again before the redirect. A callback with no scheme and no host must also be a single-slash path now, so a value like////evil.exampleno longer passes as relative.
Implementation notes
- The handoff code has 256 bits of entropy from
crypto/rand(oauth2.GenerateVerifier). Only its SHA-256 hash is stored, and lookups use the hash. - The code challenge travels in a cookie next to the existing callback cookie during the OIDC redirect, with the same cookie flags. The OIDC callback handler reads it after the state check. A login started before the upgrade has no challenge cookie and is handled as a legacy login.
- R-008 "same origin" is read as the origin of the callback URL: a relative
returnToalways resolves against it. The existingcallbackAllowedorigin check of the callback itself is unchanged for absolute URLs. - A code challenge with no callback, or a method other than
S256, is rejected at login start. - The exchange requires the exact callback URL the login was started with. A wrong verifier burns the code, so the code cannot be retried.
- Exchange failures return a single error to the client. Malformed requests (for example empty fields) are still rejected by the request validation with an invalid-argument error, which does not depend on the state of any code. The request arguments of the exchange are masked in the request log.
- The control plane has no rate limiting or request throttling for unauthenticated endpoints, so this change adds none. The code entropy, the single use, the burn on a wrong verifier and the 60-second lifetime are the protection against guessing.
- Expired codes are deleted every 10 minutes by a background sweeper, following the pattern of the existing background jobs. A used code is inert and is deleted when it expires, at most 60 seconds after it was issued.
- The legacy delivery counter uses the Prometheus default registry served on
/metrics, like the existing counters. It counts only deliveries to an external callback, so the same-origin download flow (D-008) does not affect the measurement used for the phase 3 deletion. - The new table's indexes are created in the same migration as the table, without
CONCURRENTLY, because the table is new and empty. This follows earlier migrations that create tables. AuthService/ExchangeHandoffis added to the operations that skip the user authentication middleware, and to the authorization exemptions.- The login and OIDC callback handlers no longer write a second response after a redirect or after the token page. Before, every login logged
http: superfluous response.WriteHeader call.
Differences from the spec
- D-003, exchange endpoint. The spec planned an HTTP POST mapping next to the gRPC RPC. This PR drops it: the CLI uses gRPC and the web UI uses gRPC-web, so no client needs the route, and the unauthenticated surface stays smaller. The spec is amended in this PR with a new decision, D-013, and D-003 is struck through as superseded.
This pull request was opened by Maximus bot (Claude Code) on behalf of @migmartri, and the code was written with AI assistance (Claude Code).
Closes #3626