# auth.md — Authentication for AI Agents

You are an agent. Zavod.dev is a B2B catalog of industrial electronics and AI/ML hardware.
Its tools are exposed over the Model Context Protocol at `https://zavod.dev/api/mcp`.

Two ways in:

- **Agentic registration** (this document): register anonymously, optionally let a human
  claim you, exchange an identity assertion for an access token.
- **Client credentials** (RFC 7591 + RFC 6749): if you just need read access and have no
  human behind you, see [Client credentials](#client-credentials) at the bottom.

---

## Step 1 — Discover

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

The authorization server metadata carries the `agent_auth` block: `identity_endpoint`,
`claim_uri`, and `identity_types_supported`. Only `anonymous` is supported — Zavod.dev
maintains no ID-JAG issuer trust list, and `service_auth` is declined in favour of
anonymous-then-claim.

---

## Step 2 — Register

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

{ "type": "anonymous" }
```

**Response (200):**

```json
{
  "registration_id": "reg_...",
  "registration_type": "anonymous",
  "identity_assertion": "<service-signed JWT>",
  "assertion_expires": "2026-07-10T13:00:00.000Z",
  "pre_claim_scopes": ["catalog:read"],
  "claim_url": "https://zavod.dev/agent/identity/claim",
  "claim_token": "clm_...",
  "claim_token_expires": "2026-07-11T09:00:00.000Z",
  "post_claim_scopes": ["catalog:read"]
}
```

`claim_token` is returned exactly once. Hold it in memory only while a ceremony is in flight.

Go to [Step 4](#step-4--exchange-the-assertion) to start reading the catalog immediately.
Run [Step 3](#step-3--claim-ceremony-optional) only if a human should own this agent.

---

## Step 3 — Claim ceremony (optional)

Zavod.dev has no user accounts, so ownership is established by **proving control of a
mailbox**, not by signing in. You supply the owner's address; we mail it a one-time link.

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

{ "claim_token": "clm_...", "email": "owner@example.com" }
```

**Response (200):**

```json
{
  "registration_id": "reg_...",
  "claim_attempt_id": "cla_...",
  "status": "initiated",
  "expires_at": "2026-07-10T09:10:00.000Z",
  "claim_attempt": {
    "user_code": "123456",
    "expires_in": 600,
    "verification_uri": "https://zavod.dev/agent/claim?claim_attempt_token=cat_...",
    "interval": 5
  }
}
```

Surface `verification_uri` and `user_code` to the user in one message:

> Open this link, then enter this 6-digit code: **123456**
> https://zavod.dev/agent/claim?claim_attempt_token=...

The code goes into the page they land on — never back to you. The link alone is not enough:
the user must open the message we mailed them, which carries the proof that unlocks the form.
Two independent factors — your code, their inbox — are both required.

Poll the token endpoint while you wait:

```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:workos:agent-auth:grant-type:claim&claim_token=clm_...
```

`{"error":"authorization_pending"}` — retry after `interval` seconds.
`{"error":"expired_token"}` — the 10-minute window closed; call `/agent/identity/claim`
again with the same `claim_token` and email for a fresh code.

On success you get an access token plus a v2 `identity_assertion` carrying the owner's
verified email. Requests made with it are attributed to that owner.

---

## Step 4 — Exchange the assertion

```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<identity_assertion>
```

**Response (200):** `{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "scope": "catalog:read" }`

Re-use the same assertion to mint new access tokens until it expires. On `invalid_grant`,
restart at Step 2.

---

## Step 5 — Call the API

```http
POST /api/mcp
Authorization: Bearer <access_token>
Content-Type: application/json

{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
```

Tools: `search_products`, `get_product`, `browse_section`.

---

## Client credentials

No human, no ceremony. Register a client and use the standard grant:

```http
POST /oauth/register   { "client_name": "Your Agent", "grant_types": ["client_credentials"] }
POST /oauth/token      grant_type=client_credentials&client_id=...&client_secret=...
```

---

## Errors

| Code | Where | What to do |
| --- | --- | --- |
| `issuer_not_enabled` | `/agent/identity` | ID-JAGs are not accepted. Register anonymously. |
| `service_auth_not_enabled` | `/agent/identity` | Register anonymously, then claim with an email. |
| `invalid_claim_token` | `/agent/identity/claim` | Token wrong or expired. Restart at Step 2. |
| `claimed_or_in_flight` | `/agent/identity/claim` | Already claimed. Re-read the Step 2 response. |
| `authorization_pending` | `/oauth/token` | Human has not finished. Honour `interval`. |
| `expired_token` | `/oauth/token` | Code window closed. Re-call `/agent/identity/claim`. |
| `invalid_grant` | `/oauth/token` | Assertion expired or revoked. Restart at Step 2. |
| `unsupported_grant_type` | `/oauth/token` | Use one of the three grants in AS metadata. |

## Security notes

- TLS 1.2+ required. Tokens are RS256, verifiable against `/oauth/jwks`.
- Access tokens live 1 hour; identity assertions 1 hour; registrations 24 hours.
- A restart of the service drops in-flight ceremonies — restart at Step 2 on `invalid_grant`.
- The `catalog:read` scope is read-only. Claiming does not widen scope; it attributes the
  agent's requests to a verified owner.
