Invert the claim flow to mirror device auth#10
Conversation
c904839 to
0bd206b
Compare
0bd206b to
ef3d317
Compare
ef3d317 to
56f73bb
Compare
|
@greptileai review |
Greptile SummaryThis PR inverts the agent-auth claim ceremony from a service-emailed OTP to an RFC 8628-shaped device-authorization flow: the agent now receives
Confidence Score: 5/5Safe 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
|
3d60a1d to
96bee48
Compare
|
@greptile |
339a42e to
239cb14
Compare
239cb14 to
fd36854
Compare
|
@greptile |
- 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>
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.
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>
- 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>
fd36854 to
f3bb938
Compare
| } | ||
| } | ||
|
|
||
| function renderClaimPage(input: { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
This is worth doing but I'll open another PR for it
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>
- 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>
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>
Stack
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_codeat registration time and surfaces it to the user along with averification_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/tokenwith 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 standardtoken_endpointwith a profile-specific grant URN (urn:workos:agent-auth:grant-type:claim) — not the IANAdevice_codegrant — so it doesn't collide with services that also implement standard RFC 8628 device authorization at the same endpoint. The AS routes bygrant_type, with zero risk of a claim_token being looked up in a device-auth store or vice versa.Agent surface
claimfor email-verification (returned with the registration itself) or underclaim_attemptfor anonymous (returned from/agent/identity/claim). Both carryuser_code,verification_uri,expires_in,interval./oauth2/tokengains a second grant:urn:workos:agent-auth:grant-type:claimtakes theclaim_token, returnsauthorization_pendingwhile waiting,expired_tokenon closed window, and a standard OAuth token response on completion. Success extends the standard response withidentity_assertion+assertion_expiresso the agent has a refresh path via the JWT-bearer grant once the access_token expires.grant_types_supportedlists both grants.claim_attempt_token(inverification_uri) binds the URL to a specific registration without leaking the user-typeduser_code.New user-facing surface (service-owned)
/loginroute — mock IdP, cookie-bound session./claimroute — cookie-gated form where the user types theuser_code.POST /agent/identity/claim/completeis the form-action endpoint for/claim— the agent no longer calls it.Security: binding the ceremony to a specific user
emailparameter on anonymousPOST /agent/identity/claimbinds the registration to that user — only that signed-in account can complete the ceremony, preventing third-party interception of theuser_code.claim_emailis set, regardless of registration kind — covers both email-verification (asserted by the agent at registration) and anonymous (provided by the agent at/claim).Removed
/agent/identity/claim/attempt/challenge—user_codeis minted at ceremony start now, no separate mint step./agent/identity/claim/view— polling moved to/oauth2/tokenwith 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
otp_*fields onRegistrationClaimAttempt→user_code_*.completeClaimtakessignedInUser: Userinstead of looking up by email.Sessiontype +createSession/findSession/destroySessionhelpers for the cookie-bound/claimform.AUTH.md,README.md,agent-services/README.md) and the service demo (routes/home.ts) rewritten end to end.