Files
hapi/docs/api/client-contract/auth.md
T

138 lines
8.2 KiB
Markdown

# Auth & pairing
How a native client obtains and maintains credentials for one hub. A client may be paired with multiple hubs; everything below is **per hub URL**.
## Credential model
Source of truth: `hub/src/config/cliApiToken.ts`, `hub/src/web/routes/auth.ts`.
| Credential | Lifetime | Where it comes from | What it's for |
|------------|----------|--------------------|---------------|
| **Access token** | Long-lived (until the operator rotates `CLI_API_TOKEN`) | Pairing QR / deeplink, or typed in manually | Exchanged for a JWT via `POST /api/auth`; the client's durable secret |
| **JWT** | 4 hours | Response of `POST /api/auth` | `Authorization: Bearer` on every `/api/*` request |
The hub's base token (`CLI_API_TOKEN`) is auto-generated on first run (32 random bytes, base64url, ~43 chars) and persisted in the hub's `settings.json`; an operator-provided env token overrides it. The configured base token never contains a `:` — the hub refuses to start otherwise (`validateCliApiToken`).
## Pairing
Source of truth: `hub/src/startHub.ts`, `web/src/components/settings/CompanionPairing.tsx`.
The hub terminal (when started with `--relay`) prints two QR codes; the web app's Settings → Companion pairing screen renders the second one as well:
| QR | Format | Params |
|----|--------|--------|
| Web direct-access | `https://app.hapi.run/?hub=<url>&token=<accessToken>` | `hub`, `token` |
| Companion deeplink | `hapicompanion://bind?hub=<url>&code=<accessToken>` | `hub`, `code` |
Native clients register the `hapicompanion://` scheme and parse `bind` links: `hub` is the hub base URL, `code` is the access token. Note the param-name mismatch: the web QR carries the same value under `token=`, the companion deeplink under `code=`. A robust scanner may accept both forms; the deeplink form is the canonical one for natives. Always provide a manual fallback (type hub URL + access token) for `--relay`-less local hubs.
## Access-token grammar
Source of truth: `hub/src/utils/accessToken.ts`.
```
accessToken = base [":" namespace]
```
- Split on the **last** `:`. No colon → namespace defaults to `"default"`.
- After splitting, both parts must be non-empty and contain no leading/trailing whitespace, else the token is invalid.
- The whole input is trimmed before parsing.
Clients must treat the access token as an **opaque string** and pass it through unchanged to `POST /api/auth` — never split it client-side to "normalize" it. The hub does the splitting; the namespace part selects which sessions the resulting JWT can see (see [Namespaces](#namespaces)).
## Token exchange
Source of truth: `hub/src/web/routes/auth.ts`.
```
POST /api/auth
Content-Type: application/json
{ "accessToken": "<code from pairing>" }
```
Success (200):
```json
{
"token": "<JWT>",
"user": { "id": 1, "firstName": "Web User" }
}
```
Failures: `400 {"error": "Invalid body"}`, `401 {"error": "Invalid access token"}`. Request schema: `AuthRequestSchema` in `shared/src/apiTypes.ts` (the `initData` variant is Telegram-only).
`POST /api/bind` (`hub/src/web/routes/bind.ts`) is **Telegram-only** — it binds a Telegram identity to a namespace and requires Telegram `initData`. Native clients never call it.
## The JWT
Source of truth: `hub/src/web/routes/auth.ts` (signing), `hub/src/web/middleware/auth.ts` (verification), `hub/src/config/jwtSecret.ts` (key).
- HS256, signed with a hub-local 32-byte secret (`<dataDir>/jwt-secret.json`).
- Payload: `{ "uid": <number>, "ns": <string> }` plus standard `iat`/`exp`.
- **Expires 4 hours** after issue.
Treat the token as opaque for auth purposes, but clients may base64url-decode the payload to read `exp` for proactive refresh scheduling (the web client does exactly this — `decodeJwtExpMs` in `web/src/hooks/useAuth.ts`).
## Sending the token
Source of truth: `hub/src/web/middleware/auth.ts`.
- Every `/api/*` request: `Authorization: Bearer <JWT>`.
- Exceptions: `/api/auth` and `/api/bind` are unauthenticated; `GET /health` is outside `/api` and unauthenticated.
- `GET /api/events` (SSE) **additionally** accepts `?token=<JWT>` as a query param, for HTTP stacks whose EventSource cannot set headers. The header wins when both are present. No other endpoint accepts query-param auth.
## Silent re-auth (401 handling)
Reference behavior: `web/src/api/client.ts` (`request()`), `web/src/hooks/useAuth.ts` (`refreshAuth`).
The JWT expires every 4 hours, so 401s are routine, not exceptional. The contract:
1. On any 401 from an `/api/*` call, re-exchange the **stored access token** via `POST /api/auth`.
2. If the exchange succeeds, retry the original request **exactly once** with the new JWT.
3. If the exchange fails (or the retry 401s again), surface "signed out" and require re-pairing — the access token was rotated or revoked.
Implementation notes (all present in the web reference and recommended for natives):
- **Single-flight** the refresh: concurrent 401s must share one in-flight `POST /api/auth` promise, not race N exchanges (`refreshPromiseRef` in `useAuth.ts`).
- Throttle failed refresh attempts (web: 15 s between attempts) so a dead hub doesn't cause a refresh storm.
- Optionally refresh proactively: the web schedules a refresh 60 s before `exp` and on app-foreground when remaining TTL < 60 s. This keeps the SSE connection (which authenticates once, at connect time) from dying mid-stream with a stale token on reconnect.
## Namespaces
Source of truth: `hub/src/web/middleware/auth.ts` (sets `namespace` from `ns`), `hub/src/web/routes/guards.ts`, `hub/src/web/routes/{usage,storage,hubSettings,voice}.ts`.
Every request executes in the JWT's namespace (`ns` claim, derived from the access-token suffix). Sessions and machines are namespace-scoped: a session in another namespace answers `403 Session access denied` / `404 Session not found` per the guard logic.
`ns === "default"` is the **hub owner**. Owner-only surfaces (403 for any other namespace):
| Endpoint | Check |
|----------|-------|
| `GET /api/usage/summary` | `hub/src/web/routes/usage.ts` |
| `GET /api/storage/sqlite` | `hub/src/web/routes/storage.ts` |
| `PUT /api/hub-settings` (write; read is open to all namespaces) | `hub/src/web/routes/hubSettings.ts` |
| `GET`/`PUT /api/voice/transcription/credentials` | `hub/src/web/routes/voice.ts` |
Clients should hide the usage/storage screens entirely when the paired namespace is not `default` (the namespace is known client-side: it's the part after the last `:` of the access token, or `default`).
## Credential storage guidance
- Store the **access token** in platform-secure storage: iOS Keychain, Android `EncryptedSharedPreferences` (behind an interface so the mechanism can be swapped). Never plain files, never logs.
- Key credentials **per hub base URL** (normalized), since a client can pair with several hubs. Web reference: localStorage key `hapi_access_token::<baseUrl>` (`web/src/hooks/useAuth.ts`, `web/src/components/settings/CompanionPairing.tsx`).
- The JWT is a sensitive bearer credential, but need not be persisted: hold it in memory and re-exchange on cold start. If persisted (to save one round-trip at launch), store it alongside the access token with the same protection.
- On unpair/sign-out: delete both credentials, and unregister native push (`DELETE /api/devices/register`) first while you still hold a valid JWT.
## 401 error bodies {#401-error-bodies}
All are JSON with an `error` string; none carry a `code` field except Telegram's `not_bound` (which reuses `error` as the discriminator — natives never see it):
| Origin | Body | Meaning |
|--------|------|---------|
| Middleware, any `/api/*` | `{"error": "Missing authorization token"}` | No bearer header (and no `?token=` on `/api/events`) |
| Middleware, any `/api/*` | `{"error": "Invalid token"}` | JWT signature/expiry verification failed → run silent re-auth |
| Middleware, any `/api/*` | `{"error": "Invalid token payload"}` | JWT valid but payload not `{uid, ns}` (foreign/ancient token) |
| `POST /api/auth` | `{"error": "Invalid access token"}` | Access token wrong or rotated → require re-pairing |
| `POST /api/auth` (Telegram path) | `{"error": "not_bound"}` | Telegram-only; not reachable with `accessToken` auth |
The re-auth loop must distinguish the middleware 401s (recoverable via re-exchange) from the `/api/auth` 401 (terminal — do not loop).