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
6 changes: 3 additions & 3 deletions AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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.
Expand Down Expand Up @@ -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=<access_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).
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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": "<code>", "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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
18 changes: 10 additions & 8 deletions agent-providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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

Expand Down Expand Up @@ -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": "<provider key id>"
}
Expand All @@ -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": "<code>", "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.
4 changes: 2 additions & 2 deletions agent-providers/src/jwts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function mintIdJag(input: IdJagInput): Promise<IdJagResult> {
return { jwt, jti, expiresIn };
}

export async function mintLogoutJwt(input: {
export async function mintSecEventJwt(input: {
user: User;
audience: string;
}): Promise<string> {
Expand All @@ -61,5 +61,5 @@ export async function mintLogoutJwt(input: {
{},
},
};
return sign(payload, "logout+jwt");
return sign(payload, "secevent+jwt");
}
24 changes: 14 additions & 10 deletions agent-providers/src/routes/grants.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
});
Expand All @@ -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) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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;
}
Expand Down
12 changes: 6 additions & 6 deletions agent-providers/src/routes/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ function renderHtml(seeded: { email: string; name: string }[]): string {
<option value="once">once — marked consumed on first mint</option>
</select>
</label>
<label>Revocation URI (optional)
<input id="grant-revoke" value="${config.consumerUrl}/agent/auth/revoke" placeholder="${config.consumerUrl}/agent/auth/revoke">
<label>Events URI (optional)
<input id="grant-revoke" value="${config.consumerUrl}/agent/event/notify" placeholder="${config.consumerUrl}/agent/event/notify">
</label>
<div class="label">Request</div>
<div class="req" id="grant-req"><pre></pre></div>
Expand Down Expand Up @@ -139,7 +139,7 @@ function renderHtml(seeded: { email: string; name: string }[]): string {

<section id="step-6" hidden>
<h2><span class="num">6</span>Revoke grant</h2>
<p>Deleting a grant removes it locally. If a <code>revocation_uri</code> was recorded, the provider first POSTs a <code>logout+jwt</code> there; if the call fails, the grant is retained and the endpoint returns 500. Any credentials the consumer issued for this delegation are invalidated.</p>
<p>Deleting a grant removes it locally. If an <code>events_uri</code> was recorded, the provider first POSTs a <code>secevent+jwt</code> (RFC 8417 SET, delivered per RFC 8935) there; if the call fails, the grant is retained and the endpoint returns 500. Any credentials the consumer issued for this delegation are invalidated.</p>
<div class="label">Request</div>
<div class="req" id="revoke-req"><pre></pre></div>
<button class="primary" type="button" data-action="revoke">Revoke</button>
Expand Down Expand Up @@ -227,7 +227,7 @@ function updateGrantPreview() {
mode: document.getElementById("grant-mode").value,
};
const rev = document.getElementById("grant-revoke").value.trim();
if (rev) body.revocation_uri = rev;
if (rev) body.events_uri = rev;
document.querySelector("#grant-req pre").textContent =
"POST /grants\\nAuthorization: Bearer <session>\\n\\n" + jsonStr(body);
}
Expand Down Expand Up @@ -299,7 +299,7 @@ async function grant() {
mode: document.getElementById("grant-mode").value,
};
const rev = document.getElementById("grant-revoke").value.trim();
if (rev) body.revocation_uri = rev;
if (rev) body.events_uri = rev;

const r = await jsonFetch("/grants", { method: "POST", body: JSON.stringify(body) });
document.getElementById("grant-out").innerHTML = resBlock(r.status, r.body, r.ok);
Expand Down Expand Up @@ -454,7 +454,7 @@ async function revoke() {

// If a credential was exchanged at the consumer, prove it's now rejected.
// A 401 here is the expected outcome — it confirms the consumer processed
// the logout+jwt. A 200 would mean revocation didn't take effect.
// the SET. A 200 would mean revocation didn't take effect.
if (state.credential) {
const url = state.audience + "/api/resource";
try {
Expand Down
2 changes: 1 addition & 1 deletion agent-providers/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const loginBody = z.object({
export const createGrantBody = z.object({
audience: z.url(),
mode: z.enum(["once", "always"]),
revocation_uri: z.url().optional(),
events_uri: z.url().optional(),
});

export const mintIdJagBody = z.object({
Expand Down
9 changes: 5 additions & 4 deletions agent-providers/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type Grant = {
created_at: Date;
expires_at: Date;
consumed_at?: Date;
revocation_uri?: string;
/** Consumer's `agent_auth.events_endpoint` — where we POST SETs on revoke. */
events_uri?: string;
};

function addSeconds(base: Date, seconds: number): Date {
Expand Down Expand Up @@ -95,14 +96,14 @@ export function upsertGrant(input: {
userId: string;
audience: string;
mode: GrantMode;
revocationUri?: string;
eventsUri?: string;
}): Grant {
const now = new Date();
const existing = findGrantForAudience(input.userId, input.audience);
if (existing) {
existing.mode = input.mode;
existing.expires_at = addSeconds(now, config.consentTtlSeconds);
if (input.revocationUri) existing.revocation_uri = input.revocationUri;
if (input.eventsUri) existing.events_uri = input.eventsUri;
return existing;
}
const grant: Grant = {
Expand All @@ -112,7 +113,7 @@ export function upsertGrant(input: {
mode: input.mode,
created_at: now,
expires_at: addSeconds(now, config.consentTtlSeconds),
revocation_uri: input.revocationUri,
events_uri: input.eventsUri,
};
grants.set(grant.id, grant);
return grant;
Expand Down
Loading