ID-JAG step-up + auth_time freshness#11
Conversation
46c9825 to
808fd40
Compare
ef3d317 to
56f73bb
Compare
808fd40 to
4926a3c
Compare
|
@greptileai review |
Greptile SummaryThis PR gates first-time ID-JAG delegation binding behind a user-confirmation ceremony (
Confidence Score: 5/5Safe to merge; the core security invariants (no silent delegation binding, auth_time freshness enforced universally including future timestamps) are correctly implemented and the wrong-account check at the claim page gates step-up completion to the matched user. The discriminated-union matcher, unified findOrCreateIdJagRegistration, and completeClaim delegation upsert all compose correctly. The race-resolution path in handleIdJagStepUp correctly falls through to emitIdJagSuccess when the ceremony completes concurrently. No sensitive tokens are logged, JWTs are validated with iss/aud checks, and the provider display name is service-controlled. agent-services/src/routes/agent-auth.ts (escapeHeader CRLF gap) and AUTH.md (step-up user_code refresh path undocumented) Important Files Changed
|
56f73bb to
9c13619
Compare
4926a3c to
54deca0
Compare
9c13619 to
3d60a1d
Compare
8cae8f2 to
e624e5f
Compare
3d60a1d to
96bee48
Compare
090e1b6 to
617a4fb
Compare
339a42e to
239cb14
Compare
554ae3a to
06058af
Compare
239cb14 to
fd36854
Compare
|
@greptile |
fd36854 to
f3bb938
Compare
1df9a5a to
47ac80d
Compare
|
@greptile |
47ac80d to
d02e579
Compare
Gate first-time linking of an ID-JAG to an existing account behind a
user-confirmation ceremony, and reject ID-JAGs with stale or missing
auth_time so agents have to refresh upstream.
Two distinct failure modes, both 401, with different error codes so the
agent knows where to route:
- interaction_required: service-side step-up. The ID-JAG matched an
existing user by verified email/phone but no (iss, sub) delegation yet.
Body carries the same RFC 8628-shaped ceremony block as verified-email
registration; the agent surfaces user_code + verification_uri to the
user. The /claim page renders provider-aware copy ("Acme Provider is
asking to link this account…") using the service's trust-list
display_name. The matched email becomes the registration's claim_email
so the existing wrong-account check fires.
- login_required: provider-side re-auth required. ID-JAG missing or stale
auth_time. The agent's recourse is at its provider (prompt=login or
equivalent) — nothing the user does at the service helps. WWW-Authenticate
carries max_age so the agent knows the threshold.
Service code
- config.idJagMaxAuthAgeSeconds: 3600. Hard-required, not nullable.
- trustedIssuers entries are now { iss, displayName }; trustedIssuerDisplayName(iss)
resolves what the /claim page renders.
- verify.ts requires auth_time on every ID-JAG and rejects ones older than
the max age + clockSkew. Applied universally (including known (iss, sub)
delegations) to prevent indefinite session piggy-backing.
- matcher.ts MatchResult is a discriminated union: { kind: match | step_up_required }.
Email/phone matches no longer call upsertDelegation — they return
step_up_required and let the route gate the binding.
- store.ts: findOrCreateIdJagRegistration unified into one function with a
context: { user } | { email } discriminator. Same deterministic
(iss, sub, aud) key for clean-match and step-up — step-up is just the
not-yet-claimed state of the same registration. completeClaim for id_jag
registrations calls upsertDelegation(iss, sub, user.id) on success.
- agent-auth.ts handleIdJagStepUp: 401 with ceremony block.
handleIdJagVerifyError: maps auth_time_* to 401 login_required with
WWW-Authenticate carrying max_age.
- claim.ts: per-kind prompt copy. ID-JAG registrations get
"Link <Provider> to your account?" wording.
Provider code
- session.ts: freshen user.auth_time = new Date() on every login. The
seed timestamp would otherwise be permanently stale.
Docs
- AUTH.md Step 3 ID-JAG: documents both 401 response shapes and the
required auth_time claim.
- agent-providers/README.md: auth_time moves from optional to required;
errors table adds interaction_required and login_required.
- agent-services/README.md: new sections on auth_time freshness and
first-link step-up; user matching policy updated to step-up on
email/phone matches; trust-list guidance includes service-controlled
display_name with CIMD as the production roadmap.
…line - verify.ts: reject ID-JAGs whose auth_time is more than clockSkewSeconds in the future. The old check only guarded against stale auth_time, so a compromised trusted issuer could mint tokens with a far-future auth_time and ride an indefinitely-stale session — exactly the piggy-backing the freshness gate is meant to prevent. - agent-auth.ts: when /agent/identity's step-up path detects that a concurrent ceremony already bound the delegation, emit the same 200 + identity_assertion the clean-match path would, instead of a 409 the agent had to retry. Extracted emitIdJagSuccess to share the response shape between both paths. - CHANGELOG.md: v0.5.0 entry for the step-up + freshness work. - package.json: 0.4.0 → 0.5.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the matcher/findOrCreateIdJagRegistration/completeClaim/race-fix items — those are implementation details. Keep the agent-visible contract: the two new 401 codes and the universal auth_time freshness requirement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /agent/identity/claim handler rejected every id_jag registration
with 409 claimed_or_in_flight ("ID-JAG registrations do not require a
claim ceremony"). That was true when ID-JAG was only the clean-match
path. With step-up, id_jag registrations carry a pending ceremony and
the standard "re-call claim_url when user_code expires" refresh
pattern needs to land here.
Drop the id_jag-kind guard. The status === "claimed" guard right below
it already rejects clean-match id_jag registrations (they're created
with claimed_at set, so status is "claimed" from t=0); step-up id_jag
registrations have status unclaimed/pending_claim and fall through to
the email-immutability check and recordClaimAttempt, same as
email-verification refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step-up isn't a separate registration kind — it's an identity_assertion
registration in a pending state. The previous response used three
distinct values for ID-JAG flows ("agent-provider" for clean match,
"id-jag-step-up" for pending, plus the kebab-cased "email-verification"
elsewhere); the agent has to branch on response shape (presence of
identity_assertion vs ceremony block) regardless of the label.
Collapse both ID-JAG paths to "identity_assertion" so the response
type matches the request body's `type` field. The agent reads back
what it sent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f399c1f to
8df507f
Compare
…ng prose step-up is OIDC jargon and clean-match is internal matcher vocabulary. Both belong in the service-implementer doc (agent-services/README.md) but not in AUTH.md or the CHANGELOG, which talk about what the agent encounters. Replace with "confirmation" terminology. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Missed this one in the prior pass — the success-branch header in
AUTH.md section 3 ("Clean match") still used the matcher's internal
label. Match the rest of the agent-facing prose.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
- "ceremony block" → "\`claim\` block" everywhere the response carries one under the \`claim\` key. Names the actual JSON field instead of introducing a separate term for it. - "the step-up gate makes the user's signed-in session ... the binding signal for linking external identities" → "step-up gates the binding on the user being signed in at your service — their authenticated session is what authorizes the link". Less wordy, same meaning. - "the trust list should also rotate provider display names through CIMD" → "services often source provider display names from CIMD ... instead of maintaining them by hand". Explains what CIMD actually does (a metadata doc the provider hosts at a stable URL); the warning about not rendering the raw \`client_name\` stays. - CHANGELOG.md v0.5.0 dated 2026-06-05 (was 2026-06-04 — moved forward for the merge date). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same treatment as the prior commit for PR #11's own content: name the actual JSON key (\`claim\` or \`claim_attempt\`) the response uses, or spell out the ceremony fields where the context is generic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same treatment as the PR #11 fix for the step-up response: name the actual JSON key rather than introducing a separate "ceremony block" term. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stack
Summary
Gate first-time linking of an ID-JAG to an existing account behind a user-confirmation ceremony, and reject ID-JAGs with stale or missing
auth_timeso agents have to refresh upstream.Two distinct failure modes from
/agent/identityfor ID-JAG flows, both 401, with different error codes so the agent knows where to route:interaction_required— service-side step-up. The ID-JAG matched an existing user by verified email/phone but no(iss, sub)delegation yet. Body carries the same RFC 8628-shaped ceremony block as verified-email registration; the agent surfacesuser_code+verification_urito the user. The/claimpage renders provider-aware copy ("Acme Provider is asking to link this account…") using the service's trust-listdisplayName. The matched email becomes the registration'sclaim_email, so the existing wrong-account check fires.login_required— provider-side re-auth required. ID-JAG missing or staleauth_time. The agent's recourse is at its provider (prompt=loginor equivalent) — nothing the user does at the service helps.WWW-Authenticatecarriesmax_ageso the agent knows the threshold.Without step-up, any trusted provider could mint an ID-JAG with
email_verified: truefor a victim's email and silently take over their account at the service. Step-up makes the user's signed-in session at the service the binding signal for linking external identities. Theauth_timecheck stops agents from riding indefinitely-stale upstream sessions.Service code
config.idJagMaxAuthAgeSeconds: 3600— hard-required, not nullable.trustedIssuersentries are now{ iss, displayName };trustedIssuerDisplayName(iss)resolves what the/claimpage renders. Service-controlled so a malicious provider can't pick its own copy.verify.tsrequiresauth_timeon every ID-JAG and rejects ones older thanidJagMaxAuthAgeSeconds + clockSkew. Applied universally — including known(iss, sub)delegations — to prevent indefinite session piggy-backing.matcher.tsMatchResultis a discriminated union:{ kind: "match" | "step_up_required" }. Email/phone matches no longer callupsertDelegation— they returnstep_up_requiredand let the route gate the binding.store.tsfindOrCreateIdJagRegistrationunified into one function with acontext: { user } | { email }discriminator. Same deterministic(iss, sub, aud)key for clean-match and step-up — step-up is just the not-yet-claimed state of the same registration.completeClaimforid_jagregistrations callsupsertDelegation(iss, sub, user.id)on success.agent-auth.tshandleIdJagStepUpreturns 401 with the ceremony block;handleIdJagVerifyErrormapsauth_time_*codes to 401login_requiredwithWWW-Authenticate: AgentAuth error="login_required", max_age="…".claim.tsrenders per-kind prompt copy. ID-JAG registrations get "Link<Provider>to your account?" wording.Provider code
session.tsfreshensuser.auth_time = new Date()on every login. The seed timestamp would otherwise be permanently stale; ID-JAGs would all read as expired.Docs
AUTH.mdStep 3 ID-JAG: documents both 401 response shapes and the requiredauth_timeclaim.agent-providers/README.md:auth_timemoves from optional to required; errors table addsinteraction_requiredandlogin_required; Downstream Verification section names the three resolution paths (existing delegation / JIT / step-up).agent-services/README.md: new subsections on auth_time freshness and first-link step-up; user-matching policy updated to step-up on email/phone matches; trust-list guidance includes service-controlleddisplay_namewith CIMD as the production roadmap.Smoke test
End-to-end verified:
alice@example.comuser)registration_type: id-jag-step-up+ ceremony block ✓/claimpage rendered as "Link Agent Provider to your account?" ✓user_code→ 200; agent's next claim-grant poll on/oauth2/tokenreturnsaccess_token+identity_assertionwithscope: api.read api.write✓auth_time(verified by JWT decode) ✓