# Captioner auth.md

You are an agent. Captioner supports agentic registration: discover -> register with a verified email -> complete the email claim -> call the API -> handle revocation. Follow the steps in order.

Captioner accepts WorkOS API Keys as bearer credentials. Registration is a user-claimed, email-required flow backed by WorkOS Magic Auth. Any verified AuthKit user can claim a credential; Captioner provisions or reuses a WorkOS organization and issues the API key there.

Real hosts:

- Resource server: `https://caption.chan.dev`
- Authorization server: `https://caption.chan.dev`

For now, `@workos.com` emails share a WorkOS team workspace. All other emails get a personal workspace.

## Step 1 - Discover

If a protected Captioner request returns `401`, read its `WWW-Authenticate` header and fetch the advertised `resource_metadata` URL. If you do not have that header, use the conventional URLs below.

### 1a. Fetch the Protected Resource Metadata

```http
GET https://caption.chan.dev/.well-known/oauth-protected-resource
```

Expected response shape:

```json
{
  "resource": "https://caption.chan.dev",
  "resource_name": "Captioner",
  "resource_documentation": "https://caption.chan.dev/",
  "authorization_servers": [
    "https://caption.chan.dev"
  ],
  "scopes_supported": [
    "captioner:audio:upload",
    "captioner:caption:run",
    "captioner:terminology:read"
  ],
  "bearer_methods_supported": [
    "header"
  ],
  "agent_auth": {
    "skill": "https://caption.chan.dev/auth.md",
    "register_uri": "https://caption.chan.dev/agent/auth"
  }
}
```

### 1b. Fetch the Authorization Server Metadata

```http
GET https://caption.chan.dev/.well-known/oauth-authorization-server
```

Read the `agent_auth` block. Only use methods and credential types listed there.

## Step 2 - Pick a Method

Captioner supports exactly one registration method today:

- `identity_assertion` with `assertion_type: "verified_email"`
- `requested_credential_type: "api_key"`

Captioner does not currently support:

- anonymous registration
- ID-JAG assertions
- OAuth `access_token` issuance
- a pre-claim credential
- an agent-facing revocation endpoint

Before registering, check for an existing stored Captioner credential. If a credential exists, use it first. On macOS, use service `captioner.chan.dev` and account equal to the verified email. Start Magic Auth only when no credential exists or a previously-working credential returns `401`.

## Supported Scopes

- `captioner:audio:upload`
- `captioner:caption:run`
- `captioner:terminology:read`

## Step 3 - Register With Verified Email

Before asserting an email identity to Captioner, confirm the user wants to use that email address for Captioner and understands the requested scope set.

POST `https://caption.chan.dev/agent/auth` with JSON:

```json
{
  "type": "identity_assertion",
  "assertion_type": "verified_email",
  "assertion": "user@example.com",
  "requested_credential_type": "api_key",
  "requested_scopes": [
    "captioner:audio:upload",
    "captioner:caption:run"
  ]
}
```

Response:

```json
{
  "registration_id": "reg_...",
  "registration_type": "email-verification",
  "status": "claim_required",
  "flow": "user-claimed-email-required",
  "credential_type": "api_key",
  "claim_token": "claim_...",
  "claim_token_expires": "2026-06-01T12:10:00.000Z",
  "claim_token_expires_at": "2026-06-01T12:10:00.000Z",
  "claim_url": "https://caption.chan.dev/agent/auth/claim",
  "claim_complete_url": "https://caption.chan.dev/agent/auth/claim/complete",
  "delivery": {
    "method": "workos_magic_auth_email",
    "email": "us***@example.com"
  },
  "requested_scopes": [
    "captioner:audio:upload",
    "captioner:caption:run"
  ],
  "post_claim_scopes": [
    "captioner:audio:upload",
    "captioner:caption:run"
  ]
}
```

Captioner sends a WorkOS Magic Auth code to that email address and returns a one-time `claim_token`. Hold `claim_token` in memory for the claim ceremony; do not persist it after Step 4.

Because this is an email-required flow, do not POST to `claim_url`; the email has already been sent. Use `claim_complete_url` with the OTP.

## Step 4 - Claim Ceremony

Ask the user for the six-digit code from the newest WorkOS Magic Auth email. If the user has multiple emails or multiple codes, use the newest code for this registration.

If the user pastes an email link instead of the code, tell them to open the link and read the six-digit code shown by WorkOS.

POST `https://caption.chan.dev/agent/auth/claim/complete` with JSON:

```json
{
  "claim_token": "claim_token_from_registration",
  "otp": "123456"
}
```

Response:

