feat(token): emit server-attested uid claim from User.Username#184
feat(token): emit server-attested uid claim from User.Username#184
Conversation
- Add `<prefix>_uid` private claim to access and refresh tokens, sourced from User.Username and re-resolved on every issuance and refresh - Suppress the claim on the client_credentials grant where no real user exists - Log and omit when the user lookup fails so token issuance never fails on a missing or deleted user - Extract MachineUserID/IsMachineUserID helpers and replace the "client:" sentinel literal that was duplicated across audit, introspect, handlers, and token services - Unify the per-claim test assertion helpers behind a single assertPrivateClaim so future private-claim tests reuse it Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new server-attested private JWT claim for user identity (<prefix>_uid, default extra_uid) emitted on access/refresh tokens and re-resolved from User.Username on each issuance/refresh, while also consolidating the client-credentials synthetic user ID sentinel into shared helpers.
Changes:
- Register new private claim logical name
uidand emit it fromUser.Usernamein token issuance/refresh flows (omitted forclient_credentialsand on user-lookup failure). - Refactor
"client:" + clientID/HasPrefix("client:")usages intoMachineUserID(...)/IsMachineUserID(...)helpers. - Add/extend unit + integration tests covering emission/omission and refresh-time re-resolution semantics.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| internal/token/types.go | Adds uid to the private-claim registry and documents trust model/semantics. |
| internal/config/config.go | Adds uid to the config-side drift-guard list of private claim logical names. |
| internal/services/token.go | Implements username resolution and emits <prefix>_uid as a server-attested claim during issuance. |
| internal/services/token_refresh.go | Ensures refresh-time claim composition includes the refresh token’s UserID for uid re-resolution. |
| internal/services/token_client_credentials.go | Introduces MachineUserID* helpers and ensures client-credentials tokens omit uid. |
| internal/services/token_introspect.go | Uses MachineUserID(...) for audit actor identity instead of inline "client:" concatenation. |
| internal/services/audit.go | Uses IsMachineUserID(...) when deciding whether to DB-resolve actor usernames. |
| internal/handlers/token.go | Uses services.IsMachineUserID(...) for subject-type discrimination and introspection username enrichment. |
| internal/services/token_extra_claims_test.go | Extends buildServerClaims tests to cover username/uid behavior and custom prefixes. |
| internal/services/token_domain_test.go | Generalizes claim assertion helper for reuse across private-claim tests and updates buildServerClaims signature. |
| internal/services/token_private_claim_prefix_test.go | Updates to new buildServerClaims signature to keep reserved/override tests accurate. |
| internal/services/token_uid_test.go | Adds end-to-end style tests for uid emission, omission on client credentials, and refresh-time re-resolution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… Username - Correct the IsMachineUserID doc-comment to reference AccessToken.UserID and the User.ID UUID invariant rather than User.Username, which is not what the function checks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
Adds a server-attested
<prefix>_uidprivate claim (defaultextra_uid) to access and refresh tokens, sourced fromUser.Usernameand re-resolved on every issuance and refresh so admin renames propagate to the next token. The claim is suppressed for theclient_credentialsgrant where no real user exists. This is Stage 1 of a larger design — a future Stage 2 (RFC 7523 JWT Bearer Assertion grant for external services that have already authenticated users) was discussed and intentionally deferred to a separate PR.Mirrors the pattern of
<prefix>_domainfrom #181 / b2dc884.AI Authorship
Change classification
The change touches token issuance, which affects every JWT minted by AuthGate. Downstream resource servers consume these claims; a defect here ships to every consumer.
Plan reference
This PR implements Stage 1 of the plan at
~/.claude/plans/token-post-oauth-token-synchronous-planet.md(local plan file, not in repo).Goal: every access/refresh JWT issued via
POST /oauth/tokenforauthorization_code,device_code, andrefresh_tokengrants carries<prefix>_uid: <User.Username>.client_credentialsgrant must omit. Username re-resolves from DB on every issuance/refresh.Locked design decisions (resolved with the user during planning):
<prefix>_uidentirely; downstream M2M consumers can useclient_idor<prefix>_service_accountfor machine identity.Plan-scope deviation (disclose)
The plan's
must-not-modifylist includedinternal/handlers/,internal/services/audit.go, andinternal/services/token_introspect.go. The simplify pass touched all three to consolidate the"client:"sentinel that was duplicated across 6 production sites:MachineUserIDPrefixconst +MachineUserID(clientID)/IsMachineUserID(userID)helpers ininternal/services/token_client_credentials.go."client:" + clientIDandstrings.HasPrefix(userID, "client:")literals inservices/token_introspect.go,services/audit.go,handlers/token.go, and the newservices/token.goresolver.Rationale: introducing the helper without updating the existing 4 sites would have left the new code adding a 6th duplicate of the literal. Removing duplication felt mandatory once the helper existed. Reviewer should sanity-check that this consolidation hasn't changed behavior at any of the 4 pre-existing call sites — the refactor is mechanical but each call site is in a distinct hot path (audit logging, introspection, handler subjectType discrimination).
Verification
TestBuildServerClaimstable extended for the newusernamedimension (5 cases, including custom prefix)internal/services/token_uid_test.gowith 4 tests:TestAuthCodeFlow_EmitsUidClaim— auth_code + device_code subtests, both grants emit<prefix>_uidon access AND refresh tokensTestClientCredentialsFlow_OmitsUidClaim— claim absent on M2M tokens; explicitly contrasts the parallel domain-claim test that asserts presenceTestRefresh_ReResolvesUidAfterUsernameChange— admin rename between issuance and refresh propagates to the next refreshed tokenTestUidClaim_OmittedWhenUserLookupFails— issuance with a UserID that has no matching user row succeeds with claim absentTestPrivateClaimRegistryDrift(config) auto-covers the newuidregistry entry without test edits; existing reserved-key / strip tests cascade throughBuildReservedClaimKeysandcomputeStripListso callers cannot injectextra_uid, bareuid, or default-prefixed variantsVerifiability check
internal/token/types.go, function-level docs ininternal/services/token.go)[Token] uid claim: GetUserByID failed user_id=...log line on lookup missSecurity check
This PR touches token issuance, which is auth-adjacent. Security checklist:
<prefix>_uidis fully server-derived fromUser.Usernameuid/extra_uidto reserved keys (rejected at the parser) AND to the strip list (defense-in-depth at sign time); reserved-key tests are auto-covered by the registry-driven derivation<prefix>_uidis intentionally NOT advertised inclaims_supported(matches the precedent for<prefix>_domainset in fix(oidc): drop non-OIDC claims from discovery metadata #180 — prefix-namespaced server-attested claims are AuthGate private extensions, not OIDC standard)Risk & rollback
Risks:
s.store.GetUserByID(userID). Bypasses the user cache inUserServicebecauseTokenServicecallsstoredirectly. Acknowledged as parity with the existing client lookup; future optimization can route throughUserServicewhen DI is restructured. Refresh hot path order is correct:GetUserByIDruns AFTER all short-circuit checks (IsRefreshToken,IsActive,IsExpired,ClientIDmismatch, scope subset).composeIssuanceClaimsfor uid, once inExchangeAuthorizationCodefor ID-token claims. Worth a focused follow-up to context-cache viamodels.SetUserContext; not in scope here.<prefix>_domainsemantics.<prefix>_uidfor auth decisions — the registry doc explicitly says it's a handle, not authority-attested identity. Reviewers should sanity-check the doc framing ininternal/token/types.go.Manual verification before merge:
Rollback: Single revert. Existing token consumers must already handle the claim's absence (since it's a new claim), so reverting drops the claim cleanly. Tokens already issued with the claim remain valid — JWT verification doesn't reject unknown claims.
Reviewer guide
Read carefully (line-by-line — fully AI-authored, not human-reviewed):
internal/services/token.go—buildServerClaims(signature changed),resolveUsernameForUID(new),composeIssuanceClaims(signature changed). The trust model and short-circuit logic for theclient:UserID belongs here.internal/services/token_client_credentials.go— newMachineUserIDPrefix/MachineUserID/IsMachineUserIDexports. These are now consumed cross-package byhandlers/token.go; verify export and naming sit well in this file.internal/services/token_uid_test.go— assertion semantics:assertPrivateClaim(t, cfg, raw, logical, "")asserts absence. Verify test logic matches the omission-vs-emission expectations stated in each test docstring.internal/token/types.go— registry entry (single line) plus updated trust-level docstring. The docstring is the authoritative reference for downstream consumers' trust assumptions.Spot-check OK (mechanical refactors):
internal/handlers/token.go,internal/services/audit.go,internal/services/token_introspect.go—"client:"literal → helper call. Behavior unchanged; tests cover.internal/services/token_refresh.go,internal/services/token_client_credentials.go(call site) — single-lineuserIDplumbing intocomposeIssuanceClaims.internal/services/token_extra_claims_test.go,internal/services/token_domain_test.go,internal/services/token_private_claim_prefix_test.go— signature-fix updates andassertPrivateClaimunification.internal/config/config.go— single-line drift-guard parallel registry update.Suggested reviewer count: 2+ (core code, fully AI-authored). Include the token-issuance module owner (recent recent token-stack contributors per
git log internal/token internal/services/token*.go).