Skip to content
Merged
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
88 changes: 67 additions & 21 deletions AUTH.md

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
# auth.md Changelog

## 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.

### Added

- `interaction_required` (401) from `/agent/identity` when an ID-JAG matches an existing account by verified email/phone but no `(iss, sub)` delegation exists yet. Body carries an RFC 8628-shaped `claim` block (`user_code`, `verification_uri`, `expires_in`, `interval`); the agent surfaces the code and URL to the user, who signs in at the service and confirms the link.
- `login_required` (401) from `/agent/identity` when `auth_time` is missing, older than the service's `max_age`, or set unreasonably in the future. `WWW-Authenticate` carries `max_age`. The agent's recourse is to re-authenticate at the provider (`prompt=login` or equivalent).

### Changed

- ID-JAGs are now required to include a fresh `auth_time` claim. Tokens whose `auth_time` is missing, older than the service's `max_age` window, or further than the clock-skew tolerance in the future are rejected. This prevents use of a stale user session for authorization.

## v0.4.0 (2026-06-04)

Inverts the claim ceremony and consolidates polling onto the standard `/oauth2/token` endpoint. Service emails have been removed in favor of the agent surfacing the verification URL and `user_code` to the user, who signs in through the service's own browser-based session (reusing any existing session, SSO, MFA the service applies) and confirms the code on a service-owned page. This borrows the ceremony shape from [RFC 8628 device authorization](https://datatracker.ietf.org/doc/html/rfc8628) (`user_code`, `verification_uri`, `expires_in`, `interval`) without overloading the IANA `device_code` grant.

### Added

- `urn:workos:agent-auth:grant-type:claim` grant at `/oauth2/token` — the agent polls here with the `claim_token` for ceremony completion. Returns `authorization_pending` while waiting, `expired_token` once the window closes, and a standard OAuth token response on success, extended with `identity_assertion` + `assertion_expires` so the agent has a refresh path. A profile-specific URN so services that also implement standard RFC 8628 device authorization at the same endpoint don't collide.
- Registration responses now include a ceremony block — under `claim` for email-verification (returned with the registration) or under `claim_attempt` for anonymous (returned from `/agent/identity/claim`). Both carry `user_code`, `verification_uri`, `expires_in`, `interval`.
- Registration responses now include the ceremony fields (`user_code`, `verification_uri`, `expires_in`, `interval`) — under `claim` for email-verification (returned with the registration) or under `claim_attempt` for anonymous (returned from `/agent/identity/claim`).
- `/login` — service-owned mock IdP with a cookie-bound session.
- `/claim` — service-owned, cookie-gated form where the user types the `user_code` to complete the ceremony.

Expand Down
41 changes: 25 additions & 16 deletions agent-providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Discovery is two-hop:
"jti": "<unique identifier for the token to prevent replay>",
"iat": <issuance epoch seconds>,
"exp": <iat + 5m>,
"auth_time": <epoch seconds the user last authenticated at your provider>,
"email": "user@example.com",
"email_verified": true,