```json
{
  "registration_id": "reg_...",
  "registration_type": "email-verification",
  "status": "claimed",
  "credential_type": "api_key",
  "credential": "wk_live_...",
  "credential_expires": null,
  "api_key": "wk_live_...",
  "token_type": "Bearer",
  "scopes": [
    "captioner:audio:upload",
    "captioner:caption:run"
  ],
  "organization_id": "org_...",
  "workspace": {
    "kind": "personal",
    "external_id": "org_user_x40_example_x2e_com"
  },
  "api_key_metadata": {
    "id": "api_...",
    "owner": {
      "type": "organization",
      "id": "org_..."
    },
    "obfuscated_value": "wk_live_..."
  }
}
```

A successful claim returns the WorkOS API key value exactly once. Use `credential` as the bearer token. `api_key` is the same value and is included as a compatibility alias.

## Step 5 - Use the Credential

Send the key in the HTTP `Authorization` header:

```http
Authorization: Bearer <credential>
```

Protected routes:

- `PUT /audio/:key` requires `captioner:audio:upload`.
- `POST /workflows/captioner?wait=result` requires `captioner:caption:run`.
- `GET /`, `GET /auth.md`, `GET /openapi.json`, `GET /terminology/packs`, and `GET /skills/captioner/SKILL.md` are public discovery resources.

If a previously-working credential returns `401`, drop it from memory and secure storage, then restart discovery. Do not stash the credential and retry indefinitely.

## Credential Reuse and Storage

The Magic Auth code is one-time. The returned WorkOS API key is reusable until revoked. Store it only with user consent.

Preferred storage order:

1. Agent credential vault, if available, named for `captioner.chan.dev` and `CAPTIONER_API_KEY`.
2. Platform secret store, such as macOS Keychain, Linux Secret Service/libsecret, or Windows Credential Manager.
3. CI or server secret manager exposed to the process as `CAPTIONER_API_KEY`.
4. Session-only memory when no secure store is available.

After a successful claim, ask once: "Store this Captioner API key in macOS Keychain for future runs?" If approved on macOS, store a generic password with service `captioner.chan.dev` and account equal to the verified email address.

macOS Keychain commands:

```bash
# Store after consent. Keep the key in CAPTIONER_API_KEY and never echo it.
CAPTIONER_EMAIL="user@example.com"
security add-generic-password -s captioner.chan.dev -a "$CAPTIONER_EMAIL" -U -w "$CAPTIONER_API_KEY"

# Reuse when the verified email is known.
CAPTIONER_API_KEY="$(security find-generic-password -s captioner.chan.dev -a "$CAPTIONER_EMAIL" -w)"

# If no email is known, this may return the first service-level credential.
# Prefer asking which email/account to use when multiple Captioner keys may exist.
CAPTIONER_API_KEY="$(security find-generic-password -s captioner.chan.dev -w)"

# Remove a revoked or invalid key.
security delete-generic-password -s captioner.chan.dev -a "$CAPTIONER_EMAIL"
```

Never print the API key, OTP, or claim token. Never paste the API key into chat. Never inline it in shell commands where it can land in shell history; reference a secret store or environment variable instead.

## Errors

| Code | HTTP | Retryable | What to do |
| --- | --- | --- | --- |
| `unsupported_identity_type` | 400 | No | Use `type: "identity_assertion"`. |
| `unsupported_assertion_type` | 400 | No | Use `assertion_type: "verified_email"`. |
| `unsupported_credential_type` | 400 | No | Use `requested_credential_type: "api_key"`. |
| `unsupported_scope` | 400 | No | Request only scopes listed in this file or metadata. |
| `invalid_email` | 400 | No | Supply a valid verified email assertion. |
| `invalid_claim_token` | 400 or 401 | No | Start registration again. |
| `invalid_otp` | 400 or 401 | Yes | Ask for the newest six-digit code and try again before the claim expires. |
| `claim_already_used` | 409 | No | Use the credential already returned, or start registration again. |
| `claim_expired` | 410 | Yes | Start registration again. |
| `too_many_attempts` | 429 | Yes | Start registration again after cooling down. |
| `rate_limited` | 429 | Yes | Wait for `Retry-After`, then retry. |
| `user_mismatch` | 403 | No | The verified user did not match the registration email; restart with the correct email. |
| `workos_*` | 502 or 503 | Sometimes | Inspect `error.workos`; retry only if the upstream failure is transient. |

4xx errors are not retryable with the same payload unless the table says otherwise. 5xx errors can be retried with backoff.

## Revocation and Audit

Revoke credentials through WorkOS API Keys. Captioner does not expose an agent-facing revocation endpoint today.

Captioner validates each protected request with WorkOS, authorizes it against Captioner scopes recorded for the WorkOS API key id, and emits structured Cloudflare logs containing the key id, owner type, owner organization when present, route, and required scope. It never logs raw API key values, OTP codes, or claim tokens.
