Skip to content

Invert the claim flow to mirror device auth#10

Merged
m0tzy merged 10 commits into
mainfrom
madison/swap-claim-flow
Jun 4, 2026
Merged

Invert the claim flow to mirror device auth#10
m0tzy merged 10 commits into
mainfrom
madison/swap-claim-flow

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 ← you are here
  4. ID-JAG step-up + auth_time freshness #11
  5. Claim anonymous registration via ID-JAG #12

Summary

Invert the claim ceremony and consolidate polling onto /oauth2/token.

Before: service emails the user a 6-digit OTP each time, user reads it back to the agent, agent submits. Each ceremony required a fresh email round-trip and gave us no leverage over any existing user session at the service.

After: agent gets the user_code at registration time and surfaces it to the user along with a verification_uri. The user signs in to the service in their browser (which can reuse existing session, SSO, whatever the service normally does) and types the code on a service-owned page. The agent polls /oauth2/token with a profile-specific claim grant for completion.

Re-authentication now uses the service's own browser-based sign-in surface — the channel users already trust, and where the service can apply whatever step-up / SSO / MFA policy it wants — instead of a code emailed out of band each ceremony. Eliminating the email channel also closes the phishing-by-agent vector where a malicious agent could intercept a code the user fetched from their inbox.

The new ceremony block borrows from RFC 8628 device-authorization (user_code, verification_uri, expires_in, interval). Polling happens at the standard token_endpoint with a profile-specific grant URN (urn:workos:agent-auth:grant-type:claim) — not the IANA device_code grant — so it doesn't collide with services that also implement standard RFC 8628 device authorization at the same endpoint. The AS routes by grant_type, with zero risk of a claim_token being looked up in a device-auth store or vice versa.

Agent surface

  • Registration responses include a ceremony block — under claim for email-verification (returned with the registration itself) or under claim_attempt for anonymous (returned from /agent/identity/claim). Both carry user_code, verification_uri, expires_in, interval.
  • /oauth2/token gains a second grant: urn:workos:agent-auth:grant-type:claim takes the claim_token, returns authorization_pending while waiting, expired_token on closed window, and a standard OAuth token response on completion. Success extends the standard response with identity_assertion + assertion_expires so the agent has a refresh path via the JWT-bearer grant once the access_token expires.
  • Discovery's grant_types_supported lists both grants.
  • claim_attempt_token (in verification_uri) binds the URL to a specific registration without leaking the user-typed user_code.

New user-facing surface (service-owned)

  • New /login route — mock IdP, cookie-bound session.
  • New /claim route — cookie-gated form where the user types the user_code.
  • POST /agent/identity/claim/complete is the form-action endpoint for /claim — the agent no longer calls it.

Security: binding the ceremony to a specific user

  • The email parameter on anonymous POST /agent/identity/claim binds the registration to that user — only that signed-in account can complete the ceremony, preventing third-party interception of the user_code.
  • The wrong-account check fires whenever claim_email is set, regardless of registration kind — covers both email-verification (asserted by the agent at registration) and anonymous (provided by the agent at /claim).
  • Anonymous: completing the ceremony revokes any pre-claim access_tokens. The canonical credential is the one returned by the claim grant.

Removed

  • /agent/identity/claim/attempt/challengeuser_code is minted at ceremony start now, no separate mint step.
  • /agent/identity/claim/view — polling moved to /oauth2/token with the claim grant.
  • mail.ts, routes/mail.ts, and the .mail/ directory — the agent already has the verification URL + user_code, so an out-of-band email channel adds nothing and is the part most vulnerable to phishing-by-agent.

Internal

  • Store: rename otp_* fields on RegistrationClaimAttemptuser_code_*. completeClaim takes signedInUser: User instead of looking up by email.
  • New Session type + createSession / findSession / destroySession helpers for the cookie-bound /claim form.
  • Docs (AUTH.md, README.md, agent-services/README.md) and the service demo (routes/home.ts) rewritten end to end.

