# How an agent authenticates with Vooks

**Most agents do not need to read this.** Every read on the Vooks Public Catalog API is
open: no key, no registration, no form.

```
curl https://www.vooks.com/api/v1/titles?age=5
```

A token buys two things, and nothing else:

- The rate limit rises from 600 requests a minute to 3,000.
- `POST /api/v1/exports` and the `create_catalog_export` MCP tool become available.
  That is the only operation on the whole surface that writes anything.

There are no Vooks user accounts behind this API. It serves the published catalog and
the published prices. No subscriber data, no watch history, no playback credentials.
Authenticating identifies the *client application*, not a person.

## Discover

Start at the protected-resource metadata, which names the resource and the
authorization server that guards it:

```
curl https://www.vooks.com/.well-known/oauth-protected-resource
```

```json
{
  "resource": "https://www.vooks.com/api/v1",
  "authorization_servers": ["https://www.vooks.com"],
  "scopes_supported": ["catalog:read", "pricing:read", "catalog:export"],
  "bearer_methods_supported": ["header"],
  "resource_policy_uri": "https://www.vooks.com/auth.md"
}
```

Then the authorization-server metadata (RFC 8414), which names every endpoint below:

```
curl https://www.vooks.com/.well-known/oauth-authorization-server
```

It carries an `agent_auth` block:

```json
{
  "agent_auth": {
    "identity_endpoint": "https://www.vooks.com/agent/identity",
    "identity_types_supported": ["anonymous", "service_auth"],
    "skill": "https://www.vooks.com/auth.md"
  }
}
```

You can also reach this from a refusal. Any 401 or 403 from the API carries a
`WWW-Authenticate` header pointing back here:

```
WWW-Authenticate: Bearer realm="Vooks Public Catalog API",
  resource_metadata="https://www.vooks.com/.well-known/oauth-protected-resource",
  scope="catalog:export"
```

## Pick a method

`identity_types_supported` lists two, and they map to different situations.

| Identity type | Use it when | What you get |
|---|---|---|
| `anonymous` | You just want the higher rate limit and nobody needs to vouch for you | `catalog:read pricing:read`, one call, no registration |
| `service_auth` | You run a named integration and want `catalog:export` | A registered client with a secret, and any scope it registered for |

There is no `identity_assertion` here. This server accepts no third-party assertions and
issues no ID-JAG tokens, so advertising that type would be a lie an agent would waste a
round trip on.

## Register

### Anonymous: skip registration

One call, no prior setup:

```
curl -X POST https://www.vooks.com/agent/identity \
  -H 'content-type: application/json' \
  -d '{"identity_type":"anonymous","client_name":"Nightly catalog sync"}'
```

```json
{
  "client_id": "vooks_...",
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "catalog:read pricing:read",
  "identity_type": "anonymous"
}
```

That token is ready to use. Skip to **Use the access_token**. An anonymous identity
cannot hold `catalog:export`: nothing vouched for it, so it gets reads only.

### service_auth: register a client

Dynamic client registration, RFC 7591:

```
curl -X POST https://www.vooks.com/oauth/register \
  -H 'content-type: application/json' \
  -d '{
        "client_name": "Acme Library Importer",
        "redirect_uris": ["https://acme.example.com/callback"],
        "grant_types": ["client_credentials"],
        "scope": "catalog:read pricing:read catalog:export"
      }'
```

```json
{
  "client_id": "vooks_...",
  "client_secret": "vs_...",
  "token_endpoint_auth_method": "client_secret_post",
  "client_id_issued_at": 1793000000
}
```

`redirect_uris` must be https, or http on a loopback address so a CLI can complete the
browser flow. A scope the server does not grant is rejected as `invalid_client_metadata`
rather than silently dropped.

You can skip registration entirely by using a **client ID metadata document**: host a
JSON document at an https URL whose `client_id` field is that same URL, and pass the URL
as your `client_id`. The metadata advertises this as
`client_id_metadata_document_supported`. Such a client is public, holds no secret, and
must use PKCE.

## Claim

Server-to-server, with no browser in the loop, use the client credentials grant and go
straight to **Exchange**.

For the browser flow, send the operator to the authorization endpoint. PKCE is
mandatory, and `code_challenge_method` must be `S256`: the plain method is refused.

```
https://www.vooks.com/oauth/authorize
  ?response_type=code
  &client_id=vooks_...
  &redirect_uri=https://acme.example.com/callback
  &scope=catalog:read%20catalog:export
  &state=<opaque>
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256
```

They see one screen naming your client and the scopes, with no password field, because
there is no account to log into. On approval the browser comes back to your
`redirect_uri` with `code` and your `state`. On refusal it comes back with
`error=access_denied`. Codes are single use and expire in two minutes.

## Exchange

Client credentials:

```
curl -X POST https://www.vooks.com/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=vooks_... \
  -d client_secret=vs_... \
  -d scope=catalog:export
```

Authorization code, with the verifier whose hash you sent as the challenge:

```
curl -X POST https://www.vooks.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=<code> \
  -d redirect_uri=https://acme.example.com/callback \
  -d client_id=vooks_... \
  -d code_verifier=<verifier>
```

Both return the same shape:

```json
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "catalog:export"
}
```

Tokens last an hour. There are no refresh tokens: ask for another one, which for a
client-credentials client is a single unattended call.

## Use the access_token

Send it as a bearer header:

```
curl -X POST https://www.vooks.com/api/v1/exports \
  -H 'authorization: Bearer eyJ...' \
  -H 'content-type: application/json' \
  -H 'idempotency-key: acme-nightly-2026-11-03' \
  -d '{"resource":"titles","filters":{"language":"spanish"}}'
```

```
HTTP/1.1 202 Accepted
Location: https://www.vooks.com/api/v1/jobs/<id>
```

Check what a token carries at any time:

```
curl https://www.vooks.com/agent/auth -H 'authorization: Bearer eyJ...'
```

```json
{ "authenticated": true, "client_id": "vooks_...", "scopes": ["catalog:export"], "rate_limit": 3000 }
```

For MCP, send the same header to `https://www.vooks.com/mcp`. Every read tool there
works without one; only `create_catalog_export` needs `catalog:export`.

## Errors

| Status | Body `code` / `error` | What to do |
|---|---|---|
| 401 | `unauthorized` | No token was sent. Follow **Claim**, then retry |
| 401 | `invalid_token` | Expired, revoked, or not issued here. Get a new one |
| 403 | `insufficient_scope` | The token is valid but lacks the scope. Register for it |
| 400 | `invalid_grant` | The code was wrong, expired, already used, or the `code_verifier` did not match |
| 400 | `invalid_scope` | You asked for a scope the client is not registered for |
| 401 | `invalid_client` | The `client_id` or `client_secret` is wrong |
| 429 | `rate_limited` | Slow down. `Retry-After` says for how long |

Every 401 and 403 carries `WWW-Authenticate` with `resource_metadata`, so a refusal
always tells you where to look rather than leaving you to guess.

## Revocation

```
curl -X POST https://www.vooks.com/oauth/revoke -d token=eyJ...
```

The endpoint always answers 200, including for a token that was never valid: saying
otherwise would tell a caller which tokens exist. A revoked token is refused with
`invalid_token` until it would have expired anyway.

Revocation propagates within about a minute rather than instantly. The revocation list
is held in Cloudflare KV, which is eventually consistent across edge locations, and each
location caches a token's status for up to ten seconds. Treat a revoked token as live
for roughly that long.

To retire a client, stop using it. Registrations expire 180 days after registration, not
180 days after last use: the expiry is set once and nothing refreshes it. A client in
daily use still has to re-register before that date.
