Skip to content

ID-JAG step-up + auth_time freshness#11

Merged
m0tzy merged 13 commits into
mainfrom
madison/id-jag-step-up
Jun 5, 2026
Merged

ID-JAG step-up + auth_time freshness#11
m0tzy merged 13 commits into
mainfrom
madison/id-jag-step-up

Conversation

@m0tzy
Copy link
Copy Markdown
Collaborator

@m0tzy m0tzy commented Jun 3, 2026

  • I have QA'd the changes

Stack

  1. Split credentials into a separate endpoint #8
  2. Migrate revocation channel to SET delivery (RFC 8417/8935) #9
  3. Invert the claim flow to mirror device auth #10
  4. ID-JAG step-up + auth_time freshness #11 ← you are here
  5. Claim anonymous registration via ID-JAG #12

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_time so agents have to refresh upstream.

Two distinct failure modes from /agent/identity for 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 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 displayName. 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.

Without step-up, any trusted provider could mint an ID-JAG with email_verified: true for 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. The auth_time check stops agents from riding indefinitely-stale upstream sessions.

Service code

  • config.idJagMaxAuthAgeSeconds: 3600 — hard-required, not nullable.
  • trustedIssuers entries are now { iss, displayName }; trustedIssuerDisplayName(iss) resolves what the /claim page renders. Service-controlled so a malicious provider can't pick its own copy.
  • verify.ts requires auth_time on every ID-JAG and rejects ones older than idJagMaxAuthAgeSeconds + 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 returns 401 with the ceremony block; handleIdJagVerifyError maps auth_time_* codes to 401 login_required with WWW-Authenticate: AgentAuth error="login_required", max_age="…".
  • claim.ts renders per-kind prompt copy. ID-JAG registrations get "Link <Provider> to your account?" wording.

Provider code

  • session.ts freshens user.auth_time = new Date() on every login. The seed timestamp would otherwise be permanently stale; ID-JAGs would all read as expired.

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; 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-controlled display_name with CIMD as the production roadmap.

Smoke test

End-to-end verified:

  1. alice signs up at service via email-verif (creates alice@example.com user)
  2. alice signs in at provider, mints ID-JAG, presents to service → 401 interaction_required with registration_type: id-jag-step-up + ceremony block ✓
  3. alice signs in at service, lands on /claim page rendered as "Link Agent Provider to your account?" ✓
  4. alice submits user_code → 200; agent's next claim-grant poll on /oauth2/token returns access_token + identity_assertion with scope: api.read api.write
  5. ID-JAGs now carry auth_time (verified by JWT decode) ✓

@devin-ai-integration
Copy link
Copy Markdown

@greptileai review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 3, 2026

Greptile Summary

This PR gates first-time ID-JAG delegation binding behind a user-confirmation ceremony (interaction_required step-up) and universally rejects ID-JAGs with missing or stale auth_time (login_required), closing the account-takeover vector where a trusted provider could silently claim an existing user's account via a forged email_verified assertion.

  • MatchResult is now a discriminated union; email/phone matches return step_up_required instead of silently calling upsertDelegation, and completeClaim now calls upsertDelegation for id_jag registrations so the binding is established only after the user confirms.
  • verifyIdJag enforces auth_time presence and freshness in both directions (too-old and too-future), with separate 401 shapes carrying OIDC-standard interaction_required and login_required error codes so agents can route correctly.
  • trustedIssuers entries carry a service-controlled displayName rendered on the /claim page, preventing a malicious provider from choosing its own confirmation copy.

Confidence Score: 5/5

Safe 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

Filename Overview
agent-services/src/verify.ts Adds auth_time presence and freshness checks (too-old and future); error code auth_time_too_old is reused for the future-auth_time case where a distinct code would be clearer
agent-services/src/matcher.ts MatchResult refactored to a discriminated union; email/phone matches now return step_up_required instead of silently binding the delegation — correct and well-scoped
agent-services/src/store.ts findOrCreateIdJagRegistration unified with context discriminator; completeClaim now calls upsertDelegation for id_jag kind to bind the delegation on ceremony completion
agent-services/src/routes/agent-auth.ts New handleIdJagStepUp and handleIdJagVerifyError handlers added; 409 guard removed to enable step-up; escapeHeader omits CRLF sanitization
agent-services/src/routes/claim.ts promptCopy added for provider-aware step-up wording; all user-controlled values are properly escaped; wrong-account check runs before code validation
agent-services/src/trust.ts trustedIssuerDisplayName added; display name is service-controlled and falls back to iss URL
agent-services/src/config.ts trustedIssuers entries extended to {iss, displayName}; idJagMaxAuthAgeSeconds added as a required constant
agent-providers/src/routes/session.ts auth_time freshened on every login so ID-JAGs carry a current timestamp rather than a permanently stale seed value
AUTH.md Documents both 401 shapes and updates error table; step-up user_code refresh path for ID-JAG flows is undocumented

