diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index edb08c0..12d75cf 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -156,10 +156,12 @@ and `app.json` together, or pairing breaks. version. **Server:** `handleServerInfo` advertises `admin_ui`, `web_player`, `upload`, -`transcode`, `websocket`, plus the server version (`api.Version`, stamped from the -release tag via ldflags). `transcode` reflects ffmpeg availability; `web_player` -reflects whether `/web` is populated. (`upload` and `websocket` are reserved for -**planned** phases - `POST /uploads` and WebSocket sync are not shipped.) +`transcode`, `websocket`, `api_keys`, plus the server version (`api.Version`, +stamped from the release tag via ldflags). `transcode` reflects ffmpeg +availability; `web_player` reflects whether `/web` is populated; `api_keys` +reflects that the server accepts user-minted API keys. (`upload` and `websocket` +are reserved for **planned** phases - `POST /uploads` and WebSocket sync are not +shipped.) **Frontend:** the `ServerInfo` type; feature gating and the "connected server version" display key off it. diff --git a/docs-developers/contributing/cross-repo-changes.md b/docs-developers/contributing/cross-repo-changes.md index 638d0ce..fc8bb4a 100644 --- a/docs-developers/contributing/cross-repo-changes.md +++ b/docs-developers/contributing/cross-repo-changes.md @@ -102,8 +102,8 @@ each side gated by its own CI. Any shipped app build must be able to talk to any server version, so features are negotiated, never assumed. `GET /api/v1/server` advertises capability flags - -`admin_ui`, `web_player`, `upload`, `transcode`, `websocket` - plus the server -version. +`admin_ui`, `web_player`, `upload`, `transcode`, `websocket`, `api_keys` - plus +the server version. Adding a feature that isn't universally available is a two-repo pattern: diff --git a/docs-developers/frontend/overview.md b/docs-developers/frontend/overview.md index 1fc7a17..8093810 100644 --- a/docs-developers/frontend/overview.md +++ b/docs-developers/frontend/overview.md @@ -85,7 +85,7 @@ handful of standalone screens. | `/library/[libraryId]/[...path]` | `(app)/library/[libraryId]/[...path].tsx` | Nested folder browse - same `BrowseScreen`, the catch-all segments become the folder `path` (helpers in `src/lib/paths.ts`). | | `/book/[libraryId]/[...path]` | `(app)/book/[libraryId]/[...path].tsx` | Book detail: play/resume, download control, chapters, bookmarks, notes, listening history, other versions of the same book. | | `/downloads` | `(app)/downloads.tsx` | Downloaded books + storage used ([Offline](offline.md)). | -| `/settings` | `(app)/settings.tsx` | Playback tunables, language, theme, connections, self-service password/recovery, sign-out. | +| `/settings` | `(app)/settings.tsx` | Playback tunables, language, theme, connections, self-service password/recovery, per-server API keys (capability-gated), sign-out. | | `/player` | `src/app/player.tsx` | The full player, presented as a full-screen modal above the shell. Accepts `libraryId`/`path` (+ optional `position`/`track`) params and gates playback start on the chapters query settling. | | `/finished` | `src/app/finished.tsx` | The end-credits screen shown when a book finishes (or from the player's menu). A root modal sibling of the player; renders `EndCredits` with an "up next" suggestion. See [Playback](playback.md#ending-a-book-end-credits-and-up-next). | | `/connect` layout | `src/app/connect/_layout.tsx` | Onboarding stack. An **authenticated** user is bounced home unless they are adding another server (`?add=1`, a pairing `?token=`, or a sign-in mid-flow via `pendingServerUrl`) - the app supports multiple simultaneous server connections. | diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 0fffee2..5a18802 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -76,6 +76,17 @@ since the user is present and can mint another. See [Auth & security](../auth-and-security.md) for the trust model behind codes, tokens, and hashes. +A user can also mint a **personal API key** (`POST /auth/tokens`) - a +non-expiring bearer credential for headless integrations such as dashboards and +cron. It is presented in the same `Authorization: Bearer …` header and +authenticates exactly like a session token, **acting as its owner**: an admin's +key satisfies the admin-role check, a regular user's does not. Pairing tokens +are never accepted as a bearer credential, and an API key is never valid for +`/auth/exchange`. It also **cannot mint a fresh durable credential** - an API-key +caller is refused (403) on the credential-minting routes (create key, recovery, +pair, set password), so revoking a leaked key cuts off everything it could reach. +See the [reference](reference.md#personal-api-keys). + ### Media requests: `?token=` - media GETs only `GET /libraries/{id}/cover` and `GET /libraries/{id}/stream` accept the session @@ -104,7 +115,7 @@ Status mapping is consistent across handlers: |---|---| | `400` | malformed body / unknown JSON field, missing or invalid parameter (`path is required`, `invalid cursor`, non-integer `{id}`), path escaping the library root, domain validation (`mode must be "book" or "collection"`, admin needs a password, password too short) | | `401` | missing/invalid/expired token, bad credentials, invalid auth code, wrong `current_password` | -| `403` | authenticated but not allowed: no share grants the library or path, `admin only`, demo accounts on password/recovery routes, bad setup token | +| `403` | authenticated but not allowed: no share grants the library or path, `admin only`, demo accounts on the self-service routes (password/recovery/API keys), an API key on a credential-minting route (create key/recovery/pair/password), bad setup token | | `404` | library/user/share/invite not found, `no book at that path`, feature not configured (demo mode off, well-known files unset) | | `409` | conflicts: `name already taken` (library/share), last-enabled-admin guard, setup already completed | | `429` | a rate limiter tripped (see below) | @@ -185,7 +196,8 @@ a `limit` and no pagination. "web_player": true, "transcode": true, "upload": false, - "websocket": false + "websocket": false, + "api_keys": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -194,9 +206,10 @@ a `limit` and no pagination. Clients **must** feature-gate on these flags rather than probing endpoints: `transcode` reflects whether ffmpeg is configured (without it, `?transcode=1` -is 503), `web_player` whether `/web` is mounted, `upload`/`websocket` are -roadmap phases that will flip on when they land. `demo.enabled` drives the -"Try the demo" affordance. +is 503), `web_player` whether `/web` is mounted, `api_keys` whether the server +supports user-minted [API keys](reference.md#personal-api-keys), +`upload`/`websocket` are roadmap phases that will flip on when they land. +`demo.enabled` drives the "Try the demo" affordance. ## Rate limiting @@ -209,7 +222,7 @@ client IP. Tripping any of them returns **429** with an error envelope. | Login lockout | `POST /auth/login` | 10 *failed* attempts per 15 min per IP; a success resets the counter | | Redeem lockout | `POST /auth/redeem` | 10 *failed* attempts per 15 min per IP; a success resets | | Demo cap | `POST /demo/session` | at most 5 demo sessions per IP per 15 min, metered at admission (failures count too) | -| Account mutations | `POST /auth/password`, `POST /auth/recovery` | at most 10 attempts per IP per 15 min, metered at admission | +| Account mutations | `POST /auth/password`, `POST /auth/recovery`, `/auth/tokens` (create/list/revoke) | at most 10 attempts per IP per 15 min, metered at admission | **Client IP resolution:** `X-Forwarded-For` is honored only when the direct peer is inside a `trusted_proxies` CIDR (config); otherwise the TCP peer address diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 8b1ea87..cb7ee61 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -9,7 +9,10 @@ in the [API conventions](index.md) page and are not repeated per endpoint. **Auth legend** - *Public*: no token. *Session*: bearer session token. *Session (media)*: session token via header **or** `?token=` query parameter. -*Admin*: session token + `admin` role. +*Admin*: session token + `admin` role. A personal **API key** authenticates as +its owner anywhere a *Session* token does - so an admin's key also satisfies +*Admin* - with one carve-out: it is refused on the credential-minting routes (see +[Personal API keys](#personal-api-keys)). All `/api/v1` bodies and responses are JSON. Timestamps are RFC 3339. Remember that empty list fields may serialize as `null`. @@ -32,7 +35,8 @@ else and gate features on the flags. "web_player": true, "transcode": true, "upload": false, - "websocket": false + "websocket": false, + "api_keys": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -47,7 +51,8 @@ pairing exchange (`POST /auth/exchange`) and login/demo responses, so a client h it the moment it pairs. `version` is stamped from the release tag (`"dev"` for local builds). `transcode` is true only when ffmpeg is configured; `web_player` only when the `/web` mount is populated; `upload` and `websocket` are reserved for future -phases and currently always false. +phases and currently always false. `api_keys` is true on servers that support +user-minted [personal API keys](#personal-api-keys). ### `GET /healthz` · `GET /api/v1/healthz` @@ -219,9 +224,10 @@ omitted when there is none. `role` is `"admin"` or `"user"`. ## Self-service account -Both mutating routes here share a rate limit (10 attempts per IP per 15 -minutes) and are **refused for demo accounts** (403), so a throwaway session -can't mint a durable login. +The routes in this section, together with the [API keys](#personal-api-keys) +below, share one rate limit (10 attempts per IP per 15 minutes) and are +**refused for demo accounts** (403), so a throwaway session can't mint a durable +credential. ### `POST /api/v1/auth/password` @@ -260,6 +266,90 @@ Response `201`: *Session.* Removes the caller's recovery code (no-op if none). Response: `204 No Content`. +## Personal API keys + +User-minted, **non-expiring** bearer credentials ("API keys") for headless +integrations - dashboards, cron jobs, monitoring. A key authenticates on any +*Session* or *Session (media)* route exactly like a session token and **acts as +its owner**: an admin's key also passes *Admin* routes, a regular user's does +not. A pairing token is never accepted as a bearer credential, and an API key is +never valid for `/auth/exchange`. Revocation is the only lifecycle - a key never +expires. All three routes share the [self-service](#self-service-account) rate +limit (10 attempts per IP per 15 minutes) and are **refused for demo accounts** +(403). + +**Containment.** A key cannot mint a *fresh durable credential*. A request that +authenticates with an API key is refused (**403**) on the four credential-minting +routes - `POST /auth/tokens` (another key), `POST /auth/recovery`, +`POST /auth/pair`, and `POST /auth/password` - so revoking a leaked key cuts off +everything it could reach: it can't spawn a key, recovery code, pairing token, or +password that would outlive its own revocation (mirrors GitHub's "a token cannot +create tokens"). It may still **list and revoke** keys and clear a recovery code, +since those only reduce access. + +### `POST /api/v1/auth/tokens` + +*Session.* Mints an API key and returns the secret **exactly once**. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `label` | string | yes | display name; trimmed, 1-100 characters | + +Response `200`: + +```json +{ + "token": "Qm9WZbT5nR8sHc2fLdA7yUqPgVi4oXk1NwsE3vJx0eK", + "api_key": { + "id": 3, + "label": "Home dashboard", + "created_at": "2026-07-08T14:02:11Z", + "last_seen": null + } +} +``` + +`token` is the bearer secret - only its SHA-256 hash is stored, so this is the +one time it appears. `api_key.last_seen` is `null` until the key first +authenticates a request. + +| Status | Meaning | +|---|---| +| `400` | `label` missing/empty, or longer than 100 characters | +| `403` | demo account, or the caller is itself authenticating with an API key (containment - a key can't mint another) | +| `429` | account-mutation limit tripped | + +### `GET /api/v1/auth/tokens` + +*Session.* The caller's live (non-revoked) API keys, newest first - **metadata +only** (never the secret or its hash): + +```json +{ + "api_keys": [ + { + "id": 3, + "label": "Home dashboard", + "created_at": "2026-07-08T14:02:11Z", + "last_seen": "2026-07-09T06:30:00Z" + } + ] +} +``` + +`403` for demo accounts; `429` on the shared limit. + +### `DELETE /api/v1/auth/tokens/{id}` + +*Session.* Revokes one of the caller's **own** API keys by id; it stops working +immediately. Response: `204 No Content`. + +| Status | Meaning | +|---|---| +| `403` | demo account | +| `404` | no such API key for this caller (missing, already revoked, another user's, or not an API-key token id) | +| `429` | account-mutation limit tripped | + ## Demo ### `POST /api/v1/demo/session` diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md index af97c0d..3cf12fd 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -36,10 +36,10 @@ plaintext credential ever appears, exactly once. Handlers return freshly-minted codes/tokens in the response body and store only the hash. ::: -## Tokens: session vs pairing +## Tokens: session, pairing, and API keys -`tokens.kind` distinguishes two lifetimes (`auth.KindSession`, -`auth.KindPairing`): +`tokens.kind` distinguishes three kinds (`auth.KindSession`, `auth.KindPairing`, +`auth.KindAPI`): - **Session tokens** are the durable bearer credential (`Authorization: Bearer …`). Issued by `POST /auth/login` and `POST /auth/exchange` with **no @@ -55,6 +55,24 @@ codes/tokens in the response body and store only the hash. (multi-scan within it, since recovery codes are unlimited); `/auth/pair` and demo tokens are unlinked - single-use (revoked on exchange) with the same 10-minute TTL. +- **API keys** (`auth.KindAPI`) are user-minted, **non-expiring** bearer + credentials for headless integrations (dashboards, cron), created / listed / + revoked via `POST` / `GET` / `DELETE /auth/tokens` + (`IssueAPIToken` / `ListAPITokens` / `RevokeTokenByID`). A key authenticates + exactly like a session token and **acts as its owner** - an admin's key also + passes `requireAdmin` - but it is never valid for `/auth/exchange` (pairing), + and its lifecycle is mint / list / revoke rather than sign-in / sign-out. Only + the SHA-256 hash is stored (the secret is returned once at creation) and the + user's label rides in `tokens.device_name`. All three routes go through + `gateSelfService`, so they share the `accountLimiter` and are refused for demo + accounts, exactly like the password/recovery routes. **Containment:** an API-key + caller is additionally barred (403, via `denyAPIKey` in + `internal/api/handlers_auth.go`) from the four credential-minting routes + (`POST /auth/{tokens,recovery,pair,password}`), so a leaked key can never spawn a + fresh durable credential - another key, recovery code, pairing token, or + password - that would outlive its own revocation (mirrors GitHub's "a token + cannot create tokens"). It can still list/revoke keys and clear a recovery code, + which only reduce access. `auth.ResolveToken` validates a presented secret for a specific kind: it hashes the secret, looks up the row, and rejects revoked tokens, expired tokens, and @@ -64,12 +82,16 @@ deliberately no `last_login` column. The middleware wrappers in `internal/api/middleware.go`: -- `requireAuth` - session token from the `Authorization` header only. +- `requireAuth` - a **session token or an API key** from the `Authorization` + header only (`ResolveTokenKinds(secret, KindSession, KindAPI)`); a pairing + token is never accepted here, so a QR/pairing secret can't be used as a durable + credential. - `requireMediaAuth` - additionally accepts `?token=` as a query parameter, used **only** for the two media GETs (`/cover`, `/stream`) because browser ``/`