Scoped API tokens for programmatic access from other devices #109

Closed
opened 2026-09-04 16:03:56 +02:00 by lz · 0 comments
Owner

Want

Register long-lived API tokens in Nexus that other devices can use to call the HTTP API, each token limited to a chosen set of capabilities. Concrete first use case: a token that can do nothing but GET /api/quota, so a phone widget / status bar / dashboard elsewhere can read fleet quota without holding credentials that could spawn a workspace or read the config volume.

Designed. Spec: docs/superpowers/specs/2026-09-04-api-tokens-design.md.

Why the current auth model can't do this

Auth today is a single operator passphrase → an in-memory SessionStore entry holding the Argon2id-derived master key, addressed by an httpOnly cookie (nexus/src/lib/server/auth/session.ts, routes/api/auth/unlock/+server.ts). Consequences for a headless caller:

  • All or nothing. hooks.server.ts gates on session existing, full stop. There is no capability dimension.
  • Not durable. Sessions die on TTL and on every Nexus restart (see #108). A device token must survive both.
  • Carries the master key. event.locals.masterKey is the unlocked vault key. A token minted for reading quota must never imply the vault is unlocked.

Shape

  • Table api_tokens: id, label, token_hash, scopes (JSON array), created_at, last_used_at, expires_at nullable (null = never). Revoke is a hard delete, per the repo's no-soft-delete convention.
  • Hash with SHA-256, not Argon2id. (Corrected — an earlier revision of this issue said Argon2id.) Argon2id exists to make guessing a low-entropy human secret expensive. An API token is 32 CSPRNG bytes; it is not guessable at any hash speed, so slowness buys nothing while costing ~100 ms and a memory-hard allocation on every authenticated request — a script polling quota every 10 s would be a self-inflicted DoS. A salted hash also cannot be looked up, forcing a scan of every row. Unsalted SHA-256 with a unique index on the column is the standard choice for bearer tokens and the right one here.
  • Nothing sealed under the master key. No encrypted_* column, so rotateMasterPassphrase gains no pass and sealed-columns.test.ts needs no change.
  • Transport: Authorization: Bearer nxs_<32 bytes base64url>. The prefix makes tokens identifiable in logs and leak scanners. Plaintext is shown exactly once, at creation. Cookie auth is unchanged and remains the browser path.
  • hooks.server.ts gains a fourth branch: no cookie session, but a valid bearer token → set event.locals.tokenScopes and do not set event.locals.masterKey. Every vault-touching route already guards on masterKey, so that alone is defense in depth — but it is not the authorization model, because sensitive routes exist that need no vault key (/api/workers/[id]/logs, /api/settings/agent-instructions, artifacts/[artifactId]/raw).
  • Scope map keyed on event.route.id, the SvelteKit route pattern (/api/workers/[id]/health), not the URL path. Exact-string matched, no regex, no traversal surface; an unmapped route is denied by default.
  • v1 scopes: quota:read and workspaces:read, independently grantable.
  • Tokens are GET-only in v1. Not a nicety: /api/workers and /api/workers/[id]/sessions are mapped for their GET handlers, and their POST handlers spawn a workspace and create a session. Without the method rule workspaces:read would be a write scope.
  • UI: /settings?tab=tokens — create (label + scopes + optional expiry), show the plaintext once, list with last-used, revoke.

Two properties fall out rather than needing code: a token cannot manage tokens (/api/tokens is not in the map), and a token cannot open a terminal (terminal/upgrade.ts does cookie auth only and never parses Authorization).

Resolved

  • Tokens do not reach routes requiring an unlocked vault. quota:read needs no key at all.
  • Expiry is optional, defaulting to never.
  • Rate limiting is out of scope for v1 — tracked with the other internet-exposure work in #110.
  • /api/state stays in PUBLIC_API.
## Want Register long-lived API tokens in Nexus that other devices can use to call the HTTP API, each token limited to a chosen set of capabilities. Concrete first use case: a token that can do nothing but `GET /api/quota`, so a phone widget / status bar / dashboard elsewhere can read fleet quota without holding credentials that could spawn a workspace or read the config volume. Designed. Spec: `docs/superpowers/specs/2026-09-04-api-tokens-design.md`. ## Why the current auth model can't do this Auth today is a single operator passphrase → an in-memory `SessionStore` entry holding the Argon2id-derived master key, addressed by an httpOnly cookie (`nexus/src/lib/server/auth/session.ts`, `routes/api/auth/unlock/+server.ts`). Consequences for a headless caller: - **All or nothing.** `hooks.server.ts` gates on `session` existing, full stop. There is no capability dimension. - **Not durable.** Sessions die on TTL and on every Nexus restart (see #108). A device token must survive both. - **Carries the master key.** `event.locals.masterKey` is the unlocked vault key. A token minted for reading quota must never imply the vault is unlocked. ## Shape - **Table `api_tokens`**: `id`, `label`, `token_hash`, `scopes` (JSON array), `created_at`, `last_used_at`, `expires_at` nullable (null = never). Revoke is a hard delete, per the repo's no-soft-delete convention. - **Hash with SHA-256, not Argon2id.** *(Corrected — an earlier revision of this issue said Argon2id.)* Argon2id exists to make guessing a low-entropy human secret expensive. An API token is 32 CSPRNG bytes; it is not guessable at any hash speed, so slowness buys nothing while costing ~100 ms and a memory-hard allocation **on every authenticated request** — a script polling quota every 10 s would be a self-inflicted DoS. A salted hash also cannot be looked up, forcing a scan of every row. Unsalted SHA-256 with a unique index on the column is the standard choice for bearer tokens and the right one here. - **Nothing sealed under the master key.** No `encrypted_*` column, so `rotateMasterPassphrase` gains no pass and `sealed-columns.test.ts` needs no change. - **Transport**: `Authorization: Bearer nxs_<32 bytes base64url>`. The prefix makes tokens identifiable in logs and leak scanners. Plaintext is shown exactly once, at creation. Cookie auth is unchanged and remains the browser path. - **`hooks.server.ts`** gains a fourth branch: no cookie session, but a valid bearer token → set `event.locals.tokenScopes` and **do not** set `event.locals.masterKey`. Every vault-touching route already guards on `masterKey`, so that alone is defense in depth — but it is not the authorization model, because sensitive routes exist that need no vault key (`/api/workers/[id]/logs`, `/api/settings/agent-instructions`, `artifacts/[artifactId]/raw`). - **Scope map keyed on `event.route.id`**, the SvelteKit route *pattern* (`/api/workers/[id]/health`), not the URL path. Exact-string matched, no regex, no traversal surface; an unmapped route is denied by default. - **v1 scopes**: `quota:read` and `workspaces:read`, independently grantable. - **Tokens are GET-only in v1.** Not a nicety: `/api/workers` and `/api/workers/[id]/sessions` are mapped for their GET handlers, and their POST handlers spawn a workspace and create a session. Without the method rule `workspaces:read` would be a write scope. - **UI**: `/settings?tab=tokens` — create (label + scopes + optional expiry), show the plaintext once, list with last-used, revoke. Two properties fall out rather than needing code: a token cannot manage tokens (`/api/tokens` is not in the map), and a token cannot open a terminal (`terminal/upgrade.ts` does cookie auth only and never parses `Authorization`). ## Resolved - Tokens do not reach routes requiring an unlocked vault. `quota:read` needs no key at all. - Expiry is optional, defaulting to never. - Rate limiting is out of scope for v1 — tracked with the other internet-exposure work in #110. - `/api/state` stays in `PUBLIC_API`.
lz closed this issue 2026-09-05 12:13:44 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#109
No description provided.