Sequence Diagram

sequenceDiagram
    participant Agent
    participant Service as POST /agent/identity
    participant Claim as /claim (browser)
    participant Token as POST /oauth2/token

    Note over Agent,Token: Happy path — known (iss, sub) delegation
    Agent->>Service: ID-JAG (auth_time fresh)
    Service->>Service: verifyIdJag → auth_time check ✓
    Service->>Service: matchOrProvision → kind: match
    Service->>Agent: 200 identity_assertion

    Note over Agent,Token: login_required — auth_time missing or stale
    Agent->>Service: ID-JAG (auth_time old / missing)
    Service->>Service: verifyIdJag → auth_time_missing or auth_time_too_old
    Service->>Agent: 401 login_required + WWW-Authenticate max_age
    Agent->>Agent: re-auth user at provider

    Note over Agent,Token: Step-up — (iss,sub) unknown, email matches existing user
    Agent->>Service: ID-JAG (auth_time fresh)
    Service->>Service: matchOrProvision → step_up_required
    Service->>Service: findOrCreateIdJagRegistration → ceremony minted
    Service->>Agent: 401 interaction_required + claim block
    Agent->>Token: POST claim_token (poll → authorization_pending)
    Claim->>Claim: user signs in, types user_code → completeClaim
    Claim->>Claim: upsertDelegation(iss, sub, user.id)
    Agent->>Token: POST claim_token (poll → 200 access_token)
Loading

Reviews (10): Last reviewed commit: "Replace remaining "ceremony block" refer..." | Re-trigger Greptile

Comment thread agent-services/src/verify.ts
Comment thread agent-services/src/routes/agent-auth.ts
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from 56f73bb to 9c13619 Compare June 4, 2026 00:06
@m0tzy m0tzy force-pushed the madison/id-jag-step-up branch from 4926a3c to 54deca0 Compare June 4, 2026 00:06
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from 9c13619 to 3d60a1d Compare June 4, 2026 00:12
@m0tzy m0tzy force-pushed the madison/id-jag-step-up branch 2 times, most recently from 8cae8f2 to e624e5f Compare June 4, 2026 00:14
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from 3d60a1d to 96bee48 Compare June 4, 2026 00:14
@m0tzy m0tzy force-pushed the madison/id-jag-step-up branch 2 times, most recently from 090e1b6 to 617a4fb Compare June 4, 2026 03:43
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from 339a42e to 239cb14 Compare June 4, 2026 03:44
@m0tzy m0tzy force-pushed the madison/id-jag-step-up branch 2 times, most recently from 554ae3a to 06058af Compare June 4, 2026 03:49
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from 239cb14 to fd36854 Compare June 4, 2026 03:49
@m0tzy
Copy link
Copy Markdown
Collaborator Author

m0tzy commented Jun 4, 2026

@greptile

@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from fd36854 to f3bb938 Compare June 4, 2026 04:17
@m0tzy m0tzy force-pushed the madison/id-jag-step-up branch from 1df9a5a to 47ac80d Compare June 4, 2026 04:17
@m0tzy
Copy link
Copy Markdown
Collaborator Author

m0tzy commented Jun 4, 2026

@greptile

@m0tzy m0tzy marked this pull request as ready for review June 4, 2026 16:17
@m0tzy m0tzy marked this pull request as draft June 4, 2026 20:34
@m0tzy m0tzy marked this pull request as ready for review June 4, 2026 20:34
@m0tzy m0tzy force-pushed the madison/id-jag-step-up branch from 47ac80d to d02e579 Compare June 4, 2026 20:34
Base automatically changed from madison/swap-claim-flow to main June 4, 2026 20:37
@m0tzy m0tzy mentioned this pull request Jun 4, 2026
1 task
m0tzy and others added 5 commits June 4, 2026 14:29
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>
@m0tzy m0tzy marked this pull request as draft June 4, 2026 21:29
@m0tzy m0tzy marked this pull request as ready for review June 4, 2026 21:29
@m0tzy m0tzy force-pushed the madison/id-jag-step-up branch from f399c1f to 8df507f Compare June 4, 2026 21:29
m0tzy and others added 2 commits June 4, 2026 14:48
…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>
Comment thread agent-services/README.md Outdated
Comment thread agent-services/README.md Outdated
Comment thread agent-services/README.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
m0tzy and others added 6 commits June 5, 2026 09:18
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>
m0tzy added a commit that referenced this pull request Jun 5, 2026
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>
@m0tzy m0tzy merged commit 0d4b612 into main Jun 5, 2026
2 checks passed
@m0tzy m0tzy deleted the madison/id-jag-step-up branch June 5, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants