Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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": "<your ID-JAG JWT>"
}
```

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": "<service-signed JWT>",
"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
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>{ claim_token, email }
Agent->>Service: POST /agent/identity/claim<br/>{ 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)
Expand All @@ -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<br/>{ type: anonymous }
Service-->>Agent: 200 OK (identity_assertion v1, claim_token)

Note over Agent: Agent operates pre-claim<br/>(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<br/>{ 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.<br/>Agent exchanges v2 at /oauth2/token<br/>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<br/>{ claim_attempt_token, user_code }
Note over Service: Completion binds the anonymous reg<br/>to the signed-in user AND records<br/>the (iss, sub) delegation.
loop until claimed
Agent->>Service: POST /oauth2/token<br/>grant_type=claim&claim_token=...
Service-->>Agent: 200 OK (access_token + v2 identity_assertion) | authorization_pending
end
end
```
58 changes: 57 additions & 1 deletion agent-services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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": "<ID-JAG JWT>"
}
```

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": "<service-signed JWT>",
"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:
Expand Down
141 changes: 128 additions & 13 deletions agent-services/src/routes/agent-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "../schemas.js";
import {
type Registration,
completeAnonymousClaimViaIdJag,
createAnonymousRegistration,
createEmailVerificationRegistration,
findOrCreateIdJagRegistration,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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<void> {
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,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
);
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
Expand Down
Loading