# Authentication

Every request to the Amdahl Platform API must be authenticated. The platform supports three credential types. Pick the one that matches your integration shape.

| Credential             | Header                                                  | Best for                                       | Refresh            |
| ---------------------- | ------------------------------------------------------- | ---------------------------------------------- | ------------------ |
| API key                | `X-API-Key: amdhl_...`                                  | Server-to-server, CLIs, agents, scheduled jobs | Manual rotation    |
| OAuth 2.1 access token | `Authorization: Bearer <access_token>`                  | Third-party apps acting on behalf of a user    | Refresh token flow |
| Supabase JWT           | `Authorization: Bearer <jwt>` + `X-Client-Slug: <slug>` | First-party web app only                       | Short-lived        |

All three resolve to the same `{ user_id, business_id, scopes }` payload before any tool runs, and every tool invocation writes one row to the platform audit log.

Scope enforcement runs on that resolved payload whatever the credential, but the scopes it carries differ by path: an API key carries the scopes it was minted with, an OAuth token carries the scopes the user consented to, and a Supabase JWT resolves to a broad workspace-member grant (per-route checks gate admin-only actions against `business_members.role`).

Credentials are tried in a fixed order: `X-API-Key` first, then `Authorization: Bearer` (Supabase JWT, falling through to OAuth access token). **If both `X-API-Key` and `Authorization` are present on a Platform API request, `X-API-Key` wins** and the bearer token is ignored. The `/mcp` endpoint applies the opposite order, trying the bearer token first. Send exactly one credential rather than relying on precedence.

## API keys

API keys are the recommended path for almost every integration. They are long-lived and scoped to one user plus one business.

### Create an API key

