docs/mobile-api-keys.md — what the server accepts, what changes in monorepo-mobile, and the 401-vs-403 distinction, which is the one that bites: clearing a good key on a 403 turns a member's missing capability into a logout loop they cannot escape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
12 KiB
Markdown
262 lines
12 KiB
Markdown
# API keys — implementing the client side
|
|
|
|
Written 2026-08-08, for the mobile developer. Server side is live and verified; nothing below is
|
|
planned-but-missing unless it says so.
|
|
|
|
**The mobile apps are not built here.** This document is the boundary: what the server now accepts, what
|
|
changes in `monorepo-mobile`, and what does not.
|
|
|
|
---
|
|
|
|
## The short version
|
|
|
|
**An API key goes exactly where the JWT goes.** Same `Authorization: Bearer …` header, same `?token=`
|
|
query fallback for media, same `?token=` on the WebSocket. Every door was taught the new format at once,
|
|
so there is no endpoint where a key works and another where it doesn't.
|
|
|
|
That is the whole point of the design, and it means the client change is small:
|
|
|
|
- `packages/core/src/services/api.ts` needs **no change at all** — it already sends whatever
|
|
`getToken()` returns as a bearer token.
|
|
- `packages/core/src/state/useAuth.ts` gains a second way to obtain that value.
|
|
- Multi-server storage already exists (`tokenKeyFor(activeServerId())` in `services/servers.ts`), so a
|
|
key per server slots into the same SecureStore entry the JWT uses today.
|
|
|
|
The problem being solved is **multiple logins across the apps**. Today every app signs in with the
|
|
password and gets its own 30-day JWT, and there is no way to cut one device off without a password
|
|
change that kills all of them. A key is revocable on its own, from the web UI, in one click.
|
|
|
|
---
|
|
|
|
## What a key looks like
|
|
|
|
```
|
|
ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0
|
|
```
|
|
|
|
`ofk_` prefix, then 32 CSPRNG bytes base64url-encoded. **Treat it as opaque.** Do not parse it, do not
|
|
validate its length, do not assume 47 characters — the only guarantee is the `ofk_` prefix and that it is
|
|
URL-safe and header-safe.
|
|
|
|
- **It does not expire** unless one was asked for at creation. Assume no expiry.
|
|
- **It is shown exactly once**, by the response that creates it. The server stores a SHA-256; there is no
|
|
endpoint that reads a key back and there never will be. If it is lost, revoke and mint another.
|
|
- **It carries the account's full authority** — the same as the password, no more. A member's key is
|
|
still a member.
|
|
|
|
---
|
|
|
|
## Two ways for the app to get one
|
|
|
|
### (a) Mint it in-app — recommended
|
|
|
|
Sign in with the password once, immediately trade the JWT for a key, store the key, throw the JWT away.
|
|
No copy-paste, no typing 47 characters on a phone keyboard.
|
|
|
|
```
|
|
POST /api/auth/signin {email, password} → {token, user}
|
|
POST /api/api-keys {name: "iPhone 15"} → {entry, key} (Authorization: Bearer <token>)
|
|
store `key`, discard `token`
|
|
```
|
|
|
|
Name it after the device, not the app — the owner reads that string in the web UI when deciding what to
|
|
revoke, and "iPhone 15" is a decision they can make while "music" is not.
|
|
|
|
If you want one key per app rather than per device, name it `"<device> — <app>"`. Either is fine; be
|
|
consistent so the list stays readable.
|
|
|
|
### (b) Paste a key the owner made in the web UI
|
|
|
|
**Settings → Integrations → Personal → API keys.** Useful for a device that cannot show a sign-in form,
|
|
and as the recovery path when (a) fails. A paste field that accepts the key and skips signin entirely.
|
|
|
|
Validate only that it is non-empty and starts with `ofk_`, then make a real request and let the server
|
|
answer.
|
|
|
|
---
|
|
|
|
## Using it
|
|
|
|
Identical to the JWT in all three places:
|
|
|
|
| Where | How |
|
|
|---|---|
|
|
| Normal requests | `Authorization: Bearer ofk_…` |
|
|
| Media URLs (`<Image>`, `<Video>`, file `/raw`) | `?token=ofk_…` — URL-encode it |
|
|
| WebSockets | `?token=ofk_…` on the upgrade URL |
|
|
|
|
The `?token=` fallback is accepted on **every** protected `/api` route, not just media. That is
|
|
pre-existing behaviour and not something to rely on: it puts the credential in URLs, which reach proxy
|
|
logs. Use the header wherever a header is possible.
|
|
|
|
---
|
|
|
|
## What changes in the client
|
|
|
|
**1. `useAuth`: a key is a session.** The current `signin` throws when the response has no token
|
|
(`'Sign-in did not return a token'`). A key-based login never calls `/api/auth/signin` at all — it calls
|
|
`GET /api/auth/me` with the key to confirm it works and to learn who it belongs to, then persists it and
|
|
sets the signed-in state.
|
|
|
|
**2. Do not call `POST /api/auth/signout` when signed in with a key.** It is harmless — the server skips
|
|
the blacklist step for a key and returns `{ok: true}` — but it does nothing useful either. "Sign out"
|
|
with a key means: clear it locally. If the user wants it dead server-side, that is **revoke**, and it
|
|
belongs in the app's settings screen, not on the sign-out button. Wording matters here: signing out of
|
|
one phone should not silently disable a key another app is using.
|
|
|
|
**3. Store it exactly where the token goes.** `tokenKeyFor(activeServerId())` in SecureStore. Nothing
|
|
else needs to know which kind of credential it holds — that is what makes this change small.
|
|
|
|
**4. Optionally: let the app revoke its own key.** `DELETE /api/api-keys/:id` works when authenticated
|
|
with that same key. Keep `entry.id` from the create response if you want a "disconnect this device"
|
|
button.
|
|
|
|
---
|
|
|
|
## Error semantics — the part worth getting right
|
|
|
|
**Error bodies are plain text, not JSON.** `Unauthorized`, `Forbidden`, `Not Found`, `Invalid request
|
|
body`. There is no `{error}` or `{message}` envelope anywhere. `packages/core/src/services/api.ts`
|
|
already handles this correctly (it tries JSON and falls back to raw text) — do not "fix" it.
|
|
|
|
The one exception: **429** returns `{"retryAfter": <seconds>}` as JSON, with **no `Retry-After`
|
|
header**. Rate limiting applies to `/api/auth/*` only, so a key-based client that never calls signin
|
|
will not meet it.
|
|
|
|
**401 and 403 mean different things and must be handled differently.**
|
|
|
|
| Status | Meaning | What the app should do |
|
|
|---|---|---|
|
|
| **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. |
|
|
| **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. |
|
|
|
|
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a
|
|
logout loop they cannot escape, because signing in again produces a credential with the same 403.
|
|
|
|
A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached.
|
|
|
|
---
|
|
|
|
## What a key can and cannot reach
|
|
|
|
Authorization is unchanged by how you authenticated. A key resolves to a user, and that user's role
|
|
decides everything after.
|
|
|
|
- **The owner** (user 1) reaches everything.
|
|
- **Any other account** reaches only what its role has been granted, and **can never** reach the
|
|
`execution` capabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
|
|
user in the owner's home; they are refused structurally, not by policy.
|
|
|
|
Verified: a member's key returns the same status as that member's JWT on every route tried, 403s
|
|
included. If you see a key behave differently from a password login for the same account, that is a bug —
|
|
report it, don't work around it.
|
|
|
|
For sockets specifically: `cliamp` and `cliamp-audio` (music playback) are grantable. `chat`,
|
|
`terminal`, `task-runner`, `pipeline` and `desktop` are owner-only. `useChatSocket` therefore works for
|
|
the owner and will always 403 for a member — that is not new, and not caused by keys.
|
|
|
|
---
|
|
|
|
## Endpoint reference
|
|
|
|
All three require an authenticated caller and act only on that caller's own keys. Nothing accepts a user
|
|
id; there is no request shape that reaches another account's keys.
|
|
|
|
### `POST /api/api-keys`
|
|
|
|
```jsonc
|
|
// request
|
|
{ "name": "iPhone 15", "expiresInDays": 90 } // expiresInDays optional; omit for no expiry
|
|
|
|
// 200
|
|
{
|
|
"entry": {
|
|
"id": 1, "userId": 1, "name": "iPhone 15",
|
|
"prefix": "ofk_eRgp_F", // display only — first 10 chars, never enough to use
|
|
"lastUsedAt": null, "expiresAt": null, "revokedAt": null,
|
|
"createdAt": "2026-08-08T10:08:58.687Z"
|
|
},
|
|
"key": "ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0" // the only time this appears
|
|
}
|
|
```
|
|
|
|
`400` on a missing/blank name, a name over 100 characters, or a non-positive `expiresInDays`.
|
|
|
|
### `GET /api/api-keys`
|
|
|
|
```jsonc
|
|
{ "keys": [ /* entry objects as above, newest first — never the key itself */ ] }
|
|
```
|
|
|
|
`lastUsedAt` is debounced to at most one write a minute, so it can lag by up to 60 seconds. It is a
|
|
"which of these is still in use" signal, not an audit trail.
|
|
|
|
### `DELETE /api/api-keys/:id`
|
|
|
|
`{"ok": true}`, or `404` if the id is not yours or is already revoked — deliberately the same answer for
|
|
both, so the endpoint cannot be used to discover whether an id exists.
|
|
|
|
---
|
|
|
|
## Things that will surprise you
|
|
|
|
- **No `Origin` header is needed today.** `ALLOW_ANY_ORIGIN` defaults to on, so origin checking is off
|
|
and the apps work sending none — which is what they do. Nothing here changes that. If it is ever
|
|
switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that
|
|
is a separate conversation, not part of this work.
|
|
- **`/api/auth/signin` can return 200 with no token.** Pre-existing: it happens when the account has a
|
|
passkey registered against the caller's origin, and it means "now do WebAuthn". Mobile sends no
|
|
`Origin`, so it never triggers today. Minting a key at first sign-in and never signing in again makes
|
|
the app immune to it permanently — a real reason to prefer path (a).
|
|
- **`user` in the signin response has no `role`.** It is `{id, email, name, username, passkeys}` where
|
|
`passkeys` is a count. Role comes from `GET /api/auth/me`. The mobile `AuthUser` type currently
|
|
declares `role` as required, which is wrong for signin — worth fixing while you are in there.
|
|
- **A key can mint another key.** There is no parent/child link, so revoking a key does not revoke ones
|
|
created with it. This is a consequence of a key carrying full account authority and is accepted for
|
|
now; it is the strongest single argument for scoped keys.
|
|
|
|
---
|
|
|
|
## Not built
|
|
|
|
- **Scopes.** A key cannot be narrowed to a subset of its holder's capabilities. The column and the check
|
|
are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is
|
|
there today. Design as if every key is full-authority, because it is.
|
|
- **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave —
|
|
revocation from a phone that has been lost is not a thing you can do from the phone.
|
|
- **Server-side "sign out everywhere".** Password change invalidates JWTs but deliberately **not** API
|
|
keys, since rotating them independently is the reason they exist. If the owner wants everything dead,
|
|
they revoke each key.
|
|
|
|
---
|
|
|
|
## Verifying against a real server
|
|
|
|
```bash
|
|
BASE=https://officer.pastilhas.dev
|
|
|
|
TOKEN=$(curl -s -X POST $BASE/api/auth/signin -H 'Content-Type: application/json' \
|
|
-d '{"email":"you@example.com","password":"…"}' | jq -r .token)
|
|
|
|
KEY=$(curl -s -X POST $BASE/api/api-keys -H "Authorization: Bearer $TOKEN" \
|
|
-H 'Content-Type: application/json' -d '{"name":"curl test"}' | jq -r .key)
|
|
|
|
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY" # 200
|
|
ID=$(curl -s $BASE/api/api-keys -H "Authorization: Bearer $KEY" | jq -r '.keys[0].id')
|
|
curl -s -X DELETE $BASE/api/api-keys/$ID -H "Authorization: Bearer $KEY" # {"ok":true}
|
|
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY" # 401
|
|
```
|
|
|
|
That sequence — create, use, revoke, 401 — is the one that was run against the live server before this
|
|
document was written, along with a WebSocket upgrade on `/api/cliamp/ws?token=<key>` returning 101.
|
|
|
|
---
|
|
|
|
## Where this lives on the server
|
|
|
|
- `src/servers/auth-token.ts` — the key format and `resolveAuthToken`, the single function that turns a
|
|
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
|
|
WebSocket upgrade in `server.tsx`, and the vault socket.
|
|
- `src/servers/api/api-keys/router.ts` — the three endpoints.
|
|
- `src/databases/officer_db/src/schema/api-keys.ts` — the table, and why it stores what it stores.
|