# auth.md

You are an agent. This service supports **agentic registration**: discover → register → (claim if needed) → call API → handle revocation. Follow the steps in order; do not skip ahead.

This document follows the auth.md specification (https://github.com/workos/auth.md). This service is **UserTold**; substitute the URLs below into the spec procedure.

- REST and agent-auth resource: https://usertold.ai/
- MCP protected resource: https://mcp.usertold.ai/
- MCP transport: https://mcp.usertold.ai/mcp
- Authorization server: https://usertold.ai
- JWKS: https://usertold.ai/.well-known/jwks.json

## Step 1 — Discover

Discovery is two hops.

### 1a. Fetch the Protected Resource Metadata

```http
GET /.well-known/oauth-protected-resource
```

Response shape:

```json
{
  "resource": "https://usertold.ai/",
  "resource_name": "UserTold",
  "authorization_servers": ["https://usertold.ai"],
  "scopes_supported": ["agent:read", "agent:write", "openid", "profile", "email"],
  "bearer_methods_supported": ["header"]
}
```

That document describes the REST and agent-auth resource. MCP is a separate protected resource: fetch `https://mcp.usertold.ai/.well-known/oauth-protected-resource/mcp`, whose `resource` is `https://mcp.usertold.ai/` and whose transport is `https://mcp.usertold.ai/mcp`.

### 1b. Fetch the Authorization Server metadata

```http
GET https://usertold.ai/.well-known/oauth-authorization-server
```

The `agent_auth` block on this document is the part written for you:

```json
{
  "agent_auth": {
    "skill": "https://usertold.ai/auth.md",
    "register_uri": "https://usertold.ai/agent/auth",
    "claim_uri": "https://usertold.ai/agent/auth/claim",
    "revocation_uri": "https://usertold.ai/agent/auth/revoke",
    "identity_types_supported": ["identity_assertion", "anonymous"],
    "anonymous": {
      "credential_types_supported": ["api_key"]
    },
    "identity_assertion": {
      "assertion_types_supported": [
        "urn:ietf:params:oauth:token-type:id-jag",
        "verified_email"
      ],
      "credential_types_supported": ["access_token"]
    },
    "events_supported": [
      "https://schemas.workos.com/events/agent/auth/identity/assertion/revoked"
    ],
    "trusted_issuers": [/* see trust list below */]
  }
}
```

## Step 2 — Pick a method

1. You have an audience-bound ID-JAG from a trusted provider → **identity_assertion + id-jag** (below).
2. You have only the user's email → **identity_assertion + verified_email** (below). Claim ceremony required.
3. You have neither → **anonymous** (below). The credential is issued immediately at empty pre-claim scopes; the claim ceremony is required to bind the credential to the user and grant `agent:read`.

## Step 3 — Register

### identity_assertion + id-jag

Mint an ID-JAG with `aud` = `https://usertold.ai/`, `iss` = your provider's issuer URL (must appear in the trust list), `email_verified: true`, a fresh `jti`, and a near-term `exp` (~5 minutes). The `typ` header must be `oauth-id-jag+jwt`.

```http
POST /agent/auth HTTP/1.1
Content-Type: application/json

{
  "type": "identity_assertion",
  "assertion_type": "urn:ietf:params:oauth:token-type:id-jag",
  "assertion": "<your ID-JAG JWT>",
  "requested_credential_type": "access_token"
}
```

Response (HTTP 200):

```json
{
  "registration_id": "reg_…",
  "registration_type": "agent-provider",
  "credential_type": "access_token",
  "credential": "<jwt>",
  "credential_expires": "<iso-8601>",
  "scopes": ["agent:read", "agent:write"]
}
```

Extract `credential` and proceed to Step 5. `agent:write` is opt-in: pass `"scopes": ["agent:read", "agent:write"]` to request it; otherwise the token is read-only.

### identity_assertion + verified_email

```http
POST /agent/auth HTTP/1.1
Content-Type: application/json

{
  "type": "identity_assertion",
  "assertion_type": "verified_email",
  "assertion": "user@example.com",
  "requested_credential_type": "access_token",
  "agent_context": "Cursor (workspace acme)"
}
```

Response (HTTP 200):

```json
{
  "registration_id": "reg_…",
  "registration_type": "email-verification",
  "claim_url": "https://usertold.ai/agent/auth/claim",
  "claim_token": "clm_…",
  "claim_token_expires": "<iso-8601>",
  "post_claim_scopes": ["agent:read"]
}
```

There is no credential yet. We've already emailed the user. Keep `claim_token` in memory and go to Step 4. `agent_context` is optional but recommended — it's rendered in the OTP email so the user can confirm who's asking.

### anonymous

```http
POST /agent/auth HTTP/1.1
Content-Type: application/json

{
  "type": "anonymous",
  "requested_credential_type": "api_key",
  "agent_context": "Cursor (workspace acme)"
}
```

Response (HTTP 200):

```json
{
  "registration_id": "reg_…",
  "registration_type": "anonymous",
  "credential_type": "api_key",
  "credential": "usk_…",
  "credential_expires": null,
  "scopes": [],
  "claim_url": "https://usertold.ai/agent/auth/claim",
  "claim_token": "clm_…",
  "claim_token_expires": "<iso-8601>",
  "post_claim_scopes": ["agent:read"]
}
```

The api_key is the credential. Its pre-claim scope set on UserTold is empty (this service has no genuinely anonymous surfaces), so the credential will return 401 from any protected endpoint until you complete the claim ceremony in Step 4 — after which the same api_key works with `post_claim_scopes` (no new credential is issued).

## Step 4 — Claim ceremony

The end goal: get the user to read back a 6-digit OTP.

### 4a. Trigger the claim email (anonymous only)

Skip for `verified_email` — the email was sent during Step 3.

```http
POST /agent/auth/claim HTTP/1.1
Content-Type: application/json

{
  "claim_token": "clm_…",
  "email": "user@example.com",
  "agent_context": "Cursor (workspace acme)"
}
```

Response (HTTP 200):

```json
{
  "registration_id": "reg_…",
  "claim_attempt_id": "cla_…",
  "status": "initiated",
  "expires_at": "<iso-8601>"
}
```

### 4b. Submit the OTP

```http
POST /agent/auth/claim/complete HTTP/1.1
Content-Type: application/json

{
  "claim_token": "clm_…",
  "otp": "123456"
}
```

Response on success (anonymous):

```json
{ "registration_id": "reg_…", "status": "claimed" }
```

Your existing pre-claim api_key keeps working — its scope set is upgraded in place to `post_claim_scopes`. No new credential is issued.

Response on success (email-verification):

```json
{
  "registration_id": "reg_…",
  "status": "claimed",
  "credential_type": "access_token",
  "credential": "<jwt>",
  "credential_expires": "<iso-8601>",
  "scopes": ["agent:read"]
}
```

Extract `credential` and proceed to Step 5.

Up to 4 wrong OTPs return `401 otp_invalid` with a `remaining` counter; the 5th triggers a 1-hour lockout (`429 otp_locked`). OTPs expire 10 minutes after issuance. A new `/agent/auth/claim` rotates the active OTP and resets the attempt counter.

## Step 5 — Use the credential

Send the credential as `Authorization: Bearer <credential>` on requests to the REST API (`https://usertold.ai/api/orgs/:orgHandle/projects/...`) and the MCP transport (`POST https://mcp.usertold.ai/mcp`). This works for both `access_token` and `api_key` formats.

Per-scope gating runs on both surfaces:

- **Dashboard REST**: an agent-acting credential without `agent:write` may only reach the explicit data-read allowlist using GET/HEAD/OPTIONS:
  - `GET /api/auth/session`
  - `GET /api/user/*`
  - `GET /api/me/*`
  - `GET /api/orgs/{orgHandle}/projects[/{projectHandle}[/*]]`
  All other paths and all write methods return HTTP 403 with `code: agent_write_not_permitted`.
- **MCP (`POST https://mcp.usertold.ai/mcp`)**: tool calls are gated per tool. Non-read-only tools (e.g. `*.delete`, `*.set_config`) return JSON-RPC error `-31003` ("agent:write required").

A credential carrying `agent:write` is exempt from both gates. Only the trusted-provider id-jag flow can issue `agent:write` — `verified_email` and `anonymous` are capped at `agent:read`.

When the credential expires or returns 401: drop it and restart at Step 1.

## Errors

| Code | Where | What to do |
|-|-|-|
| `invalid_request` | any | malformed body / missing required field. Fix and retry. |
| `invalid_signature` | `/agent/auth` (id-jag) | Signature didn't verify. Mint a fresh ID-JAG. |
| `credential_expired` | `/agent/auth` (id-jag) | ID-JAG `exp` is past. Mint a fresh one. |
| `audience_mismatch` | `/agent/auth` (id-jag) | `aud` wrong. Use `https://usertold.ai/`. |
| `issuer_not_enabled` | `/agent/auth` (id-jag) | Your provider isn't on this service's trust list. Fall back to verified_email or anonymous. |
| `unsupported_method` | `/agent/auth` | `type` is not one we accept. Re-read the AS metadata. |
| `unsupported_credential_type` | `/agent/auth` | Requested `credential_type` not offered for this method. Re-read the AS metadata. |
| `invalid_claim_token` | `/agent/auth/claim*` | `claim_token` wrong or expired. Restart at Step 3. |
| `otp_invalid` | `/agent/auth/claim/complete` | Wrong OTP. Body includes `remaining`. Ask the user to re-read the code. |
| `otp_expired` | `/agent/auth/claim/complete` | OTP window passed. Re-trigger `/agent/auth/claim` (anonymous) or restart at Step 3. |
| `otp_locked` (429) | `/agent/auth/claim/complete` | Too many wrong OTPs for this claim_token. Wait the lockout window or restart. |
| `claim_expired` | `/agent/auth/claim*` | The whole registration expired. Restart at Step 3. |
| `previously_claimed` | `/agent/auth/claim/complete` | This registration has already been claimed. Restart if you need a fresh credential. |
| `send_failed` (502) | `/agent/auth` (verified_email) | OTP delivery failed. The registration is not persisted. Retry. |
| `rate_limited` (429) | any | Back off and retry. `reason: ip_throttle` means caller IP hit the per-minute cap (5/min on /agent/auth claim flows, 10/min on /agent/auth/claim/complete); `reason: email_throttle` means the recipient address has received the per-hour OTP cap (3/hour). |

Retry policy:

- 5xx → exponential backoff, retry the same request.
- 4xx → do not retry the same payload; act on the table above.
- 401 on a previously-working credential → drop the credential and restart at Step 1.

## Revocation

You do not initiate revocation. Two paths:

- **Provider-driven (id-jag flows)**: the provider that minted your ID-JAG can POST a `logout+jwt` to `https://usertold.ai/agent/auth/revoke`. Your credential will be invalidated; you discover this on the next API call returning 401 — restart at Step 1.
- **verified_email / anonymous flows**: no agent-facing revoke endpoint. On a 401 for a previously-working credential, drop it and restart at Step 1.

The revoke endpoint accepts `Content-Type: application/logout+jwt` (preferred), `application/x-www-form-urlencoded` (`logout_token=<jwt>`), or `application/json` (`{"logout_token": "<jwt>"}`). The logout token must:

- Be signed by a trusted issuer (matched on the trust list).
- Have `typ` equal to `logout+jwt` and `aud` equal to `https://usertold.ai/`.
- Include an `events` claim whose body has a member named `https://schemas.workos.com/events/agent/auth/identity/assertion/revoked` (canonical auth.md identifier). For backwards compatibility, `http://schemas.openid.net/event/backchannel-logout` is also accepted.
- Carry `sub` matching the agent's subject (same as `act.sub` on the issued token).
- Include a unique `jti` (replay of the same `jti` returns 409 `replay_detected`).

## Trust list

_The trust list is currently empty. Operators add providers via the admin API; agents without a trusted-provider id-jag can use the `verified_email` or `anonymous` flows below._

Providers are added by operators of this service via the admin API at `https://usertold.ai/api/admin/agent-providers`. Email `authmd@usertold.ai` with your issuer URL, public JWKS endpoint, and audiences to request listing.

## UserTold-specific extensions

These fields are not in the auth.md spec but are present on credentials issued by this service. Spec-strict agents can ignore them safely.

- **`act` JWT claim** on issued access_tokens: `{ "iss": "<provider-issuer>", "sub": "<agent-subject>" }` for id-jag tokens; `{ "iss": "urn:usertold:agent-api-key", "sub": "reg_<registration_id>" }` for api_key auth contexts. We use this for operator-facing attribution and revocation matching. It is not required for any spec-defined flow.
- **`agent_context` field** in the registration / claim-trigger request body: a short human-readable label rendered in the OTP email so the user can confirm which agent is asking. Sanitized server-side (control chars stripped, single-line, max 120 chars, OTP-shaped digit runs redacted).