@m0tzy m0tzy changed the title Swap claim flow to RFC 8628 user_code handoff Invert the claim flow to reuse browser auth instead of emailing an OTP Jun 3, 2026
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from c904839 to 0bd206b Compare June 3, 2026 01:40
@m0tzy m0tzy changed the title Invert the claim flow to reuse browser auth instead of emailing an OTP Invert the claim flow + consolidate polling onto /oauth2/token Jun 3, 2026
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from 0bd206b to ef3d317 Compare June 3, 2026 02:15
@m0tzy m0tzy mentioned this pull request Jun 3, 2026
1 task
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from ef3d317 to 56f73bb Compare June 3, 2026 20:41
@m0tzy m0tzy force-pushed the madison/secevent branch from 0951faa to 48282c0 Compare June 3, 2026 20:41
@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 inverts the agent-auth claim ceremony from a service-emailed OTP to an RFC 8628-shaped device-authorization flow: the agent now receives user_code + verification_uri at ceremony start, the user signs in on a service-owned browser page and types the code, and the agent polls /oauth2/token with a custom urn:workos:agent-auth:grant-type:claim grant until the ceremony completes.

  • New claim ceremony flow: registration responses carry a ceremony block (user_code, verification_uri, expires_in, interval); /oauth2/token gains the claim grant that returns authorization_pending / expired_token / a standard token response with identity_assertion extension.
  • New service-owned browser surface: /login (mock IdP, cookie-bound session) and /claim (form-action endpoint); the agent never calls these — it only polls the token endpoint.
  • Security hardening: email binding is now immutable after first /agent/identity/claim call, preventing ceremony redirection to a different account on re-initiation; anonymous pre-claim credentials are revoked on successful claim; the email/OTP channel (phishing vector) is removed entirely.

Confidence Score: 5/5

Safe to merge; the core claim ceremony logic, email-immutability guard, and expired user_code check are all correct.

The ceremony inversion is well-structured: the store correctly handles email binding, user_code expiry, and pre-claim credential revocation. The token endpoint correctly returns expired_token when the user_code window closes and authorization_pending while waiting. The only new issue introduced is a hardcoded session-secret fallback in config.ts, which is a demo-only concern explicitly called out in comments.

agent-services/src/config.ts — the hardcoded SESSION_SECRET fallback should be reviewed before any non-demo deployment.

Important Files Changed

Filename Overview
agent-services/src/routes/token.ts Adds the claim-grant handler (RFC 8628-shaped polling) alongside the existing JWT-bearer grant; dispatches on grant_type, includes expired user_code check, and extends the success response with identity_assertion + assertion_expires.
agent-services/src/routes/claim.ts New user-facing claim form (GET + POST); cookie-gated via requireUser, validates claim_attempt_token via view_token_hash, enforces wrong-account check, and delegates to completeClaim in the store.
agent-services/src/routes/login.ts New mock IdP; sanitises return_to (same-origin only), auto-provisions unknown emails, and sets session.userId via express-session.
agent-services/src/routes/agent-auth.ts Stripped to registration and claim-initiation endpoints; adds email-immutability guard on re-initiation so the bound account cannot be redirected after first bind.
agent-services/src/store.ts Renames otp_* fields to user_code_*, changes completeClaim to accept a User directly, and revokes pre-claim credentials for anonymous registrations on successful claim.
agent-services/src/config.ts Adds session config (secret, TTL, poll interval); hardcoded "demo-secret-do-not-ship" fallback for SESSION_SECRET violates the no-hardcoded-secrets rule.
agent-services/src/index.ts Wires express-session and urlencoded body parser; session cookie correctly sets httpOnly, secure (conditional on NODE_ENV), and sameSite: lax.
agent-services/src/schemas.ts Replaces claimCompleteBody/generateOtpBody with claimFormBody + claimGrantBody; renames tokenEndpointBody to jwtBearerGrantBody; validates user_code as exactly 6 digits.
agent-services/src/routes/well-known.ts Adds urn:workos:agent-auth:grant-type:claim to grant_types_supported in the AS discovery document.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant Service as Service (/agent/identity, /oauth2/token)
    participant Browser as User Browser (/login, /claim)

    Agent->>Service: POST /agent/identity (anonymous or email)
    Service-->>Agent: "registration_id, claim_token, claim + {user_code, verification_uri, expires_in, interval}"

    Note over Agent,Browser: Agent surfaces user_code + verification_uri to user

    Agent->>Service: POST /agent/identity/claim (anonymous only, with email)
    Service-->>Agent: "claim_attempt {user_code, verification_uri, expires_in, interval}"

    loop Poll until complete
        Agent->>Service: "POST /oauth2/token (grant_type=urn:workos:agent-auth:grant-type:claim, claim_token)"
        Service-->>Agent: "{error: authorization_pending} or {error: expired_token}"
    end

    Browser->>Service: "GET /login?return_to=/claim?claim_attempt_token=..."
    Service-->>Browser: Login form
    Browser->>Service: POST /login (email)
    Service-->>Browser: "Redirect to /claim?claim_attempt_token=..."
    Browser->>Service: GET /claim (cookie-gated)
    Service-->>Browser: Claim form (user_code input)
    Browser->>Service: POST /agent/identity/claim/complete (user_code, claim_attempt_token)
    Service-->>Browser: All set confirmation

    Agent->>Service: "POST /oauth2/token (grant_type=claim, claim_token)"
    Service-->>Agent: access_token + identity_assertion (v2) + assertion_expires
