# Authentication

# 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                | `Authorization: Bearer ak_live_...` or `X-API-Key: ak_live_...` | 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>`                                   | First-party web app only                       | Short-lived        |

All three land on the same authenticator. Behind the header, each request is resolved to `{ user_id, business_id, effective_scopes }` before any tool runs. Scope enforcement is identical across credential types, and every tool invocation writes one row to the platform audit log.

If both `Authorization` and `X-API-Key` are present, `Authorization` wins.

## API keys

API keys are the recommended path for almost every integration. They are long-lived, per-user plus per-business, and scoped explicitly at creation time.

### Create an API key

The fastest path is 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**, give it a name, and pick the scopes it needs.
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.

You can also mint a key programmatically with the `api_keys.create` tool, if your current credential holds the `keys:manage` scope:

```bash
curl -s -X POST "$AMDAHL_BASE/api_keys" \
  -H "Authorization: Bearer $AMDAHL_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My integration",
    "scopes": ["data:read", "context:read", "pages:read", "pages:write"]
  }'
```

Response (plaintext key is returned exactly once):

```json
{
  "id": "f1e2...",
  "key": "ak_live_abc123...",
  "key_prefix": "ak_live_abc123",
  "name": "My integration",
  "scopes": ["data:read", "context:read", "pages:read", "pages:write"],
  "scope_mode": "strict",
  "created_at": "2026-04-14T12:00:00.000Z"
}
```

Requested scopes must be a subset of the calling credential's scopes. Asking for a scope you do not hold returns `403 forbidden`.

### Use an API key

Two header formats are accepted:

```bash
# Preferred (matches OAuth shape; works everywhere)
curl -H "Authorization: Bearer $AMDAHL_KEY" ...

# Equivalent alternative
curl -H "X-API-Key: $AMDAHL_KEY" ...
```

### Storage and security

- Plaintext keys are returned on creation and on rotation. They are never retrievable again.
- The server stores only a SHA-256 hash. Rows in `api_keys` record the prefix (`ak_live_abc123`) for display.
- Keys are per-user plus per-business. Switching business context requires a different key.
- All keys use the `ak_live_` prefix in production. Test keys use `ak_test_` and carry `is_test=true`.

### Rotate a key

Rotation mints a replacement with the same scopes and either revokes the old key immediately (`grace_period_hours: 0`) or keeps it valid for a window (up to 168 hours) to let deployments roll forward:

```bash
curl -s -X POST "$AMDAHL_BASE/api_keys/$OLD_KEY_ID/rotate" \
  -H "Authorization: Bearer $AMDAHL_KEY" \
  -d '{ "grace_period_hours": 24 }'
```

The response includes a new plaintext key. Update your integration, confirm traffic has moved, then let the grace window expire.

### Revoke a key

```bash
curl -s -X DELETE "$AMDAHL_BASE/api_keys/$KEY_ID" \
  -H "Authorization: Bearer $AMDAHL_KEY"
```

Revocation is immediate. You cannot revoke the key you are currently authenticating with (lock-out prevention); rotate instead.

### scope_mode: legacy vs strict

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

- `strict` (default for every new key minted via `api_keys.create` or the dashboard): 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 OAuth clients

```bash
curl -s -X DELETE "$AMDAHL_BASE/oauth_clients/$CLIENT_ID" \
  -H "Authorization: Bearer $AMDAHL_KEY"
```

Revokes the client and invalidates every access and refresh token it holds. Irreversible. Only callable by a member of the business that registered the client.

## 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.

The full role default bundles and the complete tool-to-scope matrix are in [api-reference/scopes.md](./api-reference/scopes.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>`). This path is not intended for external integrations: tokens are short-lived (one hour), bound to browser session state, and scoped by the user's role defaults. 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. Mint or rotate a key with the needed scope, or grant the user a higher role. Cross-reference [api-reference/scopes.md](./api-reference/scopes.md).

**My OAuth access token expired.** Use the refresh token flow above. Refresh tokens are valid until the user explicitly revokes the client or an admin revokes the client via `oauth_clients.revoke`.

**I lost the plaintext value of my API key.** Keys cannot be recovered. Rotate (keeping the same scopes) or revoke and create a new 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.
