@openparachute/hub 0.5.2 → 0.5.9-rc.6
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 +159 -320
- 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 +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- 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/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- 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/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -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/oauth-handlers.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
|
|
9
9
|
* - GET /oauth/authorize (login → consent → code)
|
|
10
10
|
* - POST /oauth/authorize (form posts: login + consent)
|
|
11
|
+
* - POST /oauth/authorize/approve (operator-driven inline DCR approval, #208)
|
|
11
12
|
* - POST /oauth/token (grant_type=authorization_code | refresh_token)
|
|
12
13
|
* - POST /oauth/register (RFC 7591 DCR)
|
|
13
14
|
* - POST /oauth/revoke (RFC 7009 token revocation)
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
type ClientStatus,
|
|
36
37
|
type OAuthClient,
|
|
37
38
|
type RegisteredClient,
|
|
39
|
+
approveClient,
|
|
38
40
|
getClient,
|
|
39
41
|
isValidRedirectUri,
|
|
40
42
|
registerClient,
|
|
@@ -53,7 +55,14 @@ import {
|
|
|
53
55
|
signAccessToken,
|
|
54
56
|
signRefreshToken,
|
|
55
57
|
} from "./jwt-sign.ts";
|
|
56
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
type AuthorizeFormParams,
|
|
60
|
+
renderApprovePending,
|
|
61
|
+
renderConsent,
|
|
62
|
+
renderError,
|
|
63
|
+
renderLogin,
|
|
64
|
+
} from "./oauth-ui.ts";
|
|
65
|
+
import { isSameOriginRequest } from "./origin-check.ts";
|
|
57
66
|
import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
|
|
58
67
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
59
68
|
import {
|
|
@@ -64,6 +73,7 @@ import {
|
|
|
64
73
|
SESSION_TTL_MS,
|
|
65
74
|
buildSessionCookie,
|
|
66
75
|
createSession,
|
|
76
|
+
findActiveSession,
|
|
67
77
|
findSession,
|
|
68
78
|
parseSessionCookie,
|
|
69
79
|
} from "./sessions.ts";
|
|
@@ -134,6 +144,18 @@ export interface OAuthDeps {
|
|
|
134
144
|
* `~/.parachute/services.json`; tests inject a fixture.
|
|
135
145
|
*/
|
|
136
146
|
loadServicesManifest?: () => ServicesManifest;
|
|
147
|
+
/**
|
|
148
|
+
* Set of origins (`scheme://host:port`) the hub considers itself bound to.
|
|
149
|
+
* Drives the same-origin defense on cookie-based POST endpoints: a request
|
|
150
|
+
* whose Origin/Referer matches any bound origin is accepted; everything
|
|
151
|
+
* else is rejected as cross-origin. Production wires this from
|
|
152
|
+
* `buildHubBoundOrigins` with the hub's port + expose-state hostname so
|
|
153
|
+
* loopback + tailnet + funnel access all work without restarting hub
|
|
154
|
+
* after `parachute expose`. Tests inject deterministic sets. When absent,
|
|
155
|
+
* the gate falls back to `[issuer]` — pre-#245 behavior — so callers that
|
|
156
|
+
* don't yet thread this through stay correct on a single-origin hub.
|
|
157
|
+
*/
|
|
158
|
+
hubBoundOrigins?: () => readonly string[];
|
|
137
159
|
}
|
|
138
160
|
|
|
139
161
|
export interface ServicesCatalogEntry {
|
|
@@ -149,41 +171,129 @@ export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
|
|
|
149
171
|
* version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
|
|
150
172
|
* to know where vault lives.
|
|
151
173
|
*
|
|
152
|
-
* URL source: `entry.paths[
|
|
174
|
+
* URL source: `entry.paths[*]` from services.json verbatim — never hardcode
|
|
153
175
|
* `/vault/default`. Users who installed with `parachute install vault
|
|
154
176
|
* --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
|
|
155
177
|
* catalog URL must follow that. The custom-vault-name regression test in
|
|
156
|
-
* oauth-handlers.test.ts pins this.
|
|
178
|
+
* oauth-handlers.test.ts pins this for single-vault.
|
|
157
179
|
*
|
|
158
180
|
* Filtering: only services for which the token has at least one scope are
|
|
159
181
|
* included. A scope `vault:read` admits the `vault` service; a token with only
|
|
160
182
|
* `scribe:transcribe` gets a catalog with no vault entry. The check is on the
|
|
161
183
|
* audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
|
|
162
184
|
*
|
|
163
|
-
* Multi-vault
|
|
164
|
-
* `vault
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
185
|
+
* Multi-vault (closes #247): emits per-vault keys `vault:<name>` alongside
|
|
186
|
+
* the collapsed `vault` key. A scope `vault:boulder:write` admits only
|
|
187
|
+
* boulder → emits `vault:boulder` (and the legacy `vault` key, pointing at
|
|
188
|
+
* boulder so it resolves consistently). A broad scope `vault:read` admits
|
|
189
|
+
* every vault on the hub → emits `vault:<name>` for each vault path plus
|
|
190
|
+
* the legacy `vault` key (pointing at `entry.paths[0]` of the first vault,
|
|
191
|
+
* unchanged from Phase 1). Notes' OAuthCallback (notes#115 ships the
|
|
192
|
+
* picker; per-vault consumer change is the post-#247 Notes-side PR) reads
|
|
193
|
+
* `services["vault:<name>"]` so it stops collapsing multi-vault grants
|
|
194
|
+
* onto a single VaultRecord URL.
|
|
195
|
+
*
|
|
196
|
+
* Pre-popover clients still see `services.vault` and behave unchanged —
|
|
197
|
+
* that key never goes away. Per-vault keys are additive.
|
|
169
198
|
*/
|
|
170
199
|
export function buildServicesCatalog(
|
|
171
200
|
manifest: ServicesManifest,
|
|
172
201
|
issuer: string,
|
|
173
202
|
scopes: readonly string[],
|
|
174
203
|
): ServicesCatalog {
|
|
204
|
+
// Two scope-derived sets:
|
|
205
|
+
// - audiences: bare service prefix (`vault`, `scribe`) → admits the
|
|
206
|
+
// collapsed key + every per-vault key.
|
|
207
|
+
// - namedVaults: per-vault narrowed scopes (`vault:<name>:<verb>`) →
|
|
208
|
+
// admits only `vault:<name>` and the collapsed `vault`.
|
|
209
|
+
//
|
|
210
|
+
// A token with both `vault:read` and `vault:boulder:write` should land in
|
|
211
|
+
// the "any vault" bucket — the bare scope is permissive, the named one
|
|
212
|
+
// is informational. Detect this via the bare-prefix presence; the named
|
|
213
|
+
// scope's per-vault narrowing still works for clients that prefer it.
|
|
175
214
|
const audiences = new Set<string>();
|
|
215
|
+
const namedVaults = new Set<string>();
|
|
176
216
|
for (const s of scopes) {
|
|
217
|
+
const parts = s.split(":");
|
|
218
|
+
if (
|
|
219
|
+
parts.length === 3 &&
|
|
220
|
+
parts[0] === "vault" &&
|
|
221
|
+
parts[1] &&
|
|
222
|
+
parts[2] &&
|
|
223
|
+
VAULT_VERBS.has(parts[2])
|
|
224
|
+
) {
|
|
225
|
+
namedVaults.add(parts[1]);
|
|
226
|
+
audiences.add("vault");
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
177
229
|
const colon = s.indexOf(":");
|
|
178
230
|
if (colon > 0) audiences.add(s.slice(0, colon));
|
|
179
231
|
}
|
|
232
|
+
const broadVaultScope =
|
|
233
|
+
audiences.has("vault") &&
|
|
234
|
+
scopes.some((s) => {
|
|
235
|
+
const parts = s.split(":");
|
|
236
|
+
return (
|
|
237
|
+
parts.length === 2 &&
|
|
238
|
+
parts[0] === "vault" &&
|
|
239
|
+
parts[1] !== undefined &&
|
|
240
|
+
VAULT_VERBS.has(parts[1])
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Count total admitted vault paths across the manifest. Per-vault keys
|
|
245
|
+
// are only worth emitting when there are >1 admitted vaults to
|
|
246
|
+
// disambiguate (or when the token's own scopes are per-vault narrowed —
|
|
247
|
+
// a per-vault scope is an explicit consumer signal that the per-vault
|
|
248
|
+
// key matters even on a single-vault hub). The check is on admitted
|
|
249
|
+
// paths, not raw vault rows: a broad token on a multi-path vault row
|
|
250
|
+
// sees N paths; a per-vault token sees only its own.
|
|
251
|
+
let admittedVaultPathCount = 0;
|
|
252
|
+
if (audiences.has("vault")) {
|
|
253
|
+
for (const entry of manifest.services) {
|
|
254
|
+
if (!isVaultEntry(entry)) continue;
|
|
255
|
+
const paths = entry.paths.length > 0 ? entry.paths : ["/"];
|
|
256
|
+
for (const path of paths) {
|
|
257
|
+
const instance = vaultInstanceNameFor(entry.name, path);
|
|
258
|
+
if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const emitPerVaultKeys = admittedVaultPathCount > 1 || namedVaults.size > 0;
|
|
263
|
+
|
|
180
264
|
const base = issuer.replace(/\/$/, "");
|
|
181
265
|
const catalog: ServicesCatalog = {};
|
|
182
266
|
for (const entry of manifest.services) {
|
|
183
|
-
|
|
184
|
-
|
|
267
|
+
if (isVaultEntry(entry)) {
|
|
268
|
+
if (!audiences.has("vault")) continue;
|
|
269
|
+
// Walk every path the row exposes. Real multi-vault on the hub is a
|
|
270
|
+
// single `parachute-vault` row with N paths (one per vault instance);
|
|
271
|
+
// legacy per-vault rows (`parachute-vault-<name>`) are handled by the
|
|
272
|
+
// same loop because each contributes one path.
|
|
273
|
+
const paths = entry.paths.length > 0 ? entry.paths : ["/"];
|
|
274
|
+
for (const path of paths) {
|
|
275
|
+
const instance = vaultInstanceNameFor(entry.name, path);
|
|
276
|
+
const admit = broadVaultScope || namedVaults.has(instance);
|
|
277
|
+
if (!admit) continue;
|
|
278
|
+
if (emitPerVaultKeys) {
|
|
279
|
+
const perVaultKey = `vault:${instance}`;
|
|
280
|
+
if (!catalog[perVaultKey]) {
|
|
281
|
+
catalog[perVaultKey] = { url: `${base}${path}`, version: entry.version };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Collapsed `vault` key stays for backwards compat. First admitted
|
|
285
|
+
// vault wins (deterministic — `entry.paths[0]` for a broad scope,
|
|
286
|
+
// or the only admitted instance for a per-vault scope).
|
|
287
|
+
if (!catalog.vault) {
|
|
288
|
+
catalog.vault = { url: `${base}${path}`, version: entry.version };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const key = shortName(entry.name);
|
|
185
294
|
if (!audiences.has(key)) continue;
|
|
186
|
-
if (catalog[key]) continue;
|
|
295
|
+
if (catalog[key]) continue;
|
|
296
|
+
const path = entry.paths[0] ?? "/";
|
|
187
297
|
catalog[key] = { url: `${base}${path}`, version: entry.version };
|
|
188
298
|
}
|
|
189
299
|
return catalog;
|
|
@@ -291,21 +401,100 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
|
|
|
291
401
|
};
|
|
292
402
|
}
|
|
293
403
|
|
|
294
|
-
/**
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
404
|
+
/**
|
|
405
|
+
* "App not yet approved" page (#74) for /oauth/authorize. When the request
|
|
406
|
+
* carries a valid operator session AND a same-origin Origin/Referer, render
|
|
407
|
+
* the inline approve form (#208) so one click flips the client to `approved`
|
|
408
|
+
* and the OAuth flow re-enters at consent. Otherwise fall back to the
|
|
409
|
+
* pre-#208 CLI-only message ("ask operator to run `parachute auth
|
|
410
|
+
* approve-client <id>`").
|
|
411
|
+
*
|
|
412
|
+
* The session-bound approve gate mirrors the same-origin DCR auto-approve
|
|
413
|
+
* gate on `/oauth/register` (#199, #200): valid session cookie + matching
|
|
414
|
+
* Origin/Referer = trusted operator action. Cross-origin or session-less
|
|
415
|
+
* GETs see the CLI-fallback message; the button never renders for them, so
|
|
416
|
+
* the POST handler can't be tricked into approving via a hand-crafted form
|
|
417
|
+
* either (CSRF token won't match).
|
|
418
|
+
*
|
|
419
|
+
* The form's `return_to` carries the original `/oauth/authorize?...` URL so
|
|
420
|
+
* the post-approve redirect lands the operator back on the same flow with
|
|
421
|
+
* the now-approved client. The POST handler validates `return_to` is a
|
|
422
|
+
* hub-relative path before following it (open-redirect defense).
|
|
423
|
+
*/
|
|
424
|
+
function pendingClientResponse(
|
|
425
|
+
db: Database,
|
|
426
|
+
req: Request,
|
|
427
|
+
client: OAuthClient,
|
|
428
|
+
authorizeUrl: URL,
|
|
429
|
+
deps: OAuthDeps,
|
|
430
|
+
): Response {
|
|
431
|
+
const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
|
|
432
|
+
.split(" ")
|
|
433
|
+
.filter((s) => s.length > 0);
|
|
434
|
+
// Vault hint (closes #244): Notes' VaultPopover (notes#115) sets this on
|
|
435
|
+
// the authorize URL when kicking OAuth for a specific vault. Empty-string
|
|
436
|
+
// values normalize to undefined so the approve UI omits the row rather
|
|
437
|
+
// than rendering a blank vault label.
|
|
438
|
+
const vaultParam = authorizeUrl.searchParams.get("vault");
|
|
439
|
+
const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
|
|
440
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
441
|
+
const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
|
|
442
|
+
const csrf = ensureCsrfToken(req);
|
|
443
|
+
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
444
|
+
if (session && sameOrigin) {
|
|
445
|
+
const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
|
|
446
|
+
return htmlResponse(
|
|
447
|
+
renderApprovePending({
|
|
448
|
+
clientName: client.clientName ?? client.clientId,
|
|
449
|
+
clientId: client.clientId,
|
|
450
|
+
redirectUris: client.redirectUris,
|
|
451
|
+
requestedScopes,
|
|
452
|
+
...(requestedVault !== undefined && { requestedVault }),
|
|
453
|
+
approveForm: { csrfToken: csrf.token, returnTo },
|
|
454
|
+
}),
|
|
455
|
+
403,
|
|
456
|
+
extra,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return htmlResponse(
|
|
460
|
+
renderApprovePending({
|
|
461
|
+
clientName: client.clientName ?? client.clientId,
|
|
462
|
+
clientId: client.clientId,
|
|
463
|
+
redirectUris: client.redirectUris,
|
|
464
|
+
requestedScopes,
|
|
465
|
+
...(requestedVault !== undefined && { requestedVault }),
|
|
466
|
+
}),
|
|
299
467
|
403,
|
|
468
|
+
extra,
|
|
300
469
|
);
|
|
301
470
|
}
|
|
302
471
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
472
|
+
/**
|
|
473
|
+
* JSON response for pending clients hitting /oauth/token. Carries two
|
|
474
|
+
* actionability hints alongside the OAuth error so consumers (Notes, future
|
|
475
|
+
* cross-origin SPAs) can surface an inline approval path instead of dead-
|
|
476
|
+
* ending on a CLI message:
|
|
477
|
+
*
|
|
478
|
+
* - `approve_url` — hub-served SPA route the operator can open in a
|
|
479
|
+
* browser to approve the client in one click. Same-origin to the hub.
|
|
480
|
+
* - `cli_alternative` — the `parachute auth approve-client <id>` shell
|
|
481
|
+
* command, retained for terminal-first operators or scripted flows.
|
|
482
|
+
*
|
|
483
|
+
* Spec note: the OAuth error class stays `invalid_client` per RFC 6749 §5.2
|
|
484
|
+
* — "this client cannot use this endpoint right now" is the semantic match.
|
|
485
|
+
* `access_denied` is reserved for /authorize "user said no" flows; using it
|
|
486
|
+
* here would conflate two distinct error families and break clients doing
|
|
487
|
+
* strict spec-shaped handling. The extra fields are spec-permitted
|
|
488
|
+
* extensions ("other parameters").
|
|
489
|
+
*/
|
|
490
|
+
function pendingClientJson(clientId: string, issuer: string): Response {
|
|
491
|
+
const base = issuer.replace(/\/$/, "");
|
|
305
492
|
return jsonResponse(
|
|
306
493
|
{
|
|
307
494
|
error: "invalid_client",
|
|
308
495
|
error_description: "client is registered but has not been approved by the hub operator (#74)",
|
|
496
|
+
approve_url: `${base}/admin/approve-client/${encodeURIComponent(clientId)}`,
|
|
497
|
+
cli_alternative: `parachute auth approve-client ${clientId}`,
|
|
309
498
|
},
|
|
310
499
|
401,
|
|
311
500
|
);
|
|
@@ -345,7 +534,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
345
534
|
// matched it against a registered client. Render an HTML error.
|
|
346
535
|
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
347
536
|
}
|
|
348
|
-
if (client.status !== "approved")
|
|
537
|
+
if (client.status !== "approved") {
|
|
538
|
+
return pendingClientResponse(db, req, client, url, deps);
|
|
539
|
+
}
|
|
349
540
|
try {
|
|
350
541
|
requireRegisteredRedirectUri(client, parsed.redirectUri);
|
|
351
542
|
} catch {
|
|
@@ -536,7 +727,18 @@ async function handleConsentSubmit(
|
|
|
536
727
|
if (!client) {
|
|
537
728
|
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
538
729
|
}
|
|
539
|
-
if (client.status !== "approved")
|
|
730
|
+
if (client.status !== "approved") {
|
|
731
|
+
// Defensive: consent only renders for approved clients, so a non-approved
|
|
732
|
+
// status here means the row was unapproved between render and submit (or
|
|
733
|
+
// the form was hand-crafted). The approve UI requires a known authorize
|
|
734
|
+
// URL to round-trip via `return_to`, which we don't reconstruct here —
|
|
735
|
+
// surface the static error and let the operator restart from the SPA.
|
|
736
|
+
return htmlError(
|
|
737
|
+
"App not yet approved",
|
|
738
|
+
`This client_id is registered but has not been approved. Run \`parachute auth approve-client ${client.clientId}\` from a terminal, then try again.`,
|
|
739
|
+
403,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
540
742
|
try {
|
|
541
743
|
requireRegisteredRedirectUri(client, params.redirectUri);
|
|
542
744
|
} catch {
|
|
@@ -602,6 +804,107 @@ async function handleConsentSubmit(
|
|
|
602
804
|
return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
|
|
603
805
|
}
|
|
604
806
|
|
|
807
|
+
/**
|
|
808
|
+
* POST /oauth/authorize/approve — operator-driven inline approval of a
|
|
809
|
+
* pending DCR client (closes #208). The cross-origin SPA case the
|
|
810
|
+
* same-origin DCR auto-approve (#199, #200) doesn't cover: an SPA on a
|
|
811
|
+
* different origin can't ride the cookie path during DCR, so its
|
|
812
|
+
* freshly-registered client_id lands `pending` and the operator hits
|
|
813
|
+
* "App not yet approved" on /oauth/authorize. This endpoint flips that
|
|
814
|
+
* client to `approved` in one click and redirects back into the OAuth flow.
|
|
815
|
+
*
|
|
816
|
+
* Three-belt security model. All three must pass:
|
|
817
|
+
*
|
|
818
|
+
* 1. Valid CSRF token (double-submit cookie). Defends against a malicious
|
|
819
|
+
* cross-origin POST that rides the session cookie's SameSite=Lax.
|
|
820
|
+
* Token was minted at GET render time and embedded in the form.
|
|
821
|
+
* 2. Active operator session (`findActiveSession`). The operator must be
|
|
822
|
+
* logged into this hub from the browser submitting the form — no
|
|
823
|
+
* session means no operator authority to approve anything.
|
|
824
|
+
* 3. Origin/Referer matches a hub-bound origin (`isSameOriginRequest`).
|
|
825
|
+
* Same shape as the DCR auto-approve gate (#199, #200, #245): a same-
|
|
826
|
+
* origin POST proves the form was rendered by *this hub*, not a forged
|
|
827
|
+
* page. Bound origins include issuer + loopback + tailnet hostname
|
|
828
|
+
* (#245); pre-#245 was issuer-only and rejected legitimate operator
|
|
829
|
+
* paths from loopback / tailnet.
|
|
830
|
+
*
|
|
831
|
+
* `return_to` validation: the form embeds the original authorize URL so
|
|
832
|
+
* the post-approve redirect lands the operator back on `/oauth/authorize`
|
|
833
|
+
* with the now-approved client. We refuse anything that doesn't start with
|
|
834
|
+
* `/oauth/authorize?` — open-redirect defense, plus a hand-crafted form
|
|
835
|
+
* trying to use this endpoint as a generic redirect-after-approve gadget
|
|
836
|
+
* shouldn't succeed at smuggling an off-path target.
|
|
837
|
+
*/
|
|
838
|
+
export async function handleApproveClientPost(
|
|
839
|
+
db: Database,
|
|
840
|
+
req: Request,
|
|
841
|
+
deps: OAuthDeps,
|
|
842
|
+
): Promise<Response> {
|
|
843
|
+
const form = await req.formData();
|
|
844
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
845
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
846
|
+
return htmlError(
|
|
847
|
+
"Invalid form submission",
|
|
848
|
+
"The form's CSRF token did not match. Reload the page and try again.",
|
|
849
|
+
403,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
853
|
+
if (!session) {
|
|
854
|
+
return htmlError(
|
|
855
|
+
"Sign in required",
|
|
856
|
+
"You must be signed in to this hub to approve an app. Sign in and try again.",
|
|
857
|
+
401,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
if (!isSameOriginRequest(req, resolveBoundOrigins(deps))) {
|
|
861
|
+
return htmlError(
|
|
862
|
+
"Cross-origin request rejected",
|
|
863
|
+
"The approve form must be submitted from this hub's own origin.",
|
|
864
|
+
403,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
const clientId = String(form.get("client_id") ?? "");
|
|
868
|
+
if (!clientId) {
|
|
869
|
+
return htmlError("Invalid form submission", "Missing client_id.", 400);
|
|
870
|
+
}
|
|
871
|
+
const client = getClient(db, clientId);
|
|
872
|
+
if (!client) {
|
|
873
|
+
return htmlError("Unknown application", "This client_id is not registered with this hub.", 404);
|
|
874
|
+
}
|
|
875
|
+
// Validate return_to BEFORE the DB mutation: if an authenticated operator
|
|
876
|
+
// submits a hand-crafted form with a bad return_to, we refuse without
|
|
877
|
+
// committing the client to `approved`. Practical risk is low (all three
|
|
878
|
+
// belts already passed), but ordering matters — validate, then mutate.
|
|
879
|
+
const returnTo = String(form.get("return_to") ?? "");
|
|
880
|
+
if (!isSafeAuthorizeReturnTo(returnTo)) {
|
|
881
|
+
return htmlError(
|
|
882
|
+
"Invalid form submission",
|
|
883
|
+
"The return_to value is not a hub-relative /oauth/authorize URL.",
|
|
884
|
+
400,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
approveClient(db, clientId);
|
|
888
|
+
return redirectResponse(returnTo);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Validate a form-submitted `return_to` value. Must be a hub-relative URL
|
|
893
|
+
* (no scheme, no double-slash) targeting `/oauth/authorize` with a query
|
|
894
|
+
* string — anything else is either an open-redirect attempt or a misuse of
|
|
895
|
+
* the endpoint. Empty string is rejected (the form always supplies one).
|
|
896
|
+
*/
|
|
897
|
+
function isSafeAuthorizeReturnTo(value: string): boolean {
|
|
898
|
+
if (!value) return false;
|
|
899
|
+
// Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
|
|
900
|
+
// single-slash root-relative paths are allowed.
|
|
901
|
+
if (!value.startsWith("/") || value.startsWith("//")) return false;
|
|
902
|
+
// Must target the authorize endpoint with a query string. The OAuth flow
|
|
903
|
+
// re-enters via GET /oauth/authorize?<original-params>; anything off-path
|
|
904
|
+
// is a misuse.
|
|
905
|
+
return value.startsWith("/oauth/authorize?");
|
|
906
|
+
}
|
|
907
|
+
|
|
605
908
|
function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): AuthorizeFormParams {
|
|
606
909
|
return {
|
|
607
910
|
clientId: String(form.get("client_id") ?? ""),
|
|
@@ -760,7 +1063,7 @@ async function handleTokenAuthorizationCode(
|
|
|
760
1063
|
if (!client) {
|
|
761
1064
|
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
762
1065
|
}
|
|
763
|
-
if (client.status !== "approved") return pendingClientJson();
|
|
1066
|
+
if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
|
|
764
1067
|
const authFailure = authenticateClient(client, req, form, clientId);
|
|
765
1068
|
if (authFailure) return authFailure;
|
|
766
1069
|
let redeemed: ReturnType<typeof redeemAuthCode>;
|
|
@@ -795,6 +1098,13 @@ async function handleTokenAuthorizationCode(
|
|
|
795
1098
|
issuer: deps.issuer,
|
|
796
1099
|
now: deps.now,
|
|
797
1100
|
});
|
|
1101
|
+
// Phase 1 (#212) registry exemption: code-grant access tokens piggyback
|
|
1102
|
+
// on the paired refresh token's `tokens` row (they share `jti` by
|
|
1103
|
+
// design). We don't write a separate access-token row — revocation acts
|
|
1104
|
+
// on the shared jti / family, and the 15-min access TTL bounds the
|
|
1105
|
+
// window before per-jti re-validation is needed. A separate per-jti
|
|
1106
|
+
// access-token row would double registry write volume on every OAuth
|
|
1107
|
+
// grant + every refresh rotation; not worth the trade today.
|
|
798
1108
|
const refresh = signRefreshToken(db, {
|
|
799
1109
|
jti: access.jti,
|
|
800
1110
|
userId: redeemed.userId,
|
|
@@ -835,7 +1145,7 @@ async function handleTokenRefresh(
|
|
|
835
1145
|
if (!client) {
|
|
836
1146
|
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
837
1147
|
}
|
|
838
|
-
if (client.status !== "approved") return pendingClientJson();
|
|
1148
|
+
if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
|
|
839
1149
|
const authFailure = authenticateClient(client, req, form, clientId);
|
|
840
1150
|
if (authFailure) return authFailure;
|
|
841
1151
|
const row = findRefreshToken(db, refreshToken);
|
|
@@ -848,6 +1158,18 @@ async function handleTokenRefresh(
|
|
|
848
1158
|
if (row.clientId !== clientId) {
|
|
849
1159
|
return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
|
|
850
1160
|
}
|
|
1161
|
+
// Refresh-token rows always have a non-null user_id (the caller's hub
|
|
1162
|
+
// user). Post-v6 the column is nullable to accommodate non-OAuth mints
|
|
1163
|
+
// (operator/cli mints), but those rows have no `refresh_token_hash` so
|
|
1164
|
+
// `findRefreshToken` can't return them. Defensive: surface a clean
|
|
1165
|
+
// invalid_grant if a hand-crafted row shows up here without a user.
|
|
1166
|
+
if (!row.userId) {
|
|
1167
|
+
return jsonResponse(
|
|
1168
|
+
{ error: "invalid_grant", error_description: "refresh_token has no associated user" },
|
|
1169
|
+
400,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
const refreshUserId: string = row.userId;
|
|
851
1173
|
const now = deps.now?.() ?? new Date();
|
|
852
1174
|
if (row.revokedAt) {
|
|
853
1175
|
// Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
|
|
@@ -882,7 +1204,7 @@ async function handleTokenRefresh(
|
|
|
882
1204
|
// (#107).
|
|
883
1205
|
const audience = inferAudience(row.scopes);
|
|
884
1206
|
const access = await signAccessToken(db, {
|
|
885
|
-
sub:
|
|
1207
|
+
sub: refreshUserId,
|
|
886
1208
|
scopes: row.scopes,
|
|
887
1209
|
audience,
|
|
888
1210
|
clientId: row.clientId,
|
|
@@ -895,7 +1217,7 @@ async function handleTokenRefresh(
|
|
|
895
1217
|
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
|
|
896
1218
|
return signRefreshToken(db, {
|
|
897
1219
|
jti: access.jti,
|
|
898
|
-
userId:
|
|
1220
|
+
userId: refreshUserId,
|
|
899
1221
|
clientId: row.clientId,
|
|
900
1222
|
scopes: row.scopes,
|
|
901
1223
|
familyId: row.familyId,
|
|
@@ -1064,20 +1386,50 @@ interface RegisterRequestBody {
|
|
|
1064
1386
|
token_endpoint_auth_method?: string;
|
|
1065
1387
|
}
|
|
1066
1388
|
|
|
1389
|
+
/**
|
|
1390
|
+
* Resolve the hub-bound origin set for a given `OAuthDeps`. Pre-#245 this
|
|
1391
|
+
* was implicit (just `deps.issuer`); post-#245 callers can thread a richer
|
|
1392
|
+
* set through `deps.hubBoundOrigins` so loopback + tailnet + funnel access
|
|
1393
|
+
* all match. Fallback to `[issuer]` keeps callers that haven't migrated
|
|
1394
|
+
* correct on single-origin hubs.
|
|
1395
|
+
*/
|
|
1396
|
+
function resolveBoundOrigins(deps: OAuthDeps): readonly string[] {
|
|
1397
|
+
if (deps.hubBoundOrigins) return deps.hubBoundOrigins();
|
|
1398
|
+
return [deps.issuer];
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1067
1401
|
/**
|
|
1068
1402
|
* POST /oauth/register — RFC 7591 Dynamic Client Registration.
|
|
1069
1403
|
*
|
|
1070
1404
|
* Approval gate (closes #74). New rows land as `pending` by default and
|
|
1071
1405
|
* cannot participate in OAuth flows until an operator runs
|
|
1072
|
-
* `parachute auth approve-client <id>`.
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1406
|
+
* `parachute auth approve-client <id>`. Two bypass paths:
|
|
1407
|
+
*
|
|
1408
|
+
* 1. **Operator-bearer** (#74). `Authorization: Bearer <operator-token>` whose
|
|
1409
|
+
* token carries the `hub:admin` scope — the install-time path used by
|
|
1410
|
+
* first-party modules so `parachute install vault` can self-register
|
|
1411
|
+
* without a human follow-up.
|
|
1412
|
+
* 2. **Operator-session** (#199). A valid `parachute_hub_session` cookie
|
|
1413
|
+
* plus a same-origin `Origin`/`Referer` header. The browser path: an
|
|
1414
|
+
* operator hitting their own SPA from their own browser is by definition
|
|
1415
|
+
* operator-authenticated, so re-requiring approval is friction without
|
|
1416
|
+
* benefit. CSRF defense is `isSameOriginRequest` + the cookie's
|
|
1417
|
+
* `SameSite=Lax` attribute.
|
|
1076
1418
|
*
|
|
1077
1419
|
* If a bearer is presented but invalid or insufficient, we reject with the
|
|
1078
1420
|
* RFC 6750 shape rather than silently downgrading to the public path: a
|
|
1079
1421
|
* caller who tried to authenticate but failed wants to know why, not get
|
|
1080
1422
|
* `pending` back and wonder why their module can't OAuth.
|
|
1423
|
+
*
|
|
1424
|
+
* Access-control matrix:
|
|
1425
|
+
* no auth → pending
|
|
1426
|
+
* bearer (hub:admin) → approved (#74)
|
|
1427
|
+
* bearer (other scope) → 403 insufficient_scope
|
|
1428
|
+
* bearer (malformed) → 401 invalid_token
|
|
1429
|
+
* session cookie + same-origin → approved (#199)
|
|
1430
|
+
* session cookie + cross-origin → pending (CSRF defense)
|
|
1431
|
+
* session cookie + no Origin/Referer → pending
|
|
1432
|
+
* expired/unknown session → pending
|
|
1081
1433
|
*/
|
|
1082
1434
|
export async function handleRegister(
|
|
1083
1435
|
db: Database,
|
|
@@ -1124,6 +1476,20 @@ export async function handleRegister(
|
|
|
1124
1476
|
throw err;
|
|
1125
1477
|
}
|
|
1126
1478
|
}
|
|
1479
|
+
// Operator-session auto-approve (closes #199). The browser path:
|
|
1480
|
+
// operator-authenticated SPA on the hub's own origin can self-register a
|
|
1481
|
+
// client without dropping to a terminal. Two gates: (1) a live (un-expired)
|
|
1482
|
+
// session row keyed by the cookie, (2) Origin/Referer matches the issuer
|
|
1483
|
+
// origin so a cross-site forgery can't ride the cookie. Quietly stays
|
|
1484
|
+
// `pending` on any failure — unlike the bearer path, we don't surface an
|
|
1485
|
+
// error, because absence of session/origin is the *normal* unauthenticated
|
|
1486
|
+
// public-DCR shape.
|
|
1487
|
+
if (status === "pending") {
|
|
1488
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
1489
|
+
if (session && isSameOriginRequest(req, resolveBoundOrigins(deps))) {
|
|
1490
|
+
status = "approved";
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1127
1493
|
const confidential = body.token_endpoint_auth_method === "client_secret_post";
|
|
1128
1494
|
const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
|
|
1129
1495
|
let registered: RegisteredClient;
|