Use the dashboard. In [console.amdahl.co](https://console.amdahl.co):

1. Sign in and open the workspace the key should act in (keys are per-workspace).
2. Go to **Settings -> Developer**.
3. Click **Create key** and give it a name.
4. Copy the plaintext key immediately. It is shown **once** and is never retrievable again.

The key is bound to your user and that one workspace. To act against another workspace, switch workspaces and create a separate key there.

The key-management endpoints live under `/api/mcp/api-keys` — note this is a different base path from the Platform API (`/api/platform/v1`), so `$AMDAHL_BASE` does not apply. They authenticate with a **Supabase session JWT**, not an API key:

```bash
curl -s -X POST "https://app.amdahl.co/api/mcp/api-keys/$BUSINESS_ID" \
  -H "Authorization: Bearer $SUPABASE_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "name": "My integration" }'
```

Response `201` (plaintext key is returned exactly once):

```json
{
  "id": "f1e2...",
  "key": "amdhl_abc123...",
  "name": "My integration",
  "message": "Store this key securely. It will not be shown again."
}
```

Two consequences worth planning around:

- **A key cannot mint another key.** These endpoints only accept a browser-session JWT, so there is no unattended key-bootstrapping flow. Create the first key in the dashboard.
- **Scopes are not settable at creation.** The endpoint accepts a name only; the key is issued with a default scope set. To get a key with specific scopes, use the dashboard or ask an admin.

You must be a member of the `:businessId` you pass, or the call returns `403`.

### Use an API key

Send the key in the `X-API-Key` header:

```bash
curl -H "X-API-Key: $AMDAHL_KEY" ...
```

The `Authorization: Bearer` header is reserved for OAuth access tokens and Supabase JWTs, not API keys.

### Storage and security

- Plaintext keys are returned on creation only. They are never retrievable again.
- The server stores only a SHA-256 hash. Rows in `mcp_api_keys` record a short display prefix — `amdhl_` plus the first 8 characters of the key, for example `amdhl_abc123de`.
- Keys are per-user plus per-business. Switching business context requires a different key.
- Every key has the form `amdhl_` followed by 48 hex characters. There is no separate live/test prefix: test keys carry `is_test=true` in the database but are otherwise indistinguishable by prefix.

### List keys

```bash
curl -s "https://app.amdahl.co/api/mcp/api-keys/$BUSINESS_ID" \
  -H "Authorization: Bearer $SUPABASE_JWT"
```

Returns `{ "keys": [...] }` — metadata only (id, name, display prefix, scopes, `last_used_at`). Plaintext key values are never returned.

### Rotate a key

There is no rotation endpoint. Roll a key manually, in this order, so you are never without a working credential:

1. Create the replacement key (dashboard or the endpoint above).
2. Deploy it to your integration and confirm traffic has moved — watch `last_used_at` on the new key.
3. Revoke the old key.

### Revoke a key

```bash
curl -s -X DELETE "https://app.amdahl.co/api/mcp/api-keys/$BUSINESS_ID/$KEY_ID" \
  -H "Authorization: Bearer $SUPABASE_JWT"
```

Revocation is immediate. An unknown or already-revoked key id returns `404`. Because this endpoint authenticates with a session JWT rather than the key itself, revoking the key your integration is currently using is possible — follow the rotation order above to avoid an outage.

### scope_mode: legacy vs strict

Every API key carries a `scope_mode` field that governs how the unified tools layer interprets its scopes:

- `strict` (the default for every new key): the key's exact scopes are used. If a tool requires `pages:write` and the key lacks it, the call returns `403 forbidden`.
- `legacy` (back-fill only for keys issued before the unified tools layer): role defaults are applied as a grandfather rule, and every request emits an audit flag `legacy_mode = true` for tracking.

You will not set `scope_mode` yourself unless you are migrating historical data.

## OAuth 2.1

Use OAuth when your app acts on behalf of an end user and you cannot securely store a long-lived key (for example a desktop app or a third-party integration that signs users in).

The platform implements RFC 7591 dynamic client registration and authorization code with PKCE (S256 only).

### Dynamic client registration

```bash
curl -s -X POST "https://app.amdahl.co/oauth/register" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My desktop integration",
    "redirect_uris": ["https://myapp.example/oauth/callback"]
  }'
```

Response:

```json
{
  "client_id": "mcp_abc123...",
  "client_secret": "...",
  "redirect_uris": ["https://myapp.example/oauth/callback"]
}
```

Redirect URIs must be HTTPS in production; `http://localhost` and `http://127.0.0.1` are allowed for local development only.

### Authorization code + PKCE

1. Generate a `code_verifier` (43 to 128 chars, URL-safe) and derive `code_challenge = BASE64URL(SHA256(verifier))`.
2. Send the user to:

   ```
   https://app.amdahl.co/oauth/authorize
     ?response_type=code
     &client_id=mcp_abc123...
     &redirect_uri=https://myapp.example/oauth/callback
     &code_challenge=<challenge>
     &code_challenge_method=S256
     &scope=data:read%20pages:read%20pages:write
     &state=<random-anti-csrf>
   ```

3. After consent, the user is redirected back with `?code=...&state=...`.
4. Exchange the code for an access token:

   ```bash
   curl -s -X POST "https://app.amdahl.co/oauth/token" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d "grant_type=authorization_code" \
     -d "code=<code>" \
     -d "redirect_uri=https://myapp.example/oauth/callback" \
     -d "client_id=mcp_abc123..." \
     -d "code_verifier=<verifier>"
   ```

Response:

```json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "data:read pages:read pages:write"
}
```

### Refreshing tokens

```bash
curl -s -X POST "https://app.amdahl.co/oauth/token" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=<refresh_token>" \
  -d "client_id=mcp_abc123..."
```

### Revoking tokens

Revocation is per-token (RFC 7009), not per-client:

```bash
curl -s -X POST "https://app.amdahl.co/oauth/revoke" \
  -H "Content-Type: application/json" \
  -d '{ "token": "<access_or_refresh_token>" }'
```

Per RFC 7009 this always returns `200`, including for a token that was already invalid — so a success response confirms the token is not usable, not that it existed.

There is no endpoint that revokes a client and all of its tokens at once. Note also that access is re-checked on every request: a token whose user has been removed from the workspace stops working immediately, without waiting for expiry or an explicit revocation.

## Scopes

Scopes are fine-grained `resource:action` strings enforced on every tool call. Examples: `pages:write`, `context:read`, `webhooks:delete`.

Effective scopes on a request are the intersection of:

1. The scopes granted on the credential (API key or OAuth token), and
2. The default scopes for the user's role in the business (`viewer`, `editor`, `admin`, `owner`).

A request that asks for an action outside its effective scope returns `403 forbidden` with `details.missing_scope` naming the scope that would unblock it.

Each tool's required scope is listed in the [tool catalog](./api-reference/tool-catalog.md).

## JWT (web app only)

The first-party Amdahl web app signs in with Supabase Auth and calls the Platform API with the Supabase access token (`Authorization: Bearer <jwt>`).

Unlike the other two credentials, a JWT does not name a workspace on its own, so this path also requires an `X-Client-Slug: <workspace-slug>` header. The JWT establishes who you are; the slug selects which workspace the request is scoped to, and membership is checked against `business_members`. A JWT request without the slug header is rejected with `400 Missing X-Client-Slug header`, and a slug you are not a member of returns `403`.

This path is not intended for external integrations: tokens are short-lived (one hour) and bound to browser session state. External integrations should use an API key or OAuth.

## FAQ

**My request returned `401 unauthenticated`.** Your header is missing, malformed, or pointing at a revoked credential. Re-read [Quickstart step 2](./quickstart.md) and confirm the header shape.

**My request returned `403 forbidden` with `missing_scope`.** The credential is valid but lacks the scope that tool requires. Create a key with the needed scope from the dashboard, or grant the user a higher role. Cross-reference the [tool catalog](./api-reference/tool-catalog.md) for each tool's required scope.

**My OAuth access token expired.** Use the refresh token flow above. Refresh tokens stay valid until the token is revoked via `/oauth/revoke` or the user loses access to the workspace.

**I lost the plaintext value of my API key.** Keys cannot be recovered. Create a replacement, move your integration onto it, then revoke the old one.

**Can I share one API key across teammates?** No. Keys are per-user plus per-business. Mint one per integration or per operator so audit rows attribute actions correctly.

**Can I call the API without a business context?** No. Every credential resolves to exactly one `business_id`. To operate against multiple businesses, mint a key per business.
