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
64 changes: 62 additions & 2 deletions AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,15 +262,21 @@ The end goal: get a signed-in user to confirm a 6-digit `user_code` **you supply

For **service_auth** 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:

- **login_hint shape (`type: "login_hint"`)** — start a user_code ceremony for the user identified by the login_hint. 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.

login_hint shape:

```http
POST /agent/identity/claim
Content-Type: application/json

{
"type": "login_hint",
"claim_token": "clm_...",
"email": "user@example.com"
"login_hint": "user@example.com"
}
```

Expand All @@ -293,6 +299,60 @@ Response (200):

The `claim_attempt` block here — same shape as the `claim` block in the `service_auth` 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.7.0 (2026-06-12)

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 `login_hint`-shape 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 `login_hint` 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`. The previous `{ claim_token, login_hint }` shape is now `{ "type": "login_hint", claim_token, login_hint }`; existing callers must add the `"type": "login_hint"` discriminator.

## v0.6.0 (2026-06-10)

Splits the email-based registration path out from `identity_assertion` and into a top-level `service_auth` registration type, with a body modeled on [OIDC CIBA](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html)'s `login_hint`. The previous shape was honest about how it worked — the service was verifying the email, not the agent — but it was filed under `identity_assertion` like the agent was asserting something. CIBA's vocabulary fits: the agent is hinting at who the user is, and the service authenticates the user out-of-band.
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,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: login_hint, claim_token, login_hint }
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 @@ -165,3 +165,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
```
62 changes: 59 additions & 3 deletions agent-services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,16 +473,22 @@ 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: "login_hint"` — 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.

login_hint shape:

```json
{
"type": "login_hint",
"claim_token": "clm_abc123...",
"email": "user@example.com"
"login_hint": "user@example.com"
}
```

The `email` binds the registration to the human the agent is acting for. Only that signed-in user can complete the ceremony — without this binding, a third party who intercepts the `user_code` could claim the agent on their own account.
The `login_hint` binds the registration to the human the agent is acting for. Only that signed-in user can complete the ceremony — without this binding, a third party who intercepts the `user_code` could claim the agent on their own account.

Response (200):

Expand Down Expand Up @@ -518,6 +524,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
Loading