diff --git a/AUTH.md b/AUTH.md index 44b63ad..adc034e 100644 --- a/AUTH.md +++ b/AUTH.md @@ -68,7 +68,7 @@ Response shape: "skill": "https://service.example.com/auth.md", "identity_endpoint": "https://auth.service.example.com/agent/identity", "claim_endpoint": "https://auth.service.example.com/agent/identity/claim", - "revocation_uri": "https://auth.service.example.com/agent/auth/revoke", + "events_endpoint": "https://auth.service.example.com/agent/event/notify", "identity_types_supported": ["anonymous", "identity_assertion"], "identity_assertion": { "assertion_types_supported": [ @@ -92,7 +92,7 @@ The outer fields restate the PRM. The top-level OAuth endpoints (`issuer`, `toke - `agent_auth.skill` — the URL of this document. - `agent_auth.identity_endpoint` — where you POST to register (Step 3). - `agent_auth.claim_endpoint` — where you POST the claim invite and OTP (Step 4). -- `agent_auth.revocation_uri` — where the provider POSTs a [logout token](https://openid.net/specs/openid-connect-backchannel-1_0.html) to notify the service of upstream identity events. +- `agent_auth.events_endpoint` — where the provider POSTs a [Security Event Token (RFC 8417)](https://datatracker.ietf.org/doc/html/rfc8417) per [RFC 8935](https://datatracker.ietf.org/doc/html/rfc8935) push delivery to notify the service of upstream identity events. You don't call this; it tells you what to expect. - `agent_auth.identity_types_supported` — which registration methods this service accepts. Pick yours from Step 2. - `agent_auth.identity_assertion.assertion_types_supported` — which assertion types this service accepts (ID-JAG, verified email, etc.). - `agent_auth.events_supported` — event schemas this service can ingest (currently revocation). Informational; you don't act on these directly. @@ -347,6 +347,6 @@ Retry policy: Two independent layers can kill what you're holding: - **Credential layer ([RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009), `revocation_endpoint`)** — agent-callable. POST `token=&token_type_hint=access_token` (form-encoded) to the top-level `revocation_endpoint` to kill one access_token. 200 on success, idempotent. Your `identity_assertion` is intact; re-run [Step 5](#step-5--exchange-the-assertion) to mint a fresh access_token. -- **Registration layer (`agent_auth.revocation_uri`)** — provider-driven. The provider that minted your ID-JAG can POST a [logout token](https://openid.net/specs/openid-connect-backchannel-1_0.html) (`Content-Type: application/logout+jwt`) to this service's `revocation_uri`. The service invalidates the identity assertion and every access_token derived from it. You don't call this; you discover it the next time `/oauth2/token` returns `invalid_grant` — restart at [Step 3](#step-3--register). +- **Registration layer ([RFC 8935](https://datatracker.ietf.org/doc/html/rfc8935) Security Event Token delivery, `agent_auth.events_endpoint`)** — provider-driven. The provider that minted your ID-JAG can POST a [SET (RFC 8417)](https://datatracker.ietf.org/doc/html/rfc8417) (`Content-Type: application/secevent+jwt`) to this service's `events_endpoint`. The service invalidates the identity assertion and every access_token derived from it. You don't call this; you discover it the next time `/oauth2/token` returns `invalid_grant` — restart at [Step 3](#step-3--register). On a 401 for a previously-working access_token: try [Step 5](#step-5--exchange-the-assertion) once. If `/oauth2/token` succeeds, the credential was revoked at the credential layer and your fresh access_token works. If `/oauth2/token` returns `invalid_grant`, the registration was killed at the registration layer — restart at [Step 3](#step-3--register). diff --git a/CHANGELOG.md b/CHANGELOG.md index 316af46..7ea1a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # auth.md Changelog +## v0.3.0 (2026-06-03) + +Switches the provider-driven invalidation channel from OIDC Back-Channel Logout to [RFC 8417](https://datatracker.ietf.org/doc/html/rfc8417) Security Event Token push delivery per [RFC 8935](https://datatracker.ietf.org/doc/html/rfc8935). Same trust path as before (issuer JWKS, jti replay protection), with a wire format and event shape that generalizes beyond revocation. + +### Added + +- `agent_auth.events_supported` discovery field — advertises which Security Event Token schemas the service is prepared to ingest. Currently lists `https://schemas.workos.com/events/agent/auth/identity/assertion/revoked`. + +### Changed + +- Endpoint: `/agent/auth/revoke` → `/agent/event/notify`. +- Discovery: `agent_auth.revocation_uri` → `agent_auth.events_endpoint`. +- JWT typ: `logout+jwt` → `secevent+jwt`; Content-Type: `application/logout+jwt` → `application/secevent+jwt`. +- Response shape: 202 Accepted on success (no body); 400 with `{ "err": "", "description": "..." }` per RFC 8935 §2.4 (codes: `invalid_request`, `invalid_key`, `invalid_issuer`, `invalid_audience`, `authentication_failed`). +- Receiver validates the `events` claim and dispatches on schema URI — only revokes for the `identity-assertion/revoked` event. Unknown schemas in the same envelope are ignored per RFC 8417 §2.2. + ## v0.2.0 (2026-06-03) Separates the identity and credential surfaces. Registration now mints a service-signed `identity_assertion` that the agent exchanges for an access_token at a standard OAuth token endpoint, instead of having `/agent/auth` issue credentials directly. Aligns the access-token issuance, revocation, and discovery surfaces with the standards they were already adjacent to (RFC 7523, RFC 7009, RFC 8414, RFC 6749). diff --git a/README.md b/README.md index aa7a1f3..06e3f12 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Hosted at `/.well-known/oauth-authorization-server`: "skill": "https://service.example.com/auth.md", "identity_endpoint": "https://auth.service.example.com/agent/identity", "claim_endpoint": "https://auth.service.example.com/agent/identity/claim", - "revocation_uri": "https://auth.service.example.com/agent/auth/revoke", + "events_endpoint": "https://auth.service.example.com/agent/event/notify", "identity_types_supported": ["anonymous", "identity_assertion"], "identity_assertion": { "assertion_types_supported": [ diff --git a/agent-providers/README.md b/agent-providers/README.md index 6468a84..8aae88b 100644 --- a/agent-providers/README.md +++ b/agent-providers/README.md @@ -81,7 +81,7 @@ Discovery is two-hop: "skill": "https://service.example.com/auth.md", "identity_endpoint": "https://auth.service.example.com/agent/identity", "claim_endpoint": "https://auth.service.example.com/agent/identity/claim", - "revocation_uri": "https://auth.service.example.com/agent/auth/revoke", + "events_endpoint": "https://auth.service.example.com/agent/event/notify", "identity_types_supported": ["anonymous", "identity_assertion"], "identity_assertion": { "assertion_types_supported": [ @@ -96,7 +96,7 @@ Discovery is two-hop: } ``` - The top-level `token_endpoint`, `revocation_endpoint`, and `grant_types_supported` are standard [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) / [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) / [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) fields. The `agent_auth` block carries the profile-specific bootstrap, claim, and revocation endpoints. + The top-level `token_endpoint`, `revocation_endpoint`, and `grant_types_supported` are standard [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) / [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) / [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) fields. The `agent_auth` block carries the profile-specific bootstrap, claim, and SET-receive endpoints. ### Minting the Identity Assertion @@ -233,16 +233,16 @@ Services will reject ID-JAGs with neither a verified email nor a verified phone ## Tracking and Revocation -In a robust implementation, agent providers will want to track the services to which identity assertions have been delegated so that the user can revoke the credentials if needed from a control plane. The discovery document and the API response both contain the endpoint for revoking the assertion. The mechanism is a [logout token](https://openid.net/specs/openid-connect-backchannel-1_0.html): +In a robust implementation, agent providers will want to track the services to which identity assertions have been delegated so that the user can revoke the delegation if needed from a control plane. The discovery document's `agent_auth.events_endpoint` is where the provider transmits identity-event SETs to the service. Transmission is the canonical [RFC 8935](https://datatracker.ietf.org/doc/html/rfc8935) push-based delivery of a [Security Event Token (RFC 8417)](https://datatracker.ietf.org/doc/html/rfc8417): ```json -POST /agent/auth/revoke HTTP/1.1 +POST /agent/event/notify HTTP/1.1 Host: auth.service.example.com -Content-Type: application/logout+jwt +Content-Type: application/secevent+jwt // header { - "typ": "logout+jwt", + "typ": "secevent+jwt", "alg": "ES256", // or RS256, etc. "kid": "" } @@ -260,6 +260,8 @@ Content-Type: application/logout+jwt } ``` -Receiving services invalidate credentials associated with the ID-JAG. +The receiving service validates the SET against the provider's JWKS, dispatches on the `events` claim, and invalidates the identity assertion (and the credentials derived from it). Per RFC 8935 §2.4, a successful receive returns 202 Accepted; failures return 400 with `{ "err": "", "description": "..." }`. -In a future state, we expect the need for [SET](https://datatracker.ietf.org/doc/html/rfc8417) / [CAEP](https://openid.net/specs/openid-caep-1_0-final.html) / RISC event communication between the agent providers and the consuming services, via webhook or SSE. +Note that this `events_endpoint` is distinct from the top-level `revocation_endpoint`. The `revocation_endpoint` ([RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009)) is for the agent or admin to kill a single bearer credential by value. The `events_endpoint` is for the provider to notify the service of an upstream identity event — a broader signal that invalidates the registration itself. + +In a future state, we expect richer [SET](https://datatracker.ietf.org/doc/html/rfc8417) / [CAEP](https://openid.net/specs/openid-caep-1_0-final.html) / RISC event communication between agent providers and consuming services, layered on this same push-based SET delivery channel. diff --git a/agent-providers/src/jwts.ts b/agent-providers/src/jwts.ts index 9432864..6446504 100644 --- a/agent-providers/src/jwts.ts +++ b/agent-providers/src/jwts.ts @@ -47,7 +47,7 @@ export async function mintIdJag(input: IdJagInput): Promise { return { jwt, jti, expiresIn }; } -export async function mintLogoutJwt(input: { +export async function mintSecEventJwt(input: { user: User; audience: string; }): Promise { @@ -61,5 +61,5 @@ export async function mintLogoutJwt(input: { {}, }, }; - return sign(payload, "logout+jwt"); + return sign(payload, "secevent+jwt"); } diff --git a/agent-providers/src/routes/grants.ts b/agent-providers/src/routes/grants.ts index 00e41a5..8d901d3 100644 --- a/agent-providers/src/routes/grants.ts +++ b/agent-providers/src/routes/grants.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { requireSession } from "../auth.js"; -import { mintLogoutJwt } from "../jwts.js"; +import { mintSecEventJwt } from "../jwts.js"; import { createGrantBody, parseBody } from "../schemas.js"; import { grants, listGrantsForUser, upsertGrant } from "../store.js"; @@ -16,7 +16,7 @@ grantsRouter.post("/grants", requireSession, (req, res) => { userId: req.user!.id, audience: parsed.value.audience, mode: parsed.value.mode, - revocationUri: parsed.value.revocation_uri, + eventsUri: parsed.value.events_uri, }); res.status(201).json(grant); }); @@ -34,33 +34,37 @@ grantsRouter.delete("/grants/:id", requireSession, async (req, res) => { return; } - if (grant.revocation_uri) { + if (grant.events_uri) { let resp: Response; try { - const logoutJwt = await mintLogoutJwt({ + const setJwt = await mintSecEventJwt({ user: req.user!, audience: grant.audience, }); - resp = await fetch(grant.revocation_uri, { + resp = await fetch(grant.events_uri, { method: "POST", - headers: { "content-type": "application/logout+jwt" }, - body: logoutJwt, + headers: { "content-type": "application/secevent+jwt" }, + body: setJwt, }); } catch (err) { console.warn("[revocation] outbound call failed:", err); res.status(500).json({ error: "revocation_failed", - message: `Failed to reach ${grant.revocation_uri}. Grant retained.`, + message: `Failed to reach ${grant.events_uri}. Grant retained.`, }); return; } + /* + * RFC 8935 §2.4: success is 202 Accepted. Treat any non-2xx as a + * delivery failure and keep the grant on file so the user can retry. + */ if (!resp.ok) { console.warn( - `[revocation] ${grant.revocation_uri} responded ${resp.status}; grant retained`, + `[revocation] ${grant.events_uri} responded ${resp.status}; grant retained`, ); res.status(500).json({ error: "revocation_failed", - message: `${grant.revocation_uri} responded ${resp.status}. Grant retained.`, + message: `${grant.events_uri} responded ${resp.status}. Grant retained.`, }); return; } diff --git a/agent-providers/src/routes/home.ts b/agent-providers/src/routes/home.ts index 2fe2731..c87778c 100644 --- a/agent-providers/src/routes/home.ts +++ b/agent-providers/src/routes/home.ts @@ -93,8 +93,8 @@ function renderHtml(seeded: { email: string; name: string }[]): string { -