diff --git a/AUTH.md b/AUTH.md index 6d3a35c..64837fa 100644 --- a/AUTH.md +++ b/AUTH.md @@ -264,13 +264,19 @@ The end goal: get a signed-in user to confirm a 6-digit `user_code` **you supply For **email-verification** registrations, you already have them — they're in the `claim` block of the Step 3 response. Skip to 4b. -For **anonymous** registrations, ask the service to start a ceremony: +For **anonymous** registrations, you have two options: + +- **Email shape (`type: "email"`)** — start a user_code ceremony for the email you provide. Default path; works when the agent has no provider identity. +- **ID-JAG shape (`type: "identity_assertion"`)** — if you later acquire an ID-JAG, you can finish the claim straight away and skip the user_code ceremony. See [4a-alt](#4a-alt-claim-via-id-jag) below. + +Email shape: ```http POST /agent/identity/claim Content-Type: application/json { + "type": "email", "claim_token": "clm_...", "email": "user@example.com" } @@ -295,6 +301,60 @@ Response (200): The `claim_attempt` block here — same shape as the `claim` block in the email-verification registration response — borrows from [RFC 8628 device-authorization](https://datatracker.ietf.org/doc/html/rfc8628), with `claim_attempt_token` embedded in `verification_uri` so the URL identifies the registration without leaking the user-typed `user_code`. Surface `verification_uri` + `user_code` to the user; poll the standard `token_endpoint` from AS metadata with the claim grant (see 4c). +### 4a-alt. Claim via ID-JAG + +If you started anonymous, the user wants to claim the registration, and you can obtain an ID-JAG for them, you can bind the existing registration to that identity instead of re-registering: + +```http +POST /agent/identity/claim +Content-Type: application/json + +{ + "type": "identity_assertion", + "claim_token": "clm_...", + "assertion": "" +} +``` + +Two success shapes: + +**No confirmation needed (200)** — the ID-JAG is enough on its own, either because there's already an `(iss, sub)` delegation for this user or because the ID-JAG's email doesn't conflict with another account at the service. The registration is bound right away: + +```json +{ + "registration_id": "reg_...", + "registration_type": "identity_assertion", + "status": "claimed", + "identity_assertion": "", + "assertion_expires": "2026-05-21T18:31:25.994Z" +} +``` + +Skip to [Step 5](#step-5--exchange-the-assertion) with the new `identity_assertion`. + +**Confirmation required (200)** — the ID-JAG's verified email matches an existing different account at the service and no `(iss, sub)` delegation exists yet. The service won't silently bind the delegation; surface the returned `claim_attempt` block to the user and poll `/oauth2/token` exactly as in the user-code claim flow ([Step 4b](#4b-hand-off-to-the-user) and [Step 4c](#4c-poll-for-completion)). The user signs in, confirms linking the provider identity to their account, and the next poll resolves to a post-claim access_token plus a v2 `identity_assertion`: + +```json +{ + "registration_id": "reg_...", + "registration_type": "identity_assertion", + "claim_attempt_id": "cla_...", + "status": "initiated", + "expires_at": "...", + "claim_attempt": { + "user_code": "123456", + "verification_uri": "https://auth.service.example.com/claim?claim_attempt_token=...", + "expires_in": 600, + "interval": 5 + } +} +``` + +Failures: + +- **`login_required` (401)** — the ID-JAG's `auth_time` is missing or stale. Re-authenticate at your provider and retry. +- **`invalid_grant`** / other ID-JAG verification errors (400) — fix the ID-JAG (fresh `jti`, correct `aud`, etc.) and retry. + The `email` you supply on anonymous `/claim` binds the registration to the human you intend the agent to act on behalf of — only that signed-in user can complete the ceremony. Without this, a third party who intercepted the `user_code` could claim the agent for themselves. ### 4b. Hand off to the user diff --git a/CHANGELOG.md b/CHANGELOG.md index 4134a86..dab09ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # auth.md Changelog +## v0.6.0 (2026-06-08) + +Adds a second body shape to `POST /agent/identity/claim`. An agent that started anonymous can now claim its registration by presenting an ID-JAG: if the ID-JAG is enough on its own, the claim completes right there; if it isn't (the ID-JAG's email matches a different existing account), the response falls back to the user_code ceremony so the user can confirm. Either way, `registration_id` and the pre-claim credentials stay intact — no re-registration needed. + +### Added + +- `POST /agent/identity/claim` accepts `{ type: "identity_assertion", claim_token, assertion }` as an alternative to the existing `{ type: "email", claim_token, email }` body. The body shape is now a discriminated union on `type`. +- Clean-match response (200) — `{ status: "claimed", identity_assertion, assertion_expires }`. The agent skips polling and goes straight to `/oauth2/token` (jwt-bearer) with the new assertion. +- Step-up response (200) — `{ status: "initiated", claim_attempt: { user_code, verification_uri, expires_in, interval } }`. Same ceremony shape as the email body. The agent surfaces the code, the user confirms at `/claim`, and `/oauth2/token` (claim grant) yields the post-claim access_token. + +### Changed + +- `/agent/identity/claim` body is now a discriminated union on `type`; existing callers using the email shape need to add `"type": "email"`. + ## v0.5.0 (2026-06-05) Gates first-time linking of an ID-JAG to an existing account behind a user-confirmation ceremony, and requires fresh `auth_time` on every ID-JAG. Without this confirmation gate, any trusted provider could mint an ID-JAG with `email_verified: true` for a victim's email and silently take over their account. Without the freshness gate, an agent could use a stale upstream session. diff --git a/README.md b/README.md index a23b1a9..c5a7b86 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ sequenceDiagram Note over Agent: Agent operates with pre-claim scopes User-->>Agent: Wants to take ownership - Agent->>Service: POST /agent/identity/claim
{ claim_token, email } + Agent->>Service: POST /agent/identity/claim
{ type: email, claim_token, email } Service-->>Agent: 200 OK (claim_attempt: user_code + verification_uri) Agent-->>User: Surface user_code + verification_uri User->>Service: GET verification_uri (signs in, lands on /claim) @@ -166,3 +166,42 @@ sequenceDiagram end end ``` + +### Anonymous Registration Claimed via ID-JAG + +If the agent started anonymous, the user wants to claim the registration, and the agent can obtain an ID-JAG, it can bind the registration to that identity by presenting the ID-JAG at `/agent/identity/claim` with `type: identity_assertion`. Two branches: + +```mermaid +sequenceDiagram + actor User + participant Agent + participant Provider as Agent Provider + participant Service + + Agent->>Service: POST /agent/identity
{ type: anonymous } + Service-->>Agent: 200 OK (identity_assertion v1, claim_token) + + Note over Agent: Agent operates pre-claim
(may exchange v1 for a pre-claim access_token) + + User-->>Agent: Signs in at provider + Agent->>Provider: Request audience-specific ID-JAG + Provider-->>Agent: 200 OK (ID-JAG) + + Agent->>Service: POST /agent/identity/claim
{ type: identity_assertion, claim_token, assertion: ID-JAG } + Service->>Service: Verify ID-JAG + auth_time + matcher + + alt No confirmation needed (no email conflict, or existing (iss, sub) delegation) + Service-->>Agent: 200 OK (status: claimed, identity_assertion v2) + Note over Agent: Pre-claim access_token revoked.
Agent exchanges v2 at /oauth2/token
via jwt-bearer for a fresh credential. + else Confirmation required (ID-JAG email matches an existing different account, no delegation) + Service-->>Agent: 200 OK (claim_attempt: user_code + verification_uri) + Agent-->>User: Surface user_code + verification_uri + User->>Service: GET verification_uri (signs in, lands on /claim) + User->>Service: POST /agent/identity/claim/complete
{ claim_attempt_token, user_code } + Note over Service: Completion binds the anonymous reg
to the signed-in user AND records
the (iss, sub) delegation. + loop until claimed + Agent->>Service: POST /oauth2/token
grant_type=claim&claim_token=... + Service-->>Agent: 200 OK (access_token + v2 identity_assertion) | authorization_pending + end + end +``` diff --git a/agent-services/README.md b/agent-services/README.md index 2f7647d..e42dbbb 100644 --- a/agent-services/README.md +++ b/agent-services/README.md @@ -475,10 +475,16 @@ The `verification_uri` routes through `/login` first so the user authenticates b Anonymous-only. Verified-email registrations skip this — their `claim` block is bundled into the `/agent/identity` registration response. -Request: +Two shapes, discriminated on `type`: + +- `type: "email"` — start a user_code ceremony (default path; covered below). +- `type: "identity_assertion"` — agent acquired an ID-JAG and wants to skip the ceremony; see [Claim via ID-JAG](#claim-via-id-jag) below. + +Email shape: ```json { + "type": "email", "claim_token": "clm_abc123...", "email": "user@example.com" } @@ -520,6 +526,56 @@ The user opens `verification_uri`, signs in to the service, and lands on a page This is a service-owned UX surface — agents never see it. +#### Claim via ID-JAG + +If the agent started anonymous, the user wants to claim the registration, and the agent can obtain an ID-JAG for them, it can finish the claim in one shot instead of running the user through the user_code ceremony. Same `/agent/identity/claim` endpoint, different `type`: + +```json +{ + "type": "identity_assertion", + "claim_token": "clm_abc123...", + "assertion": "" +} +``` + +Implementation: + +1. Hash the `claim_token` and look up the registration. Reject if not found, expired, or already claimed; reject if `kind !== "anonymous"` with `claimed_or_in_flight`. +2. Verify the ID-JAG the same way `/agent/identity` does — signature, audience, replay, `auth_time` freshness. `auth_time_*` errors become `401 login_required` so the agent knows to refresh upstream (same as the `/agent/identity` path). +3. Run the matcher. Two terminal shapes from here: + - **Clean match** (no email conflict, or the existing `(iss, sub)` delegation matches): bind the anonymous registration to the resolved user (`user_id`, `claimed_at`), record the ID-JAG triple as `id_jag = { iss, sub, aud }`, call `upsertDelegation`, revoke pre-claim access_tokens (same as user_code completion), and return a v2 `identity_assertion`: + + ```json + { + "registration_id": "reg_...", + "registration_type": "identity_assertion", + "status": "claimed", + "identity_assertion": "", + "assertion_expires": "2026-05-04T13:00:00.000Z" + } + ``` + + The agent exchanges the v2 assertion at `/oauth2/token` (jwt-bearer) for a post-claim access_token. Same shape as the user_code ceremony's terminal state — the only difference is no polling. + - **Step-up required** (`matcher.kind === "step_up_required"` — ID-JAG's verified email matches an existing different user, no `(iss, sub)` delegation): mint a fresh `claim_attempt` on the anonymous registration with the matched email and the `id_jag = { iss, sub, aud }` triple, and return **200 with the `claim_attempt` block** — the same shape `/agent/identity/claim` returns for the email-shape body. The agent surfaces `user_code` + `verification_uri` to the user; the user confirms at `/claim`, which binds the anonymous registration to the user AND records the `(iss, sub)` delegation in one shot. The agent then polls `/oauth2/token` (claim grant) for the post-claim access_token, same as the email-shape flow. + + ```json + { + "registration_id": "reg_...", + "registration_type": "identity_assertion", + "claim_attempt_id": "cla_...", + "status": "initiated", + "expires_at": "...", + "claim_attempt": { + "user_code": "123456", + "verification_uri": "https://auth.service.example.com/claim?claim_attempt_token=...", + "expires_in": 600, + "interval": 5 + } + } + ``` + +**Why anonymous-only.** Email-verification registrations have already asserted an email and started a ceremony for that email. Replacing that with a different ID-JAG identity is ambiguous (which identity wins?) and not worth the complexity — agents that started email-verification but later got an ID-JAG can just re-register at `/agent/identity` with the ID-JAG directly. + #### POST /oauth2/token (claim grant) — Agent poll Polling happens at the standard `token_endpoint` with a profile-specific grant. Form-encoded, as with the JWT-bearer grant: diff --git a/agent-services/src/routes/agent-auth.ts b/agent-services/src/routes/agent-auth.ts index bc6d13f..91e95bb 100644 --- a/agent-services/src/routes/agent-auth.ts +++ b/agent-services/src/routes/agent-auth.ts @@ -9,6 +9,7 @@ import { } from "../schemas.js"; import { type Registration, + completeAnonymousClaimViaIdJag, createAnonymousRegistration, createEmailVerificationRegistration, findOrCreateIdJagRegistration, @@ -247,13 +248,6 @@ async function handleEmailAssertion( }); } -/* - * Initiates or re-mints a claim ceremony. Two registration kinds reach here: - * - anonymous: first initiation (binds the email) or refresh (after the - * user_code window closed before the user could complete). - * - email_verification: refresh only (the initial ceremony was minted at - * /agent/identity); the supplied email must match the registration. - */ agentAuthRouter.post(config.claimEndpointPath, async (req, res) => { const parsed = parseBody(claimBody, req.body); if (!parsed.ok) { @@ -283,13 +277,28 @@ agentAuthRouter.post(config.claimEndpointPath, async (req, res) => { }); return; } + if (parsed.value.type === "identity_assertion") { + /* Only anonymous registrations claim atomically via ID-JAG. */ + if (registration.kind !== "anonymous") { + res.status(409).json({ + error: "claimed_or_in_flight", + message: + "ID-JAG claim is only supported for anonymous registrations. Use the email-shape body to refresh the ceremony.", + }); + return; + } + return handleAnonymousClaimViaIdJag( + registration, + parsed.value.assertion, + res, + ); + } + /* - * Email is immutable once bound. For email_verification it's bound at - * registration time (from the agent's identity claim). For anonymous - * it's bound on the first /claim call; subsequent re-initiations must - * supply the same email — otherwise an agent could redirect the - * ceremony from the originally-bound user to a different account, - * defeating the wrong-account check in /claim. + * Once an email is bound to a registration, it stays bound. Without + * this check, an agent — or anyone who got hold of the user_code — + * could call /claim again with a different email and redirect the + * ceremony onto a different account. */ if ( registration.claim?.email && @@ -329,6 +338,112 @@ agentAuthRouter.post(config.claimEndpointPath, async (req, res) => { }); }); +/* + * Verifies the ID-JAG and finishes the claim straight away if it can. + * If the ID-JAG isn't enough on its own, falls back to a user_code + * ceremony so the user can confirm. Response shapes are in AUTH.md. + */ +async function handleAnonymousClaimViaIdJag( + registration: Registration, + assertion: string, + res: express.Response, +): Promise { + const verified = await verifyIdJag(assertion); + if (!verified.ok) { + return handleIdJagVerifyError(verified.error, res); + } + const { claims } = verified; + const match = matchOrProvision(claims); + + /* + * Same rule as the email path: if a registration already has an + * email bound, the ID-JAG has to resolve to that same user. Otherwise + * an agent could send an ID-JAG for someone else and slip past the + * email check entirely. + */ + const boundEmail = registration.claim?.email; + const incomingEmail = + match.kind === "step_up_required" + ? match.matched_user.email + : match.user.email; + if (boundEmail && boundEmail.toLowerCase() !== incomingEmail.toLowerCase()) { + res.status(400).json({ + error: "email_mismatch", + message: + "The ID-JAG resolves to a different user than the registration's bound email.", + }); + return; + } + + if (match.kind === "step_up_required") { + /* + * Bind the ID-JAG triple onto the registration alongside the + * ceremony so completeClaim upserts the (iss, sub) delegation when + * the user confirms. + */ + const fresh = recordClaimAttempt( + registration, + match.matched_user.email, + { iss: claims.iss, sub: claims.sub, aud: claims.aud }, + ); + const attempt = registration.claim!.attempt!; + console.log( + `[agent-auth] anon claim via ID-JAG requires step-up: registration=${registration.id} iss=${claims.iss} sub=${claims.sub}`, + ); + res.json({ + registration_id: registration.id, + registration_type: "identity_assertion", + claim_attempt_id: attempt.id, + status: "initiated", + expires_at: attempt.view_expires_at.toISOString(), + claim_attempt: buildCeremonyBlock({ + claimViewTokenPlaintext: fresh.claimViewTokenPlaintext, + userCode: fresh.userCode, + userCodeExpiresAt: fresh.userCodeExpiresAt, + }), + }); + return; + } + + const result = completeAnonymousClaimViaIdJag( + registration, + { iss: claims.iss, sub: claims.sub, aud: claims.aud }, + match.user, + ); + if (!result.ok) { + /* + * Defensive: the route-level checks above already rule these out, but + * surface store-level errors with the right HTTP shape if reached. + */ + const status = + result.error === "previously_claimed" || + result.error === "ceremony_in_flight" + ? 409 + : result.error === "claim_expired" + ? 410 + : 400; + res.status(status).json({ error: result.error }); + return; + } + + const { jwt, expiresAt } = await signServiceIdJag({ + registration: result.registration, + email: claims.email, + emailVerified: claims.email_verified, + amr: claims.amr, + }); + console.log( + `[agent-auth] anonymous registration=${result.registration.id} claimed via ID-JAG for user=${result.user.id} iss=${claims.iss} sub=${claims.sub}`, + ); + res.json({ + registration_id: result.registration.id, + registration_type: "identity_assertion", + status: "claimed", + identity_assertion: jwt, + assertion_expires: expiresAt.toISOString(), + }); +} + /* * Polling moved to /oauth2/token with grant_type=urn:workos:agent-auth: * grant-type:claim. The agent posts its claim_token there; while pending diff --git a/agent-services/src/routes/home.ts b/agent-services/src/routes/home.ts index 7008d0c..18e4143 100644 --- a/agent-services/src/routes/home.ts +++ b/agent-services/src/routes/home.ts @@ -341,6 +341,7 @@ function updateAnonExchangePreview() { } function updateAnonClaimPreview() { const body = { + type: "email", claim_token: abbrev(state.anon_claim_token), email: document.getElementById("anon-claim-email").value, }; @@ -473,6 +474,7 @@ async function anonCallPre() { async function anonClaim() { const body = { + type: "email", claim_token: state.anon_claim_token, email: document.getElementById("anon-claim-email").value, }; diff --git a/agent-services/src/schemas.ts b/agent-services/src/schemas.ts index 5b837a8..5931fed 100644 --- a/agent-services/src/schemas.ts +++ b/agent-services/src/schemas.ts @@ -25,11 +25,24 @@ export const agentAuthBody = z.union([ anonymousBody, ]); -export const claimBody = z.object({ +/* POST /agent/identity/claim — discriminated on `type`. */ +const emailClaimBody = z.object({ + type: z.literal("email"), claim_token: z.string().min(1), email: z.email(), }); +const idJagClaimBody = z.object({ + type: z.literal("identity_assertion"), + claim_token: z.string().min(1), + assertion: z.string().min(1), +}); + +export const claimBody = z.discriminatedUnion("type", [ + emailClaimBody, + idJagClaimBody, +]); + /** Mock IdP sign-in form. */ export const loginFormBody = z.object({ email: z.email(), diff --git a/agent-services/src/store.ts b/agent-services/src/store.ts index 8f9973d..79c4680 100644 --- a/agent-services/src/store.ts +++ b/agent-services/src/store.ts @@ -507,6 +507,8 @@ export function findRegistrationByClaimViewHash( export function recordClaimAttempt( registration: Registration, email: string, + /* When set, completeClaim upserts the (iss, sub) → user delegation. */ + idJag?: { iss: string; sub: string; aud: string }, ): { claimViewTokenPlaintext: string; userCode: string; @@ -529,6 +531,7 @@ export function recordClaimAttempt( user_code_generated_at: now, user_code_expires_at: code.expiresAt, }; + if (idJag) registration.id_jag = idJag; return { claimViewTokenPlaintext: plaintext, userCode: code.plaintext, @@ -614,11 +617,10 @@ export function completeClaim( } } - if (registration.kind === "id_jag" && registration.id_jag) { - /* - * Step-up complete: bind the (iss, sub) → user delegation so future - * ID-JAGs from this provider for this sub take the clean-match path. - */ + if (registration.id_jag) { + /* Remember the (iss, sub) → user mapping so the next ID-JAG from + * this provider for this sub goes straight through without a + * ceremony. */ upsertDelegation( registration.id_jag.iss, registration.id_jag.sub, @@ -628,3 +630,57 @@ export function completeClaim( return { ok: true, registration, user: signedInUser }; } + +export type IdJagClaimResult = + | { ok: true; registration: Registration; user: User } + | { + ok: false; + error: + | "previously_claimed" + | "claim_expired" + | "wrong_kind" + | "ceremony_in_flight"; + }; + +/* + * Closes out a claim in one shot using an ID-JAG, no user_code + * ceremony needed. Binds the registration to the ID-JAG's user, + * records the (iss, sub, aud) as a delegation, and revokes any + * pre-claim access_tokens. The caller mints a fresh identity_assertion + * off the returned registration. + */ +export function completeAnonymousClaimViaIdJag( + registration: Registration, + idJag: { iss: string; sub: string; aud: string }, + user: User, +): IdJagClaimResult { + if (registration.kind !== "anonymous") { + return { ok: false, error: "wrong_kind" }; + } + if (registration.status === "claimed") { + return { ok: false, error: "previously_claimed" }; + } + if (registration.status === "expired") { + return { ok: false, error: "claim_expired" }; + } + /* There's already a user_code ceremony in flight — the user might + * be looking at the /claim page right now. Let that finish (or time + * out) before the ID-JAG path takes over. */ + if (registration.status === "pending_claim") { + return { ok: false, error: "ceremony_in_flight" }; + } + + registration.user_id = user.id; + registration.claimed_at = new Date(); + registration.id_jag = idJag; + upsertDelegation(idJag.iss, idJag.sub, user.id); + + /* Revoke pre-claim access_tokens — same as the user_code path. */ + for (const cred of credentials.values()) { + if (cred.registration_id === registration.id && !cred.revoked) { + cred.revoked = true; + } + } + + return { ok: true, registration, user }; +} diff --git a/package.json b/package.json index 36db3b2..fcdcb26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auth-md", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "dev": "pnpm --filter shared build && concurrently --names agent-provider,agent-service --prefix-colors blue,magenta \"pnpm --filter agent-providers dev\" \"pnpm --filter agent-services dev\"",