fix(api): let native clients manage Personal Access Tokens (#386) #387

Merged
james merged 1 commit from 386-native-pat-management into main 2026-06-29 18:56:17 +00:00
Owner

Closes #386

The PAT-management endpoints gated on a cookie session, so native clients (Android, Linux Flatpak) — which authenticate with the bearer access token cat_… (ADR-0027) and hold no cookie — got 401 on the account → tokens screen. The UI (apps/client/app/(app)/account.tsx) exists and works on web; it was simply locked out natively.

Why this is safe

ADR-0021 made token-management session-only to enforce "a stolen PAT can't re-provision itself". That carve-out predates native access tokens, which are the interactive-sign-in equivalent of a cookie session: 15-min TTL, minted only by the password or OAuth authorization_code grant on POST /api/auth/token. The threat model never required a cookie specifically — it required an interactive credential and the exclusion of PATs. Admitting the access token preserves the model verbatim: a stolen PAT still cannot list, create, or revoke tokens.

What changed

  • lib/auth/identity.ts: new getInteractiveIdentity(req) — accepts session + access_token, rejects pat and unauthenticated.
  • Three handlers (GET/POST /api/account/tokens, DELETE /api/account/tokens/{id}): getSessiongetInteractiveIdentity.
  • OpenAPI: a dedicated accessTokenAuth scheme so the routes advertise cookie or native access token, not a PAT precisely. Regenerated openapi.json + @carol/api-client.
  • ADR-0021: amended with a native-access-token update (top-of-file Amended note, forward-pointer at the Decision section, full Update section) cross-referencing ADR-0027.
  • No client change — the screen works natively once the endpoints accept the access token.

Tests (tests/api/account-tokens.test.ts)

  • A native access token can list / create / revoke tokens.
  • A PAT bearer is refused 401 on list, create, and revoke (no self-provisioning, no breach-hiding).
  • A native access token only manages its own user's tokens (cross-user revoke → 404, no existence leak).
  • The renamed existing test documents the PAT carve-out explicitly.

Verification

  • typecheck / lint clean; account-tokens 17/17; full suite 1280 tests across SQLite + Postgres (ephemeral PG via TEST_POSTGRES_URL); openapi:check + openapi:coverage + @carol/api-client check all green; full CI semgrep pack set (99 rules) 0 findings.
  • Independent of the open client PRs — backend + spec + docs only.

🤖 Generated with Claude Code

Closes #386 The PAT-management endpoints gated on a **cookie session**, so native clients (Android, Linux Flatpak) — which authenticate with the bearer access token `cat_…` (ADR-0027) and hold no cookie — got **401** on the account → tokens screen. The UI (`apps/client/app/(app)/account.tsx`) exists and works on web; it was simply locked out natively. ## Why this is safe ADR-0021 made token-management session-only to enforce *"a stolen PAT can't re-provision itself"*. That carve-out predates native access tokens, which are the **interactive-sign-in equivalent of a cookie session**: 15-min TTL, minted only by the `password` or OAuth `authorization_code` grant on `POST /api/auth/token`. The threat model never required a *cookie* specifically — it required an *interactive* credential and the exclusion of **PATs**. Admitting the access token preserves the model verbatim: a stolen PAT still cannot list, create, or revoke tokens. ## What changed - **`lib/auth/identity.ts`**: new `getInteractiveIdentity(req)` — accepts `session` + `access_token`, rejects `pat` and unauthenticated. - **Three handlers** (`GET`/`POST /api/account/tokens`, `DELETE /api/account/tokens/{id}`): `getSession` → `getInteractiveIdentity`. - **OpenAPI**: a dedicated `accessTokenAuth` scheme so the routes advertise *cookie or native access token, not a PAT* precisely. Regenerated `openapi.json` + `@carol/api-client`. - **ADR-0021**: amended with a native-access-token update (top-of-file `Amended` note, forward-pointer at the Decision section, full *Update* section) cross-referencing ADR-0027. - **No client change** — the screen works natively once the endpoints accept the access token. ## Tests (`tests/api/account-tokens.test.ts`) - A native access token can **list / create / revoke** tokens. - A **PAT bearer is refused 401** on list, create, and revoke (no self-provisioning, no breach-hiding). - A native access token only manages its own user's tokens (cross-user revoke → 404, no existence leak). - The renamed existing test documents the PAT carve-out explicitly. ## Verification - typecheck / lint clean; **account-tokens 17/17**; full suite **1280 tests across SQLite + Postgres** (ephemeral PG via `TEST_POSTGRES_URL`); `openapi:check` + `openapi:coverage` + `@carol/api-client check` all green; full CI semgrep pack set (99 rules) **0 findings**. - Independent of the open client PRs — backend + spec + docs only. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(api): let native clients manage Personal Access Tokens (#386)
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 5s
PR / OpenAPI (pull_request) Successful in 2m13s
PR / Lint (pull_request) Successful in 2m42s
PR / pnpm audit (pull_request) Successful in 3m2s
PR / Static analysis (pull_request) Successful in 3m13s
PR / Typecheck (pull_request) Successful in 3m32s
PR / Client (web export smoke) (pull_request) Successful in 3m44s
PR / OSV-Scanner (pull_request) Successful in 1m47s
PR / Package age policy (soft) (pull_request) Successful in 49s
PR / Test (sqlite) (pull_request) Successful in 4m11s
PR / Test (postgres) (pull_request) Successful in 4m16s
PR / Build (pull_request) Successful in 4m24s
Secrets / gitleaks (pull_request) Successful in 51s
PR / Trivy (image) (pull_request) Successful in 2m35s
PR / Coverage (soft) (pull_request) Successful in 2m16s
PR / E2E (Playwright) (pull_request) Successful in 6m3s
e5b51aa702
The PAT-management endpoints (GET/POST /api/account/tokens, DELETE
/api/account/tokens/{id}) gated on a cookie session, so native clients
(Android, Linux Flatpak) — which authenticate with the bearer access
token cat_… (ADR-0027) and hold no cookie — got 401 on the account →
tokens screen. The UI exists and works on web; it was simply locked out
natively.

ADR-0021 made token-management session-only to enforce "a stolen PAT
can't re-provision itself". That predates native access tokens, which
are the interactive-sign-in equivalent of a cookie session (15-min TTL,
minted only by the password / OAuth authorization_code grant). The
threat model never required a cookie specifically — it required an
interactive credential and the exclusion of PATs.

- lib/auth/identity.ts: add getInteractiveIdentity(req) — accepts
  session + access_token, rejects pat and unauthenticated.
- The three token-management handlers: getSession → getInteractiveIdentity.
  A stolen PAT still can't list/create/revoke tokens (verbatim threat
  model); native clients regain PWA parity.
- OpenAPI: a dedicated accessTokenAuth scheme so the three routes
  advertise "cookie or native access token, not a PAT" precisely;
  regenerated openapi.json + @carol/api-client.
- ADR-0021: amended with a native-access-token update referencing ADR-0027.
- Tests: access token can list/create/revoke; PAT bearer is refused 401
  on all three; cross-user revoke via access token still 404s.

No client change — the screen works natively once the endpoints accept
the access token.

Closes #386

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 79.7% ≥ 50%
Branches 71.6% ⚠️ ≥ 75%
Functions 81.2% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 79.7% ✅ | ≥ 50% | | Branches | 71.6% ⚠️ | ≥ 75% | | Functions | 81.2% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 7f1b8163db into main 2026-06-29 18:56:17 +00:00
james deleted branch 386-native-pat-management 2026-06-29 18:56:17 +00:00
Sign in to join this conversation.
No description provided.