@openparachute/hub 0.5.7 → 0.5.10-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/api-me.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `GET /api/me` — public who-am-I endpoint for hub-served surfaces.
3
+ *
4
+ * Reads the `parachute_hub_session` cookie. If present and active, returns
5
+ * the user identity AND a CSRF token bound to the existing CSRF cookie (or
6
+ * a freshly-minted one). Otherwise returns the minimal `{ hasSession: false }`
7
+ * payload.
8
+ *
9
+ * Public — no auth required (returns "not signed in" rather than 401 when
10
+ * no session, so the SPA / discovery page can render a consistent affordance
11
+ * regardless of auth state). No CORS — same-origin only; hub-served UIs
12
+ * are same-origin.
13
+ *
14
+ * Response shape:
15
+ *
16
+ * { hasSession: false }
17
+ * { hasSession: true, user: { id, displayName }, csrf: "<token>" }
18
+ *
19
+ * `displayName` is the user's `username` today — there's no separate
20
+ * display-name field on the User shape. Surfaced under a different key
21
+ * here so a future profile-name migration can land without breaking
22
+ * SPA / discovery consumers.
23
+ *
24
+ * Why include the CSRF token only when signed in: there's nothing to
25
+ * sign-out (or otherwise mutate) without a session. Minting a token in
26
+ * the unsigned-in case would just bloat the response and prime a cookie
27
+ * the consumer has no use for.
28
+ *
29
+ * Why a dedicated endpoint rather than probing a session-gated SPA page:
30
+ * those redirect to /login when unauthenticated, which is exactly the
31
+ * wrong UX for an unconditionally-fetched "show sign-in affordance" call.
32
+ * `/api/me` cleanly returns either state without a bounce.
33
+ *
34
+ * The CSRF token returned here is the same token any same-session
35
+ * `<form>` would carry — the consumer (SPA fetch POST or
36
+ * server-rendered discovery form) submits it back as `__csrf` against
37
+ * the existing logout / mutation handlers.
38
+ */
39
+ import type { Database } from "bun:sqlite";
40
+ import { ensureCsrfToken } from "./csrf.ts";
41
+ import { findActiveSession } from "./sessions.ts";
42
+ import { getUserById } from "./users.ts";
43
+
44
+ export interface ApiMeDeps {
45
+ db: Database;
46
+ }
47
+
48
+ interface SignedInUser {
49
+ id: string;
50
+ displayName: string;
51
+ }
52
+
53
+ /**
54
+ * Discriminated union mirroring the client-side `MeResponse` shape in
55
+ * `web/ui/src/lib/api.ts`. The two early returns below (no-session,
56
+ * deleted-user) construct the `false` arm; the success path constructs
57
+ * the `true` arm. Typing it as a union (rather than an interface with
58
+ * optional fields) means the compiler refuses any future construction
59
+ * that mixes states — e.g. `{ hasSession: false, user: staleUser }`
60
+ * fails at the type-check, not just at code-review.
61
+ */
62
+ type ApiMeResponse = { hasSession: false } | { hasSession: true; user: SignedInUser; csrf: string };
63
+
64
+ export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
65
+ if (req.method !== "GET") {
66
+ return jsonError(405, "method_not_allowed", "use GET");
67
+ }
68
+
69
+ const session = findActiveSession(deps.db, req);
70
+ if (!session) {
71
+ return ok({ hasSession: false });
72
+ }
73
+
74
+ const user = getUserById(deps.db, session.userId);
75
+ if (!user) {
76
+ // Session row points at a deleted user — treat as not signed in.
77
+ // The session row should be cleaned up by some future sweep, but
78
+ // surfacing a stale identity to the UI would be worse than a
79
+ // momentary "signed out" affordance.
80
+ return ok({ hasSession: false });
81
+ }
82
+
83
+ // Mint a CSRF token (or reuse the existing cookie's). When this is the
84
+ // first request to set the cookie, attach Set-Cookie so the browser
85
+ // stores it for future logout submission.
86
+ const csrf = ensureCsrfToken(req);
87
+ const headers: Record<string, string> = {
88
+ "content-type": "application/json",
89
+ // Don't cache — session can change at any time (login, logout,
90
+ // expiry). The endpoint is cheap; revalidate on every request.
91
+ "cache-control": "no-store",
92
+ };
93
+ if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
94
+
95
+ const body: ApiMeResponse = {
96
+ hasSession: true,
97
+ user: {
98
+ id: user.id,
99
+ displayName: user.username,
100
+ },
101
+ csrf: csrf.token,
102
+ };
103
+ return new Response(JSON.stringify(body), { status: 200, headers });
104
+ }
105
+
106
+ function ok(body: ApiMeResponse): Response {
107
+ return new Response(JSON.stringify(body), {
108
+ status: 200,
109
+ headers: {
110
+ "content-type": "application/json",
111
+ "cache-control": "no-store",
112
+ },
113
+ });
114
+ }
115
+
116
+ function jsonError(status: number, error: string, description: string): Response {
117
+ return new Response(JSON.stringify({ error, error_description: description }), {
118
+ status,
119
+ headers: {
120
+ "content-type": "application/json",
121
+ "cache-control": "no-store",
122
+ },
123
+ });
124
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `POST /api/auth/mint-token` — HTTP companion to `parachute auth mint-token`.
3
+ *
4
+ * Same arg/return shape as the CLI; just the network path. Used by:
5
+ *
6
+ * - automation that doesn't have CLI access (CI runners, cloud agents)
7
+ * but does hold an operator-bearer with `parachute:host:auth` scope;
8
+ * - the future admin SPA when the operator wants to mint a one-shot
9
+ * scope-narrow token without dropping to a terminal.
10
+ *
11
+ * Auth: `Authorization: Bearer <token>` where `token`'s `scope` claim
12
+ * contains `parachute:host:auth`. The operator's local operator.token
13
+ * (admin scope-set) covers this; a narrow `--scope-set=auth` operator
14
+ * token also covers this.
15
+ *
16
+ * Why a separate endpoint instead of extending /admin/host-admin-token:
17
+ * that endpoint is session-cookie-gated for the SPA's needs and only
18
+ * mints `parachute:host:admin`. This endpoint is bearer-gated for
19
+ * automation and mints arbitrary scope/permissions tuples per request.
20
+ *
21
+ * Every successful mint writes a row to the `tokens` registry
22
+ * (`created_via='cli_mint'` — same provenance as the CLI path, since
23
+ * HTTP mint is just CLI-by-network). Powers the
24
+ * `/.well-known/parachute-revocation.json` endpoint.
25
+ */
26
+ import type { Database } from "bun:sqlite";
27
+ import { inferAudience } from "./jwt-audience.ts";
28
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
29
+ import { isNonRequestableScope } from "./scope-explanations.ts";
30
+
31
+ /** Default lifetime when --expires-in / `expires_in` is omitted. Matches the CLI. */
32
+ export const API_MINT_TOKEN_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
33
+ /** Hard cap. Matches the CLI's --expires-in upper bound. */
34
+ export const API_MINT_TOKEN_MAX_TTL_SECONDS = 365 * 24 * 60 * 60;
35
+ /** Scope required on the bearer token to call this endpoint. */
36
+ export const API_MINT_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
37
+ /** client_id stamped on minted tokens. Matches the CLI flow's value. */
38
+ export const API_MINT_TOKEN_CLIENT_ID = "parachute-hub";
39
+
40
+ export interface ApiMintTokenDeps {
41
+ db: Database;
42
+ /** Hub origin — written into the JWT `iss` of minted tokens AND used to validate the bearer. */
43
+ issuer: string;
44
+ /** Test seam for time. */
45
+ now?: () => Date;
46
+ }
47
+
48
+ interface MintTokenRequest {
49
+ scope?: unknown;
50
+ audience?: unknown;
51
+ expires_in?: unknown;
52
+ subject?: unknown;
53
+ permissions?: unknown;
54
+ }
55
+
56
+ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps): Promise<Response> {
57
+ if (req.method !== "POST") {
58
+ return jsonError(405, "method_not_allowed", "use POST");
59
+ }
60
+
61
+ // 1. Bearer presence + parsing.
62
+ const auth = req.headers.get("authorization");
63
+ if (!auth || !auth.startsWith("Bearer ")) {
64
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
65
+ }
66
+ const bearer = auth.slice("Bearer ".length).trim();
67
+ if (!bearer) {
68
+ return jsonError(401, "unauthenticated", "empty bearer token");
69
+ }
70
+
71
+ // 2. Bearer validation (signature, issuer, expiry, revocation).
72
+ let bearerSub: string;
73
+ let bearerScopes: string[];
74
+ try {
75
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
76
+ const sub = validated.payload.sub;
77
+ if (typeof sub !== "string" || sub.length === 0) {
78
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
79
+ }
80
+ bearerSub = sub;
81
+ bearerScopes =
82
+ typeof validated.payload.scope === "string"
83
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
84
+ : [];
85
+ } catch (err) {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
88
+ }
89
+
90
+ // 3. Scope gate.
91
+ if (!bearerScopes.includes(API_MINT_TOKEN_REQUIRED_SCOPE)) {
92
+ return jsonError(
93
+ 403,
94
+ "insufficient_scope",
95
+ `bearer token lacks ${API_MINT_TOKEN_REQUIRED_SCOPE}`,
96
+ );
97
+ }
98
+
99
+ // 4. Body parsing.
100
+ let body: MintTokenRequest;
101
+ try {
102
+ body = (await req.json()) as MintTokenRequest;
103
+ } catch (err) {
104
+ const msg = err instanceof Error ? err.message : String(err);
105
+ return jsonError(400, "invalid_request", `body must be valid JSON — ${msg}`);
106
+ }
107
+ if (typeof body !== "object" || body === null) {
108
+ return jsonError(400, "invalid_request", "body must be a JSON object");
109
+ }
110
+
111
+ // 5. Required + typed field extraction.
112
+ if (typeof body.scope !== "string" || body.scope.trim().length === 0) {
113
+ return jsonError(400, "invalid_request", "scope is required and must be a non-empty string");
114
+ }
115
+ const scopes = body.scope.split(/\s+/).filter((s) => s.length > 0);
116
+ if (scopes.length === 0) {
117
+ return jsonError(400, "invalid_request", "scope must contain at least one scope");
118
+ }
119
+
120
+ // Privilege-diffusion guard: mint paths cannot themselves mint tokens
121
+ // carrying non-requestable scopes (parachute:host:admin, the host:*
122
+ // narrow scopes, vault:<name>:admin). Holder of `parachute:host:auth`
123
+ // can mint vault/scribe/agent verb scopes for downstream services, but
124
+ // cannot mint another `:auth` (or any other non-requestable) without
125
+ // forced re-auth via the operator.token rotation path. Same set the
126
+ // public OAuth flow already rejects.
127
+ const blocked = scopes.filter((s) => isNonRequestableScope(s));
128
+ if (blocked.length > 0) {
129
+ return jsonError(
130
+ 400,
131
+ "invalid_scope",
132
+ `scope ${blocked.join(", ")} is not requestable via mint-token; use OAuth flow or operator rotation`,
133
+ );
134
+ }
135
+
136
+ let audience: string;
137
+ if (body.audience === undefined) {
138
+ audience = inferAudience(scopes);
139
+ } else if (typeof body.audience === "string" && body.audience.length > 0) {
140
+ audience = body.audience;
141
+ } else {
142
+ return jsonError(400, "invalid_request", "audience must be a non-empty string when present");
143
+ }
144
+
145
+ let ttlSeconds = API_MINT_TOKEN_DEFAULT_TTL_SECONDS;
146
+ if (body.expires_in !== undefined) {
147
+ if (typeof body.expires_in !== "number" || !Number.isFinite(body.expires_in)) {
148
+ return jsonError(400, "invalid_request", "expires_in must be a positive integer (seconds)");
149
+ }
150
+ if (!Number.isInteger(body.expires_in) || body.expires_in <= 0) {
151
+ return jsonError(400, "invalid_request", "expires_in must be a positive integer (seconds)");
152
+ }
153
+ if (body.expires_in > API_MINT_TOKEN_MAX_TTL_SECONDS) {
154
+ return jsonError(
155
+ 400,
156
+ "invalid_request",
157
+ `expires_in exceeds 365d cap (${API_MINT_TOKEN_MAX_TTL_SECONDS} seconds)`,
158
+ );
159
+ }
160
+ ttlSeconds = body.expires_in;
161
+ }
162
+
163
+ let subject: string;
164
+ if (body.subject === undefined) {
165
+ subject = bearerSub;
166
+ } else if (typeof body.subject === "string" && body.subject.length > 0) {
167
+ subject = body.subject;
168
+ } else {
169
+ return jsonError(400, "invalid_request", "subject must be a non-empty string when present");
170
+ }
171
+
172
+ let permissionsClaim: Record<string, unknown> | undefined;
173
+ let permissionsCanonical: string | undefined;
174
+ if (body.permissions !== undefined) {
175
+ if (
176
+ typeof body.permissions !== "object" ||
177
+ body.permissions === null ||
178
+ Array.isArray(body.permissions)
179
+ ) {
180
+ return jsonError(400, "invalid_request", "permissions must be a JSON object");
181
+ }
182
+ permissionsClaim = body.permissions as Record<string, unknown>;
183
+ permissionsCanonical = JSON.stringify(permissionsClaim);
184
+ }
185
+
186
+ // 6. Mint + register.
187
+ const minted = await signAccessToken(deps.db, {
188
+ sub: subject,
189
+ scopes,
190
+ audience,
191
+ clientId: API_MINT_TOKEN_CLIENT_ID,
192
+ issuer: deps.issuer,
193
+ ttlSeconds,
194
+ ...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
195
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
196
+ });
197
+
198
+ recordTokenMint(deps.db, {
199
+ jti: minted.jti,
200
+ createdVia: "cli_mint",
201
+ subject,
202
+ // user_id intentionally omitted — CLI-mint rows store subject only,
203
+ // matching the CLI path's shape (so HTTP and CLI mints look identical
204
+ // in the registry). The bearer's user identity is implicit via the
205
+ // bearer's own user_id (which is in its own tokens row).
206
+ clientId: API_MINT_TOKEN_CLIENT_ID,
207
+ scopes,
208
+ expiresAt: minted.expiresAt,
209
+ ...(permissionsCanonical !== undefined ? { permissions: permissionsCanonical } : {}),
210
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
211
+ });
212
+
213
+ return new Response(
214
+ JSON.stringify({
215
+ jti: minted.jti,
216
+ token: minted.token,
217
+ expires_at: minted.expiresAt,
218
+ scope: scopes.join(" "),
219
+ ...(permissionsClaim !== undefined ? { permissions: permissionsClaim } : {}),
220
+ }),
221
+ {
222
+ status: 200,
223
+ headers: {
224
+ "content-type": "application/json",
225
+ "cache-control": "no-store",
226
+ },
227
+ },
228
+ );
229
+ }
230
+
231
+ function jsonError(status: number, error: string, description: string): Response {
232
+ return new Response(JSON.stringify({ error, error_description: description }), {
233
+ status,
234
+ headers: {
235
+ "content-type": "application/json",
236
+ "cache-control": "no-store",
237
+ },
238
+ });
239
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `GET /.well-known/parachute-revocation.json` — public list of revoked,
3
+ * not-yet-expired token jtis. Resource servers (vault, scribe, agent)
4
+ * fetch this on a 60s TTL and reject any presented JWT whose jti appears.
5
+ *
6
+ * Public endpoint (no auth). The list itself is harmless to expose: it's
7
+ * a list of opaque IDs whose only utility is "this token shouldn't be
8
+ * accepted." A leaked list doesn't enable any new attack — at worst, an
9
+ * attacker learns which compromise the operator already cleaned up.
10
+ *
11
+ * Already-expired jtis are filtered out: every consumer checks `exp`
12
+ * itself, so listing expired tokens just bloats the response. The
13
+ * revocation list exists for *unexpired* tokens whose validity got cut
14
+ * short. Once `exp` passes, a row falls off the list naturally.
15
+ *
16
+ * Caching: 60s `Cache-Control: max-age=60` matches the consumer's
17
+ * polling cadence (Phase 4 wires the 60s TTL on the resource-server
18
+ * side). Shorter cache = revocation propagates faster but burns more
19
+ * CPU on this endpoint; 60s is the published convergence target.
20
+ */
21
+ import type { Database } from "bun:sqlite";
22
+ import { listActiveRevocations } from "./jwt-sign.ts";
23
+
24
+ export const REVOCATION_LIST_MOUNT = "/.well-known/parachute-revocation.json";
25
+ /** Consumer cache TTL in seconds. Resource servers should poll on this cadence. */
26
+ export const REVOCATION_LIST_CACHE_SECONDS = 60;
27
+
28
+ export interface RevocationListDeps {
29
+ db: Database;
30
+ /** Test seam for time. */
31
+ now?: () => Date;
32
+ }
33
+
34
+ interface RevocationListBody {
35
+ generated_at: string;
36
+ jtis: string[];
37
+ }
38
+
39
+ export function handleRevocationList(req: Request, deps: RevocationListDeps): Response {
40
+ if (req.method !== "GET") {
41
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), {
42
+ status: 405,
43
+ headers: { "content-type": "application/json" },
44
+ });
45
+ }
46
+ const now = deps.now?.() ?? new Date();
47
+ const jtis = listActiveRevocations(deps.db, now);
48
+ const body: RevocationListBody = {
49
+ generated_at: now.toISOString(),
50
+ jtis,
51
+ };
52
+ return new Response(JSON.stringify(body), {
53
+ status: 200,
54
+ headers: {
55
+ "content-type": "application/json",
56
+ "cache-control": `public, max-age=${REVOCATION_LIST_CACHE_SECONDS}`,
57
+ },
58
+ });
59
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `POST /api/auth/revoke-token` — HTTP companion to `parachute auth
3
+ * revoke-token <jti>` (hub#221) and the missing piece behind the future
4
+ * admin UI's revoke action.
5
+ *
6
+ * Same auth shape as `POST /api/auth/mint-token`: bearer-gated on
7
+ * `parachute:host:auth` (admin scope-set tokens carry it as a superset;
8
+ * narrow `--scope-set auth` operator tokens carry it directly). Closes
9
+ * hub#220.
10
+ *
11
+ * Body: `{ jti: string }`.
12
+ *
13
+ * Responses (matching the OAuth 2.0 error-shape vocabulary used by
14
+ * mint-token and the rest of the hub's bearer-protected admin API):
15
+ *
16
+ * - 200 `{ jti, revoked_at }` — success. Idempotent: re-revoking an
17
+ * already-revoked jti returns the existing `revoked_at` and 200,
18
+ * same as the CLI's exit-0-with-existing-timestamp behavior.
19
+ * - 400 `invalid_request` — missing/malformed body, missing jti.
20
+ * - 401 `unauthenticated` — missing or invalid bearer.
21
+ * - 403 `insufficient_scope` — bearer lacks `parachute:host:auth`.
22
+ * - 404 `not_found` — no `tokens` row matches the jti.
23
+ * - 405 `method_not_allowed` — non-POST.
24
+ *
25
+ * Identity field in audit-friendly success: not echoed in the response
26
+ * body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
27
+ * is all a UI consumer needs); operator-side audit lives in hub logs.
28
+ * Mirrors the CLI's design where `identity=` was added for stdout but
29
+ * the wire response stays narrow.
30
+ */
31
+ import type { Database } from "bun:sqlite";
32
+ import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
33
+
34
+ /** Scope required on the bearer token to call this endpoint. */
35
+ export const API_REVOKE_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
36
+
37
+ export interface ApiRevokeTokenDeps {
38
+ db: Database;
39
+ /** Hub origin — used to validate the bearer's `iss`. */
40
+ issuer: string;
41
+ /** Test seam for time. */
42
+ now?: () => Date;
43
+ }
44
+
45
+ interface RevokeTokenRequest {
46
+ jti?: unknown;
47
+ }
48
+
49
+ export async function handleApiRevokeToken(
50
+ req: Request,
51
+ deps: ApiRevokeTokenDeps,
52
+ ): Promise<Response> {
53
+ if (req.method !== "POST") {
54
+ return jsonError(405, "method_not_allowed", "use POST");
55
+ }
56
+
57
+ // 1. Bearer presence + parsing.
58
+ const auth = req.headers.get("authorization");
59
+ if (!auth || !auth.startsWith("Bearer ")) {
60
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
61
+ }
62
+ const bearer = auth.slice("Bearer ".length).trim();
63
+ if (!bearer) {
64
+ return jsonError(401, "unauthenticated", "empty bearer token");
65
+ }
66
+
67
+ // 2. Bearer validation (signature, issuer, expiry, hub-side revocation).
68
+ let bearerScopes: string[];
69
+ try {
70
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
71
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
72
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
73
+ }
74
+ bearerScopes =
75
+ typeof validated.payload.scope === "string"
76
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
77
+ : [];
78
+ } catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
81
+ }
82
+
83
+ // 3. Scope gate.
84
+ if (!bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE)) {
85
+ return jsonError(
86
+ 403,
87
+ "insufficient_scope",
88
+ `bearer token lacks ${API_REVOKE_TOKEN_REQUIRED_SCOPE}`,
89
+ );
90
+ }
91
+
92
+ // 4. Body parsing + field extraction.
93
+ let body: RevokeTokenRequest;
94
+ try {
95
+ body = (await req.json()) as RevokeTokenRequest;
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return jsonError(400, "invalid_request", `body must be valid JSON — ${msg}`);
99
+ }
100
+ if (typeof body !== "object" || body === null) {
101
+ return jsonError(400, "invalid_request", "body must be a JSON object");
102
+ }
103
+ if (typeof body.jti !== "string" || body.jti.length === 0) {
104
+ return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
105
+ }
106
+ const jti = body.jti;
107
+
108
+ // 5. Lookup + revoke. Order: row-existence first (404 if missing), then
109
+ // attempt revoke. Idempotent: if already revoked, surface the existing
110
+ // revoked_at — same CLI semantics from hub#221.
111
+ const existing = findTokenRowByJti(deps.db, jti);
112
+ if (!existing) {
113
+ return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
114
+ }
115
+ if (existing.revokedAt) {
116
+ return ok({ jti, revoked_at: existing.revokedAt });
117
+ }
118
+
119
+ const now = deps.now?.() ?? new Date();
120
+ const flipped = revokeTokenByJti(deps.db, jti, now);
121
+ if (!flipped) {
122
+ // Race: row vanished or was concurrently revoked between our lookup
123
+ // and the UPDATE. Re-read to surface the now-current revoked_at if
124
+ // someone else won. If still nothing, 404 (the row genuinely went
125
+ // away — a concurrent prune, perhaps).
126
+ const reRead = findTokenRowByJti(deps.db, jti);
127
+ if (reRead?.revokedAt) {
128
+ return ok({ jti, revoked_at: reRead.revokedAt });
129
+ }
130
+ return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
131
+ }
132
+ return ok({ jti, revoked_at: now.toISOString() });
133
+ }
134
+
135
+ function ok(body: { jti: string; revoked_at: string }): Response {
136
+ return new Response(JSON.stringify(body), {
137
+ status: 200,
138
+ headers: {
139
+ "content-type": "application/json",
140
+ "cache-control": "no-store",
141
+ },
142
+ });
143
+ }
144
+
145
+ function jsonError(status: number, error: string, description: string): Response {
146
+ return new Response(JSON.stringify({ error, error_description: description }), {
147
+ status,
148
+ headers: {
149
+ "content-type": "application/json",
150
+ "cache-control": "no-store",
151
+ },
152
+ });
153
+ }