feat(integration): scoped bearer API tokens + JSON API v1 (LC-72) #167

Merged
nrupard merged 2 commits from feat/lc-72-api-tokens into main 2026-05-20 21:01:56 +02:00
Owner

Summary

Scoped personal API tokens + a documented JSON API (/api/v1) so bots / scripts / CI can call lets-chat with a bearer token, scoped narrower than the owning user (LC-72). The cookie session path is unchanged; token auth is additive.

Design

  • Storage (migration 0019, api_tokens): only an HMAC-SHA256 of the token (keyed by LETS_CHAT_SECRET_KEY) is stored; plaintext is shown once at creation, never persisted or logged. Space-separated scopes, optional expiry, revoked_at. Revoked/expired rows retained for audit. hmac+hex moved from saas-only to unconditional deps.
  • ApiAuth extractor: reads Authorization: Bearer, HMACs, resolves User + scope set. Missing/unknown/expired/revoked token (or no server secret) -> 401. last_used_at bumped in a detached task (never blocks). ApiAuth::require(scope) -> 403 when a valid token lacks the scope (so undeclared-scope routes are unreachable by tokens).
  • /api/v1 (JSON): merged after the cookie/2FA/maintenance/branding layers, so it bypasses the browser session. GET /me (any token), GET /rooms (rooms:read), GET /rooms/{id}/messages (messages:read), POST /rooms/{id}/messages (messages:write). Every route still enforces the user's own room access + ban/mute.
  • Management UI: Settings -> API tokens (mint with name + scope checkboxes + optional expiry; plaintext shown once; revoke). Both standalone + saas.
  • Reference: docs/api.md lists every API route + scope.

Acceptance criteria

  • Mint from settings, see once, copy.
  • Revoke takes effect immediately.
  • Non-empty scopes; missing scope -> 403 (not 401).
  • last_used_at updates without blocking the request.
  • Expiry enforced -> 401.
  • Plaintext never logged.
  • Revoked/expired rows retained for audit.
  • Reference doc lists routes + scopes.

Open questions left for later: admin-mint-on-behalf, per-token rate limits, IP allow-lists, LC-73 bot interaction.

Tests

routes_api.rs: no/bad token 401, identity, missing-scope 403, write+read round-trip, expiry 401, immediate-revoke 401. Migration 0019 appended to hand-rolled auth lists. just check, just test, just test-saas green.

🤖 Generated with Claude Code

## Summary Scoped personal API tokens + a documented JSON API (`/api/v1`) so bots / scripts / CI can call lets-chat with a bearer token, scoped narrower than the owning user (LC-72). The cookie session path is unchanged; token auth is additive. ## Design - **Storage** (migration `0019`, `api_tokens`): only an HMAC-SHA256 of the token (keyed by `LETS_CHAT_SECRET_KEY`) is stored; plaintext is shown once at creation, never persisted or logged. Space-separated scopes, optional expiry, `revoked_at`. Revoked/expired rows retained for audit. `hmac`+`hex` moved from saas-only to unconditional deps. - **`ApiAuth` extractor**: reads `Authorization: Bearer`, HMACs, resolves User + scope set. Missing/unknown/expired/revoked token (or no server secret) -> **401**. `last_used_at` bumped in a detached task (never blocks). `ApiAuth::require(scope)` -> **403** when a valid token lacks the scope (so undeclared-scope routes are unreachable by tokens). - **`/api/v1`** (JSON): merged after the cookie/2FA/maintenance/branding layers, so it bypasses the browser session. `GET /me` (any token), `GET /rooms` (`rooms:read`), `GET /rooms/{id}/messages` (`messages:read`), `POST /rooms/{id}/messages` (`messages:write`). Every route still enforces the user's own room access + ban/mute. - **Management UI**: Settings -> API tokens (mint with name + scope checkboxes + optional expiry; plaintext shown once; revoke). Both standalone + saas. - **Reference**: `docs/api.md` lists every API route + scope. ## Acceptance criteria - [x] Mint from settings, see once, copy. - [x] Revoke takes effect immediately. - [x] Non-empty scopes; missing scope -> 403 (not 401). - [x] `last_used_at` updates without blocking the request. - [x] Expiry enforced -> 401. - [x] Plaintext never logged. - [x] Revoked/expired rows retained for audit. - [x] Reference doc lists routes + scopes. Open questions left for later: admin-mint-on-behalf, per-token rate limits, IP allow-lists, LC-73 bot interaction. ## Tests `routes_api.rs`: no/bad token 401, identity, missing-scope 403, write+read round-trip, expiry 401, immediate-revoke 401. Migration `0019` appended to hand-rolled auth lists. `just check`, `just test`, `just test-saas` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(integration): scoped bearer API tokens + JSON API v1 (LC-72)
Some checks failed
Check / clippy + fmt + tests (pull_request) Failing after 5s
ff11216430
A documented HTTP API external programs can call with a personal bearer token, scoped narrower than the owning user.

Tokens (migration 0019, auth.db api_tokens): only an HMAC-SHA256 of the token (keyed by the server secret) is stored; the plaintext is shown exactly once at creation and never persisted or logged. Rows carry a space-separated scope list, optional expiry, and revoked_at; revoked/expired rows are retained for audit. hmac + hex moved from saas-only to unconditional deps so the HMAC is available in every build.

Auth: new ApiAuth extractor (FromRequestParts<AppState>) reads Authorization: Bearer, HMACs, looks up the token, and resolves the User + scope set. Missing / unknown / expired / revoked token (or no server secret) -> 401. last_used_at is bumped in a detached task so it never blocks the request. Scope enforcement is per-route via ApiAuth::require(scope), which returns 403 (never 401) when a valid token lacks the scope - so a route that declares no scope is unreachable by tokens.

API surface (/api/v1, JSON): merged AFTER the cookie / 2FA / maintenance / branding middleware so it bypasses the browser session entirely. GET /me (any valid token), GET /rooms (rooms:read), GET /rooms/{id}/messages (messages:read), POST /rooms/{id}/messages (messages:write). Every route still enforces the token owner's own room access + ban/mute - scopes narrow, never widen.

Management UI: Settings -> API tokens (GET/POST /settings/api-tokens, POST .../revoke). Mint with a name + scope checkboxes + optional expiry; the plaintext is rendered once on the create response. Available in both standalone and saas. docs/api.md is the handwritten reference listing every API route and its scope.

Tests: routes_api.rs covers no/bad token 401, valid identity, missing-scope 403 (not 401), write+read message round-trip, expiry 401, and immediate-revoke 401. Migration 0019 appended to the hand-rolled auth migration lists. just check, just test, just test-saas all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(api): case-insensitive Bearer scheme; document maintenance bypass (LC-72)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m20s
f5b2d21c22
Accept the auth scheme case-insensitively (RFC 7235: "Bearer" / "bearer" / "BEARER") instead of matching only the capital-B literal. New test covers a lowercase scheme.

Document in docs/api.md that the API is intentionally not gated by maintenance mode (bearer requests keep working during a UI maintenance window) and that the scheme is case-insensitive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-72-api-tokens 2026-05-20 21:01:56 +02:00
Sign in to join this conversation.
No reviewers
No labels
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
psa-systems/lets-chat!167
No description provided.