@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.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +238 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- 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
|
+
}
|