feat(auth): login code exchange instead of a token in the callback URL

Merged
MI
3d ago
+5899-96

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_challenge and code_challenge_method=S256 and 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_handoffs table. 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 same 401 LOGIN_HANDOFF_INVALID error 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 the chainloop_controlplane_auth_legacy_callback_deliveries_total metric.
  • R-005, CLI uses the exchange. When the control plane reports support, chainloop auth login sends 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. Infoz reports login_code_exchange_supported and login_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 to false, 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 returnTo is 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.example no 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 returnTo always resolves against it. The existing callbackAllowed origin 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/ExchangeHandoff is 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