Loading

Reviews (8): Last reviewed commit: "Swap bespoke session plumbing for expres..." | Re-trigger Greptile

Comment thread agent-services/src/routes/token.ts
Comment thread agent-services/src/routes/claim.ts Outdated
Comment thread agent-services/src/routes/login.ts Outdated
Comment thread agent-services/src/routes/token.ts
@m0tzy m0tzy force-pushed the madison/secevent branch from 983ace1 to 11ef73d Compare June 4, 2026 00:06
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch 3 times, most recently from 3d60a1d to 96bee48 Compare June 4, 2026 00:14
@m0tzy m0tzy changed the title Invert the claim flow + consolidate polling onto /oauth2/token Invert the claim flow to mirror device auth Jun 4, 2026
@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 00:34
Comment thread agent-services/src/routes/agent-auth.ts
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from 339a42e to 239cb14 Compare June 4, 2026 03:44
@m0tzy m0tzy marked this pull request as draft June 4, 2026 03:49
@m0tzy m0tzy marked this pull request as ready for review 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
Base automatically changed from madison/secevent to main June 4, 2026 03:51
@m0tzy
Copy link
Copy Markdown
Collaborator Author

m0tzy commented Jun 4, 2026

@greptile

m0tzy added a commit that referenced this pull request Jun 4, 2026
- AUTH.md, agent-services/README.md: the step-up branch of POST
  /agent/identity/claim (type: identity_assertion) returns 200 with a
  ceremony block, not 401 interaction_required as the docs claimed.
  Rewrote the section to show both terminal shapes (clean-match 200 +
  identity_assertion, step-up 200 + claim_attempt) accurately.
