feat(oauth): stamp gitlab-rails' resolved cell onto the access token

Stacked on !588 (merged) (the token-prefix MR) — targets that branch, not main.

Summary

  • Adds a cell_id field to LoginServiceAcceptRequest, so gitlab-rails can pass through the cell it already resolved for the user during its own Topology Service lookup earlier in the login flow.
  • Threads that value through both session-creation paths (trusted-client fast path, and the normal consent path) into a c claim on the access token's JWT payload.
  • Also adds a u (User ID) claim, per the Cells: Routable Tokens design doc: at least one of c/o is required (IAM has no o anywhere in this flow, so c covers that), and the doc recommends u in addition for a user-scoped token like this one.
  • Both c and u are base36-encoded strings ("c":"16", not "c":42), matching every other GitLab routable token and the Cells HTTP Router's strict decoder.
  • Implements part of gitlab-org/gitlab#604548 (closed) section 8.2, without any Topology Service integration in IAM itself.

Why this shape, not an IAM-side Topology Service client

The ticket's draft assumed IAM Auth would call the Topology Service's Classify RPC directly to resolve a user's cell. Investigating that turned up two blockers: Classify requires an mTLS certificate under one of three fixed roles (Cell/Router/Admin, per gitlab-org/cells/topology-service's docs/certificates.md) that IAM doesn't have, and the RPC only returns a proxy address, not the stable numeric cell ID the token needs — a caller would have to reverse-map it via a separately cached GetCells() call, with no existing pattern anywhere to copy.

Checking gitlab-org/gitlab's actual production code for glpat-, glagent-, and CI runner tokens (lib/authn/token_field/generator/routable_token.rb and its callers) showed none of them call the Topology Service either — the default routing payload is just c: -> { Gitlab.config.cell.id }, the local cell config, because Rails only ever mints a token from inside the cell that owns it. By the time gitlab-rails calls Login.Accept, it has already resolved the user's cell via its own earlier Topology Service lookup (the existing 2-step login flow). This MR has it pass that value through, rather than teaching IAM to ask the Topology Service the same question a second time.

u costs nothing extra to add: it's the same value already carried as the JWT's standard sub claim (subject on LoginService.Accept is @user.id.to_s on the gitlab-rails side). It's duplicated under the u key, rather than expecting the router to read sub, so the router's routable-token vocabulary doesn't need IAM-specific handling.

What changed

  • proto/auth/auth.proto: LoginServiceAcceptRequest.cell_id (int64, optional; 0 means not supplied).
  • login_challenges gets a nullable cell_id BIGINT column (both backends), set alongside the other verifier fields (subject/name/email) by AcceptChallenge. The three consent+login join queries now select l.cell_id too, since the non-trusted-client path builds its session from the joined ConsentChallenge.
  • identity.UserInfo.CellID carries the value from the gRPC handler through to session construction.
  • core.NewSessionWithClaims: the access-token and ID-token sessions used to share one extra claims map by reference. The access-token claims are now a clone with u (always) and c (when known) added, base36-encoded, so the ID token's claim set is unaffected — only the access token is ever cell-routed. u is derived by parsing subject back to an integer; if that fails, u is omitted rather than guessing, same as c.
  • pkg/storage.Int8OrNull, mirroring the existing TextOrNull/TimestampOrNull helpers.
  • docs/oauth-login-consent-api.md updated with the new field.
  • docs/routable-tokens.md: new "Routing claims" section covering u, c, the base36 encoding requirement, and why (cites the design doc and the router's decoder).

Testing

  • Unit tests (go test ./...): pass, including coverage on NewSessionWithClaims for u/c presence, base36 conversion, isolation from the ID token, and the non-numeric-subject fallback.
  • Integration tests against both backends (ENV=development-l2 / ENV=development-l1, go test -tags=integration ./auth/...): pass, including two end-to-end cases in handler_integration_test.go driving the real DB-backed login+token flow (trusted-client and consent paths), asserting both u and c land on the access token, base36-encoded, and neither on the ID token.
  • golangci-lint: 0 issues.
  • ./scripts/verify-agent-docs.sh: all documented claims hold.

Out of scope (tracked separately)

Edited by Shilpa Kundapur

Merge request reports

Loading
Loading