Expand All @@ -135,6 +136,8 @@ Discovery is two-hop:
}
```

`auth_time` is required (not optional). Consuming services enforce a max age on it — typically one hour — and reject older ID-JAGs with `401 login_required`, expecting the agent to refresh the user's session at your provider (`prompt=login` or equivalent) before minting a new one. Freshen `auth_time` whenever the user re-authenticates; don't reuse a long-lived session timestamp.

### Hosted Discovery Documents

In order for consuming services to verify the ID-JAG tokens, agent providers must publish a document specifying their [JSON Web Key Sets (JWKS)](https://datatracker.ietf.org/doc/html/rfc7517), usually at `.well-known/jwks.json`.
Expand Down Expand Up @@ -178,7 +181,7 @@ Content-Type: application/json
```json
{
"registration_id": "reg_...",
"registration_type": "agent-provider",
"registration_type": "identity_assertion",
"identity_assertion": "<service-signed JWT>",
"assertion_expires": "2026-05-04T13:00:00.000Z",
"scopes": ["api.read", "api.write"]
Expand Down Expand Up @@ -214,25 +217,31 @@ If the access_token expires, the agent re-calls `/oauth2/token` with the same id

Errors at `/agent/identity` describe profile-specific states; errors at `/oauth2/token` follow OAuth-standard vocabulary.

| Endpoint | Error code | Meaning |
| ----------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `/agent/identity` | `invalid_issuer` | Token `iss` isn't in the service's trusted providers list. |
| `/agent/identity` | `invalid_signature` | JWKS lookup failed or the signature didn't verify against any known key. |
| `/agent/identity` | `expired` | `exp` is in the past. |
| `/agent/identity` | `replay_detected` | `jti` has already been seen within the replay window. |
| `/agent/identity` | `invalid_audience` | `aud` doesn't match the service's auth server. |
| `/agent/identity` | `invalid_client_id` | `client_id` doesn't resolve to a known provider identity. |
| `/agent/identity` | `missing_verified_email` | Neither `email_verified` nor `phone_number_verified` is `true`. |
| `/agent/identity` | `invalid_request` | Body shape, missing claims, or unverified identity (neither `email_verified` nor `phone_number_verified` is `true`). |
| `/oauth2/token` | `invalid_grant` | Assertion failed verification, expired, replayed, audience-mismatched, or has been revoked. |
| `/oauth2/token` | `invalid_client` | `client_id` doesn't resolve to a known provider identity. |
| `/oauth2/token` | `unsupported_grant_type` | `grant_type` is not `urn:ietf:params:oauth:grant-type:jwt-bearer`. |
| Endpoint | Error code | Meaning |
| ----------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/agent/identity` | `invalid_issuer` | Token `iss` isn't in the service's trusted providers list. |
| `/agent/identity` | `invalid_signature` | JWKS lookup failed or the signature didn't verify against any known key. |
| `/agent/identity` | `expired` | `exp` is in the past. |
| `/agent/identity` | `replay_detected` | `jti` has already been seen within the replay window. |
| `/agent/identity` | `invalid_audience` | `aud` doesn't match the service's auth server. |
| `/agent/identity` | `invalid_client_id` | `client_id` doesn't resolve to a known provider identity. |
| `/agent/identity` | `missing_verified_email` | Neither `email_verified` nor `phone_number_verified` is `true`. |
| `/agent/identity` | `invalid_request` | Body shape, missing claims, or unverified identity (neither `email_verified` nor `phone_number_verified` is `true`). |
| `/agent/identity` | `interaction_required` (401) | Step-up: service knows the user by email/phone but needs them to confirm linking your `(iss, sub)`. Response body carries a `claim` block — surface it to the user. Not your problem to fix; just hand off. |
| `/agent/identity` | `login_required` (401) | `auth_time` missing or older than the service's `max_age` (in the response). Re-authenticate the user at your end and mint a fresh ID-JAG. |
| `/oauth2/token` | `invalid_grant` | Assertion failed verification, expired, replayed, audience-mismatched, or has been revoked. |
| `/oauth2/token` | `invalid_client` | `client_id` doesn't resolve to a known provider identity. |
| `/oauth2/token` | `unsupported_grant_type` | `grant_type` is not `urn:ietf:params:oauth:grant-type:jwt-bearer`. |

## Downstream Verification

Services will maintain a list of trusted agent providers. The service will attempt to match to an existing customer, looking for matches on `(iss, sub)` and then email/phone for JIT provisioning, and will determine whether to create a new account or permit using the identity assertion to return credentials for an existing account.
Services maintain a list of trusted agent providers. On first contact with a `(iss, sub)` pair the service has three resolutions:

1. **Existing delegation** — `(iss, sub)` is already bound to a user. Clean match; return a service-signed identity_assertion immediately.
2. **JIT-provisioned** — no existing user matches the ID-JAG's verified email/phone. Service creates a new user and binds the delegation. Clean match.
3. **Step-up required** — `(iss, sub)` is new but the verified email/phone matches an _existing_ user. The service won't silently bind the delegation; it returns a 401 `interaction_required` with a claim ceremony so the user can confirm the link.

Services will reject ID-JAGs with neither a verified email nor a verified phone number. If the service is satisfied by the validity of the identity assertion, it will return a service-signed identity assertion the agent can then exchange at `/oauth2/token` for an access_token.
Services reject ID-JAGs with neither a verified email nor a verified phone number, and reject ID-JAGs whose `auth_time` is older than the service's `max_age` (typically 1 hour) with `401 login_required` — agents must refresh the user's authentication at the provider before retrying.

## Tracking and Revocation

Expand Down
7 changes: 7 additions & 0 deletions agent-providers/src/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ sessionRouter.post("/login", (req, res) => {
return;
}

/*
* Freshen auth_time so downstream services can enforce a max_age on the
* upstream authentication via the ID-JAG's auth_time claim. Without this,
* the seed timestamp persists forever and ID-JAGs read as stale.
*/
user.auth_time = new Date();

const session = createSession(user.id);
res.json({ session_token: session.token, user });
});
Loading