- agent-auth.ts: move the email-immutability check inside the email-
  type branch so parsed.value.email narrows correctly; the prior
  position type-erred against the new discriminated union. Rename the
  stale recordAnonymousClaimAttempt call to recordClaimAttempt (carried
  over from PR #10's rename). Update the handleAnonymousClaimViaIdJag
  docstring to describe both terminal shapes instead of the old "refuse
  step-up" wording.
- home.ts: claim preview body sent type: "user_code", which the
  discriminated union rejects. Change to type: "email".
- CHANGELOG.md: v0.6.0 entry for the new discriminated union body shape
  and its two terminal responses.
- package.json: 0.5.0 → 0.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
m0tzy added 2 commits June 3, 2026 21:17
Invert the claim ceremony and consolidate polling onto /oauth2/token.

Before: service emails the user a 6-digit OTP each time, user reads it back
to the agent, agent submits. Each ceremony required a fresh email round-trip
and gave us no leverage over any existing user session at the service.

After: agent gets the user_code at registration time and surfaces it to the
user along with a verification_uri. The user signs in to the service in
their browser (which can reuse existing session, SSO, whatever the service
normally does) and types the code on a service-owned page. The agent polls
/oauth2/token with a profile-specific claim grant for completion.

Re-authentication now uses the service's own browser-based sign-in surface
— the channel users already trust, and where the service can apply whatever
step-up / SSO / MFA policy it wants — instead of a code emailed out of band
each ceremony. Eliminating the email channel also closes the
phishing-by-agent vector where a malicious agent could intercept a code the
user fetched from their inbox.

The new ceremony block on the agent side borrows from RFC 8628
device-authorization (user_code, verification_uri, expires_in, interval).
Polling happens at the standard token_endpoint with a profile-specific
grant URN (urn:workos:agent-auth:grant-type:claim) so it doesn't collide
with services that also implement standard RFC 8628 device authorization
at the same endpoint — the AS routes by grant_type, with zero risk of a
claim_token being looked up in a device-auth store or vice versa.

Agent surface
- Registration responses include a ceremony block — claim for
  email-verification (returned with the registration itself) or
  claim_attempt for anonymous (returned from /agent/identity/claim). Both
  carry user_code, verification_uri, expires_in, interval.
- /oauth2/token gains a second grant: urn:workos:agent-auth:grant-type:claim
  takes the claim_token, returns authorization_pending while waiting,
  expired_token on closed window, and a standard OAuth token response on
  completion. Success extends the standard response with identity_assertion
  + assertion_expires so the agent has a refresh path via jwt-bearer.
- discovery's grant_types_supported lists both grants.
- claim_attempt_token (in verification_uri) binds the URL to a specific
  registration without leaking the user-typed user_code.

New user-facing surface (service-owned)
- /login mock IdP, cookie-bound session.
- /claim cookie-gated form where the user types the user_code.
- POST /agent/identity/claim/complete is the form-action endpoint for
  /claim — the agent no longer calls it.

Security: binding the ceremony to a specific user
- The email parameter on anonymous POST /agent/identity/claim binds the
  registration to that user — only that signed-in account can complete the
  ceremony, preventing third-party interception of the user_code.
- Wrong-account check fires whenever claim_email is set, regardless of
  registration kind — covers both email-verification (asserted by the agent
  at registration) and anonymous (provided by the agent at /claim).
- Anonymous: completing the ceremony revokes any pre-claim access_tokens.
  The canonical credential is the one returned by the claim grant.

Removed
- /agent/identity/claim/attempt/challenge — user_code is minted at ceremony
  start now, no separate mint step.
- /agent/identity/claim/view — polling moved to /oauth2/token with the
  claim grant.
- mail.ts, routes/mail.ts, and .mail/ — the agent already has the
  verification URL + user_code, so an out-of-band email channel adds
  nothing and is the part most vulnerable to phishing-by-agent.

Internal
- Store: rename otp_* fields on RegistrationClaimAttempt → user_code_*.
  completeClaim takes signedInUser: User instead of looking up by email.
- New Session type + createSession / findSession / destroySession helpers
  for the cookie-bound /claim form.
- Docs (AUTH.md, READMEs) and the service demo rewritten end to end.
m0tzy and others added 2 commits June 3, 2026 21:17
When the inner user_code window (10 min) closes before the user finishes
the ceremony, the agent's poll on /oauth2/token used to return
authorization_pending indefinitely until the outer claim window (24h)
also expired. Email-verification registrations had no recovery path at
all — they could only re-register.

- token.ts: claim grant returns expired_token when user_code_expires_at
  is past, telling the agent to re-call /agent/identity/claim for a
  fresh code.
- agent-auth.ts: /agent/identity/claim now accepts email-verification
  registrations too. The supplied email must match the bound email.
  Renamed recordAnonymousClaimAttempt to recordClaimAttempt since it
  now serves both kinds.
- login.ts: session cookie now sets secure: true outside development.
- token.ts: added a comment near the authorization_pending branch
  noting that a production AS should also issue slow_down on too-fast
  polling, with a sketch of how (last-poll timestamp per claim_token).
- claim.ts, store.ts: fix stale comments that still referenced the
  removed /agent/identity/claim/view endpoint.
- README.md sequence diagrams: add an alt-branch showing the
  expired_token → re-initiate → fresh user_code flow.
- AUTH.md: document the re-initiation procedure in Step 4 and update
  the error-table entry for expired_token.
- agent-services/README.md, agent-auth.ts: email-verif → email-verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /agent/identity/claim email-mismatch guard only fired for
email_verification registrations. Anonymous registrations let the agent
overwrite registration.claim.email on every retry, so the user_code
ceremony could be redirected from the originally-bound user (alice) to
any other account (mallory) — only mallory's session would pass the
wrong-account check in /claim, and alice could no longer complete the
ceremony with her original link.

Extend the guard to fire whenever registration.claim.email is already
set, regardless of kind. The first anonymous /claim call still binds
freely (claim.email is undefined); subsequent calls must match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m0tzy m0tzy marked this pull request as draft June 4, 2026 04:17
@m0tzy m0tzy marked this pull request as ready for review June 4, 2026 04:17
m0tzy added a commit that referenced this pull request Jun 4, 2026
- AUTH.md, agent-services/README.md: the step-up branch of POST
  /agent/identity/claim (type: identity_assertion) returns 200 with a
  ceremony block, not 401 interaction_required as the docs claimed.
  Rewrote the section to show both terminal shapes (clean-match 200 +
  identity_assertion, step-up 200 + claim_attempt) accurately.
- agent-auth.ts: move the email-immutability check inside the email-
  type branch so parsed.value.email narrows correctly; the prior
  position type-erred against the new discriminated union. Rename the
  stale recordAnonymousClaimAttempt call to recordClaimAttempt (carried
  over from PR #10's rename). Update the handleAnonymousClaimViaIdJag
  docstring to describe both terminal shapes instead of the old "refuse
  step-up" wording.
- home.ts: claim preview body sent type: "user_code", which the
  discriminated union rejects. Change to type: "email".
- CHANGELOG.md: v0.6.0 entry for the new discriminated union body shape
  and its two terminal responses.
- package.json: 0.5.0 → 0.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m0tzy m0tzy force-pushed the madison/swap-claim-flow branch from fd36854 to f3bb938 Compare June 4, 2026 04:17
}
}

function renderClaimPage(input: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fine as we were prototyping, but for the sake of readability, maybe time to bring in a real template engine?

(Doesn't need to be done here, but maybe in a future refactor).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is worth doing but I'll open another PR for it

Comment thread agent-services/src/store.ts Outdated
Comment thread agent-services/README.md Outdated
Comment thread AUTH.md Outdated
Comment thread AUTH.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread README.md Outdated
m0tzy and others added 6 commits June 4, 2026 12:44
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>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
The hand-rolled Session type + sessions Map + createSession / findSession /
destroySession trio in store.ts duplicated what express-session's default
MemoryStore already does. Replace it with the standard idiom: app-level
session middleware, req.session.userId reads/writes at the call sites,
req.session.destroy() for logout.

- store.ts: Session type, sessions Map, and the three helpers removed.
- index.ts: cookie-parser swapped for express-session, configured with
  the same TTL and cookie attributes (httpOnly, secure outside dev,
  sameSite=lax). MemoryStore default is fine for the demo; the README
  already calls out that production stores swap in.
- login.ts: req.session.userId = user.id replaces createSession +
  res.cookie(...); req.session.destroy() replaces destroySession +
  res.clearCookie. The readSessionCookie helper is gone.
- claim.ts: requireUser reads req.session.userId directly instead of
  pulling the cookie and looking it up in the sessions Map.
- config.ts: sessionCookieName dropped; sessionSecret added (sourced
  from env in production).
- types/express-session.d.ts: module augmentation declaring SessionData
  has a string userId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
m0tzy added a commit that referenced this pull request Jun 4, 2026
- AUTH.md, agent-services/README.md: the step-up branch of POST
  /agent/identity/claim (type: identity_assertion) returns 200 with a
  ceremony block, not 401 interaction_required as the docs claimed.
  Rewrote the section to show both terminal shapes (clean-match 200 +
  identity_assertion, step-up 200 + claim_attempt) accurately.
- agent-auth.ts: move the email-immutability check inside the email-
  type branch so parsed.value.email narrows correctly; the prior
  position type-erred against the new discriminated union. Rename the
  stale recordAnonymousClaimAttempt call to recordClaimAttempt (carried
  over from PR #10's rename). Update the handleAnonymousClaimViaIdJag
  docstring to describe both terminal shapes instead of the old "refuse
  step-up" wording.
- home.ts: claim preview body sent type: "user_code", which the
  discriminated union rejects. Change to type: "email".
- CHANGELOG.md: v0.6.0 entry for the new discriminated union body shape
  and its two terminal responses.
- package.json: 0.5.0 → 0.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m0tzy m0tzy merged commit 95f0b46 into main Jun 4, 2026
2 checks passed
@m0tzy m0tzy deleted the madison/swap-claim-flow branch June 4, 2026 20:37
@m0tzy m0tzy mentioned this pull request Jun 4, 2026
1 task
m0tzy added a commit that referenced this pull request Jun 5, 2026
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>
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