@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/module-manifest.ts
CHANGED
|
@@ -114,6 +114,27 @@ export interface ModuleManifest {
|
|
|
114
114
|
* as `hasAuth` / `init` / `urlForEntry`.
|
|
115
115
|
*/
|
|
116
116
|
readonly managementUrl?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Where the module's primary user-facing UI lives. Hub renders a tile on
|
|
119
|
+
* the discovery page (`/`) Services section when set (see
|
|
120
|
+
* `parachute-patterns/patterns/module-json-extensibility.md` and the
|
|
121
|
+
* `loadUiUrls` resolver in `hub-server.ts`).
|
|
122
|
+
*
|
|
123
|
+
* Two shapes — same rules as `managementUrl`:
|
|
124
|
+
* - A relative path (e.g. `"/notes"`, `"/agent"`) — hub resolves
|
|
125
|
+
* against the canonical hub origin.
|
|
126
|
+
* - A full absolute URL — hub uses verbatim.
|
|
127
|
+
*
|
|
128
|
+
* Absent = no Services tile rendered (the module is API-only or surfaces
|
|
129
|
+
* its UI via a sibling module — e.g. vault content browses through Notes,
|
|
130
|
+
* so vault has no `uiUrl`).
|
|
131
|
+
*
|
|
132
|
+
* Read at every discovery render via `installDir/.parachute/module.json`
|
|
133
|
+
* (mirrors how `managementUrl` is sourced for vaults). Not persisted in
|
|
134
|
+
* services.json — that file's "services own the write side" semantics
|
|
135
|
+
* would clobber any install-time copy on the next service boot.
|
|
136
|
+
*/
|
|
137
|
+
readonly uiUrl?: string;
|
|
117
138
|
/**
|
|
118
139
|
* When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
|
|
119
140
|
* before forwarding (so the backend sees `/health` rather than
|
|
@@ -374,6 +395,7 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
|
|
|
374
395
|
const dependencies = asDependencies(m.dependencies, where);
|
|
375
396
|
const configSchema = asConfigSchema(m.configSchema, where);
|
|
376
397
|
const managementUrl = asManagementUrl(m.managementUrl, where);
|
|
398
|
+
const uiUrl = asUiUrl(m.uiUrl, where);
|
|
377
399
|
let stripPrefix: boolean | undefined;
|
|
378
400
|
if (m.stripPrefix !== undefined) {
|
|
379
401
|
if (typeof m.stripPrefix !== "boolean") {
|
|
@@ -396,6 +418,9 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
|
|
|
396
418
|
if (managementUrl !== undefined) {
|
|
397
419
|
(out as { managementUrl?: string }).managementUrl = managementUrl;
|
|
398
420
|
}
|
|
421
|
+
if (uiUrl !== undefined) {
|
|
422
|
+
(out as { uiUrl?: string }).uiUrl = uiUrl;
|
|
423
|
+
}
|
|
399
424
|
if (stripPrefix !== undefined) {
|
|
400
425
|
(out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
|
|
401
426
|
}
|
|
@@ -403,26 +428,39 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
|
|
|
403
428
|
}
|
|
404
429
|
|
|
405
430
|
function asManagementUrl(v: unknown, where: string): string | undefined {
|
|
431
|
+
return asPathOrUrl(v, where, "managementUrl");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function asUiUrl(v: unknown, where: string): string | undefined {
|
|
435
|
+
return asPathOrUrl(v, where, "uiUrl");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Validate a "path or http(s) URL" field. Both `managementUrl` and `uiUrl`
|
|
440
|
+
* follow the same shape per the module-json-extensibility pattern doc;
|
|
441
|
+
* factored so the next URL-shaped field doesn't have to copy-paste.
|
|
442
|
+
*/
|
|
443
|
+
function asPathOrUrl(v: unknown, where: string, field: string): string | undefined {
|
|
406
444
|
if (v === undefined) return undefined;
|
|
407
445
|
if (typeof v !== "string" || v.length === 0) {
|
|
408
|
-
throw new ModuleManifestError(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
//
|
|
413
|
-
|
|
446
|
+
throw new ModuleManifestError(`${where}: "${field}" must be a non-empty string if present`);
|
|
447
|
+
}
|
|
448
|
+
// Two valid shapes: an absolute path starting with a single "/" or a full
|
|
449
|
+
// http(s) URL. Reject protocol-relative forms like "//evil.com" — they
|
|
450
|
+
// start with "/" but `new URL("//evil.com", base)` resolves to the foreign
|
|
451
|
+
// origin, which would let a malicious module render an off-origin tile and
|
|
452
|
+
// turn the discovery page into an open-redirect surface.
|
|
453
|
+
if (v.startsWith("/") && !v.startsWith("//")) return v;
|
|
414
454
|
try {
|
|
415
455
|
const u = new URL(v);
|
|
416
456
|
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
417
|
-
throw new ModuleManifestError(
|
|
418
|
-
`${where}: "managementUrl" absolute form must use http: or https:`,
|
|
419
|
-
);
|
|
457
|
+
throw new ModuleManifestError(`${where}: "${field}" absolute form must use http: or https:`);
|
|
420
458
|
}
|
|
421
459
|
return v;
|
|
422
460
|
} catch (err) {
|
|
423
461
|
if (err instanceof ModuleManifestError) throw err;
|
|
424
462
|
throw new ModuleManifestError(
|
|
425
|
-
`${where}: "
|
|
463
|
+
`${where}: "${field}" must be a path starting with "/" or a full http(s) URL`,
|
|
426
464
|
);
|
|
427
465
|
}
|
|
428
466
|
}
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
renderError,
|
|
63
63
|
renderLogin,
|
|
64
64
|
} from "./oauth-ui.ts";
|
|
65
|
+
import { isSameOriginRequest } from "./origin-check.ts";
|
|
65
66
|
import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
|
|
66
67
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
67
68
|
import {
|
|
@@ -143,6 +144,18 @@ export interface OAuthDeps {
|
|
|
143
144
|
* `~/.parachute/services.json`; tests inject a fixture.
|
|
144
145
|
*/
|
|
145
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[];
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
export interface ServicesCatalogEntry {
|
|
@@ -158,41 +171,129 @@ export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
|
|
|
158
171
|
* version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
|
|
159
172
|
* to know where vault lives.
|
|
160
173
|
*
|
|
161
|
-
* URL source: `entry.paths[
|
|
174
|
+
* URL source: `entry.paths[*]` from services.json verbatim — never hardcode
|
|
162
175
|
* `/vault/default`. Users who installed with `parachute install vault
|
|
163
176
|
* --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
|
|
164
177
|
* catalog URL must follow that. The custom-vault-name regression test in
|
|
165
|
-
* oauth-handlers.test.ts pins this.
|
|
178
|
+
* oauth-handlers.test.ts pins this for single-vault.
|
|
166
179
|
*
|
|
167
180
|
* Filtering: only services for which the token has at least one scope are
|
|
168
181
|
* included. A scope `vault:read` admits the `vault` service; a token with only
|
|
169
182
|
* `scribe:transcribe` gets a catalog with no vault entry. The check is on the
|
|
170
183
|
* audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
|
|
171
184
|
*
|
|
172
|
-
* Multi-vault
|
|
173
|
-
* `vault
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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.
|
|
178
198
|
*/
|
|
179
199
|
export function buildServicesCatalog(
|
|
180
200
|
manifest: ServicesManifest,
|
|
181
201
|
issuer: string,
|
|
182
202
|
scopes: readonly string[],
|
|
183
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.
|
|
184
214
|
const audiences = new Set<string>();
|
|
215
|
+
const namedVaults = new Set<string>();
|
|
185
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
|
+
}
|
|
186
229
|
const colon = s.indexOf(":");
|
|
187
230
|
if (colon > 0) audiences.add(s.slice(0, colon));
|
|
188
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
|
+
|
|
189
264
|
const base = issuer.replace(/\/$/, "");
|
|
190
265
|
const catalog: ServicesCatalog = {};
|
|
191
266
|
for (const entry of manifest.services) {
|
|
192
|
-
|
|
193
|
-
|
|
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);
|
|
194
294
|
if (!audiences.has(key)) continue;
|
|
195
|
-
if (catalog[key]) continue;
|
|
295
|
+
if (catalog[key]) continue;
|
|
296
|
+
const path = entry.paths[0] ?? "/";
|
|
196
297
|
catalog[key] = { url: `${base}${path}`, version: entry.version };
|
|
197
298
|
}
|
|
198
299
|
return catalog;
|
|
@@ -330,8 +431,14 @@ function pendingClientResponse(
|
|
|
330
431
|
const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
|
|
331
432
|
.split(" ")
|
|
332
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;
|
|
333
440
|
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
334
|
-
const sameOrigin =
|
|
441
|
+
const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
|
|
335
442
|
const csrf = ensureCsrfToken(req);
|
|
336
443
|
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
337
444
|
if (session && sameOrigin) {
|
|
@@ -342,6 +449,7 @@ function pendingClientResponse(
|
|
|
342
449
|
clientId: client.clientId,
|
|
343
450
|
redirectUris: client.redirectUris,
|
|
344
451
|
requestedScopes,
|
|
452
|
+
...(requestedVault !== undefined && { requestedVault }),
|
|
345
453
|
approveForm: { csrfToken: csrf.token, returnTo },
|
|
346
454
|
}),
|
|
347
455
|
403,
|
|
@@ -354,18 +462,39 @@ function pendingClientResponse(
|
|
|
354
462
|
clientId: client.clientId,
|
|
355
463
|
redirectUris: client.redirectUris,
|
|
356
464
|
requestedScopes,
|
|
465
|
+
...(requestedVault !== undefined && { requestedVault }),
|
|
357
466
|
}),
|
|
358
467
|
403,
|
|
359
468
|
extra,
|
|
360
469
|
);
|
|
361
470
|
}
|
|
362
471
|
|
|
363
|
-
/**
|
|
364
|
-
|
|
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(/\/$/, "");
|
|
365
492
|
return jsonResponse(
|
|
366
493
|
{
|
|
367
494
|
error: "invalid_client",
|
|
368
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}`,
|
|
369
498
|
},
|
|
370
499
|
401,
|
|
371
500
|
);
|
|
@@ -376,6 +505,61 @@ function pendingClientJson(): Response {
|
|
|
376
505
|
* either renders the login form (no session) or the consent screen (session
|
|
377
506
|
* present). All authorize-time params are echoed back via hidden inputs so
|
|
378
507
|
* the form POST keeps the binding intact.
|
|
508
|
+
*
|
|
509
|
+
* ## Silent-approve flow (skip-consent gate, hub#75, hub#236)
|
|
510
|
+
*
|
|
511
|
+
* Cross-surface session smoothness ("first Notes use prompts for consent;
|
|
512
|
+
* subsequent uses are seamless") rides on a single gate further down in
|
|
513
|
+
* this function. The end-to-end flow:
|
|
514
|
+
*
|
|
515
|
+
* 1. **First use.** A client lands on `/oauth/authorize` with scope `S`.
|
|
516
|
+
* The user has a session but no prior `grants` row for this
|
|
517
|
+
* (user, client) pair. `isCoveredByGrant` returns false; the gate
|
|
518
|
+
* falls through; the consent screen renders. User clicks approve →
|
|
519
|
+
* `handleAuthorizePost` records a `grants` row keyed on
|
|
520
|
+
* (user_id, client_id) with the approved scopes, then mints the
|
|
521
|
+
* auth code.
|
|
522
|
+
* 2. **Subsequent use, same scopes.** Same client lands on
|
|
523
|
+
* `/oauth/authorize` with scope `S` again. `isCoveredByGrant` finds
|
|
524
|
+
* the row and returns true. The gate fires: auth code minted
|
|
525
|
+
* directly via `issueAuthCodeRedirect`; no consent screen renders;
|
|
526
|
+
* operator sees a silent redirect. This is the seamless second-use
|
|
527
|
+
* experience.
|
|
528
|
+
* 3. **Subsequent use, subset.** Client asks for scope `S' ⊂ S`. The
|
|
529
|
+
* grant covers every requested scope; gate fires.
|
|
530
|
+
* 4. **Subsequent use, novel scope.** Client asks for scope `S''`
|
|
531
|
+
* where `S'' ⊄ S` (a strict superset, or any new scope). The grant
|
|
532
|
+
* doesn't cover the new ask; gate falls through; consent re-renders
|
|
533
|
+
* with the new scope explicit. User must approve to extend the grant.
|
|
534
|
+
* 5. **Grant revoked.** Operator revokes via `/admin/permissions` or
|
|
535
|
+
* `parachute auth revoke-grant`. The next /authorize re-renders
|
|
536
|
+
* consent — already-minted refresh tokens keep working until they
|
|
537
|
+
* expire (or are revoked separately via `/oauth/revoke`).
|
|
538
|
+
*
|
|
539
|
+
* Two important constraints on the gate itself:
|
|
540
|
+
*
|
|
541
|
+
* - **Unnamed vault verbs (`vault:read`) always render consent.** The
|
|
542
|
+
* vault-picker UI is the only path that binds an unnamed scope to a
|
|
543
|
+
* specific vault (grants store narrowed `vault:<name>:<verb>`, so
|
|
544
|
+
* `vault:read` never matches a stored grant literally). Re-flowing
|
|
545
|
+
* with `vault:read` must always show the picker even if any prior
|
|
546
|
+
* grant exists.
|
|
547
|
+
* - **Client re-registration breaks the grant link.** Dynamic Client
|
|
548
|
+
* Registration mints a fresh `client_id` each time; grants are keyed
|
|
549
|
+
* on `(user_id, client_id)` so a re-registered client looks brand-
|
|
550
|
+
* new and re-prompts for consent. (Intentional: the operator should
|
|
551
|
+
* re-consent to an app whose registration was destroyed and re-made
|
|
552
|
+
* — that's a stronger signal of "this is the same app I trusted"
|
|
553
|
+
* than the redirect URI alone.)
|
|
554
|
+
*
|
|
555
|
+
* The full grant-scope subset semantics live in `grants.ts`
|
|
556
|
+
* `isCoveredByGrant`; the gate itself is the if-block below the
|
|
557
|
+
* "Skip-consent gate" comment in this function.
|
|
558
|
+
*
|
|
559
|
+
* Pinned by the regression test "first-use consent → silent-approve →
|
|
560
|
+
* novel scope re-prompts" in `oauth-handlers.test.ts` (hub#236), plus
|
|
561
|
+
* the per-branch tests in the same describe block (subset / superset /
|
|
562
|
+
* revoke / unnamed-vault / re-registered-client).
|
|
379
563
|
*/
|
|
380
564
|
export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps): Response {
|
|
381
565
|
const url = new URL(req.url);
|
|
@@ -692,9 +876,12 @@ async function handleConsentSubmit(
|
|
|
692
876
|
* 2. Active operator session (`findActiveSession`). The operator must be
|
|
693
877
|
* logged into this hub from the browser submitting the form — no
|
|
694
878
|
* session means no operator authority to approve anything.
|
|
695
|
-
* 3. Origin/Referer matches
|
|
696
|
-
* shape as the DCR auto-approve gate (#199, #200): a same-
|
|
697
|
-
* proves the form was rendered by *this hub*, not a forged
|
|
879
|
+
* 3. Origin/Referer matches a hub-bound origin (`isSameOriginRequest`).
|
|
880
|
+
* Same shape as the DCR auto-approve gate (#199, #200, #245): a same-
|
|
881
|
+
* origin POST proves the form was rendered by *this hub*, not a forged
|
|
882
|
+
* page. Bound origins include issuer + loopback + tailnet hostname
|
|
883
|
+
* (#245); pre-#245 was issuer-only and rejected legitimate operator
|
|
884
|
+
* paths from loopback / tailnet.
|
|
698
885
|
*
|
|
699
886
|
* `return_to` validation: the form embeds the original authorize URL so
|
|
700
887
|
* the post-approve redirect lands the operator back on `/oauth/authorize`
|
|
@@ -725,7 +912,7 @@ export async function handleApproveClientPost(
|
|
|
725
912
|
401,
|
|
726
913
|
);
|
|
727
914
|
}
|
|
728
|
-
if (!
|
|
915
|
+
if (!isSameOriginRequest(req, resolveBoundOrigins(deps))) {
|
|
729
916
|
return htmlError(
|
|
730
917
|
"Cross-origin request rejected",
|
|
731
918
|
"The approve form must be submitted from this hub's own origin.",
|
|
@@ -931,7 +1118,7 @@ async function handleTokenAuthorizationCode(
|
|
|
931
1118
|
if (!client) {
|
|
932
1119
|
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
933
1120
|
}
|
|
934
|
-
if (client.status !== "approved") return pendingClientJson();
|
|
1121
|
+
if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
|
|
935
1122
|
const authFailure = authenticateClient(client, req, form, clientId);
|
|
936
1123
|
if (authFailure) return authFailure;
|
|
937
1124
|
let redeemed: ReturnType<typeof redeemAuthCode>;
|
|
@@ -966,6 +1153,13 @@ async function handleTokenAuthorizationCode(
|
|
|
966
1153
|
issuer: deps.issuer,
|
|
967
1154
|
now: deps.now,
|
|
968
1155
|
});
|
|
1156
|
+
// Phase 1 (#212) registry exemption: code-grant access tokens piggyback
|
|
1157
|
+
// on the paired refresh token's `tokens` row (they share `jti` by
|
|
1158
|
+
// design). We don't write a separate access-token row — revocation acts
|
|
1159
|
+
// on the shared jti / family, and the 15-min access TTL bounds the
|
|
1160
|
+
// window before per-jti re-validation is needed. A separate per-jti
|
|
1161
|
+
// access-token row would double registry write volume on every OAuth
|
|
1162
|
+
// grant + every refresh rotation; not worth the trade today.
|
|
969
1163
|
const refresh = signRefreshToken(db, {
|
|
970
1164
|
jti: access.jti,
|
|
971
1165
|
userId: redeemed.userId,
|
|
@@ -1006,7 +1200,7 @@ async function handleTokenRefresh(
|
|
|
1006
1200
|
if (!client) {
|
|
1007
1201
|
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
1008
1202
|
}
|
|
1009
|
-
if (client.status !== "approved") return pendingClientJson();
|
|
1203
|
+
if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
|
|
1010
1204
|
const authFailure = authenticateClient(client, req, form, clientId);
|
|
1011
1205
|
if (authFailure) return authFailure;
|
|
1012
1206
|
const row = findRefreshToken(db, refreshToken);
|
|
@@ -1019,6 +1213,18 @@ async function handleTokenRefresh(
|
|
|
1019
1213
|
if (row.clientId !== clientId) {
|
|
1020
1214
|
return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
|
|
1021
1215
|
}
|
|
1216
|
+
// Refresh-token rows always have a non-null user_id (the caller's hub
|
|
1217
|
+
// user). Post-v6 the column is nullable to accommodate non-OAuth mints
|
|
1218
|
+
// (operator/cli mints), but those rows have no `refresh_token_hash` so
|
|
1219
|
+
// `findRefreshToken` can't return them. Defensive: surface a clean
|
|
1220
|
+
// invalid_grant if a hand-crafted row shows up here without a user.
|
|
1221
|
+
if (!row.userId) {
|
|
1222
|
+
return jsonResponse(
|
|
1223
|
+
{ error: "invalid_grant", error_description: "refresh_token has no associated user" },
|
|
1224
|
+
400,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
const refreshUserId: string = row.userId;
|
|
1022
1228
|
const now = deps.now?.() ?? new Date();
|
|
1023
1229
|
if (row.revokedAt) {
|
|
1024
1230
|
// Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
|
|
@@ -1053,7 +1259,7 @@ async function handleTokenRefresh(
|
|
|
1053
1259
|
// (#107).
|
|
1054
1260
|
const audience = inferAudience(row.scopes);
|
|
1055
1261
|
const access = await signAccessToken(db, {
|
|
1056
|
-
sub:
|
|
1262
|
+
sub: refreshUserId,
|
|
1057
1263
|
scopes: row.scopes,
|
|
1058
1264
|
audience,
|
|
1059
1265
|
clientId: row.clientId,
|
|
@@ -1066,7 +1272,7 @@ async function handleTokenRefresh(
|
|
|
1066
1272
|
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
|
|
1067
1273
|
return signRefreshToken(db, {
|
|
1068
1274
|
jti: access.jti,
|
|
1069
|
-
userId:
|
|
1275
|
+
userId: refreshUserId,
|
|
1070
1276
|
clientId: row.clientId,
|
|
1071
1277
|
scopes: row.scopes,
|
|
1072
1278
|
familyId: row.familyId,
|
|
@@ -1236,37 +1442,15 @@ interface RegisterRequestBody {
|
|
|
1236
1442
|
}
|
|
1237
1443
|
|
|
1238
1444
|
/**
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
* suspicious and rejected: cookie-bearing POSTs from same-origin browsers
|
|
1245
|
-
* always send Origin (per Fetch standard) and almost always send Referer,
|
|
1246
|
-
* so a header-stripped request is more likely a curl probe or a privacy
|
|
1247
|
-
* extension on a third-party site than a legitimate same-origin caller.
|
|
1248
|
-
*
|
|
1249
|
-
* SameSite=Lax on the session cookie (sessions.ts:buildSessionCookie) is the
|
|
1250
|
-
* browser-side defense layer; this function is the server-side belt.
|
|
1445
|
+
* Resolve the hub-bound origin set for a given `OAuthDeps`. Pre-#245 this
|
|
1446
|
+
* was implicit (just `deps.issuer`); post-#245 callers can thread a richer
|
|
1447
|
+
* set through `deps.hubBoundOrigins` so loopback + tailnet + funnel access
|
|
1448
|
+
* all match. Fallback to `[issuer]` keeps callers that haven't migrated
|
|
1449
|
+
* correct on single-origin hubs.
|
|
1251
1450
|
*/
|
|
1252
|
-
function
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
try {
|
|
1256
|
-
return new URL(origin).origin === new URL(issuer).origin;
|
|
1257
|
-
} catch {
|
|
1258
|
-
return false;
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
const referer = req.headers.get("referer");
|
|
1262
|
-
if (referer) {
|
|
1263
|
-
try {
|
|
1264
|
-
return new URL(referer).origin === new URL(issuer).origin;
|
|
1265
|
-
} catch {
|
|
1266
|
-
return false;
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
return false;
|
|
1451
|
+
function resolveBoundOrigins(deps: OAuthDeps): readonly string[] {
|
|
1452
|
+
if (deps.hubBoundOrigins) return deps.hubBoundOrigins();
|
|
1453
|
+
return [deps.issuer];
|
|
1270
1454
|
}
|
|
1271
1455
|
|
|
1272
1456
|
/**
|
|
@@ -1284,7 +1468,7 @@ function originMatchesIssuer(req: Request, issuer: string): boolean {
|
|
|
1284
1468
|
* plus a same-origin `Origin`/`Referer` header. The browser path: an
|
|
1285
1469
|
* operator hitting their own SPA from their own browser is by definition
|
|
1286
1470
|
* operator-authenticated, so re-requiring approval is friction without
|
|
1287
|
-
* benefit. CSRF defense is `
|
|
1471
|
+
* benefit. CSRF defense is `isSameOriginRequest` + the cookie's
|
|
1288
1472
|
* `SameSite=Lax` attribute.
|
|
1289
1473
|
*
|
|
1290
1474
|
* If a bearer is presented but invalid or insufficient, we reject with the
|
|
@@ -1357,7 +1541,7 @@ export async function handleRegister(
|
|
|
1357
1541
|
// public-DCR shape.
|
|
1358
1542
|
if (status === "pending") {
|
|
1359
1543
|
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
1360
|
-
if (session &&
|
|
1544
|
+
if (session && isSameOriginRequest(req, resolveBoundOrigins(deps))) {
|
|
1361
1545
|
status = "approved";
|
|
1362
1546
|
}
|
|
1363
1547
|
}
|
package/src/oauth-ui.ts
CHANGED
|
@@ -111,6 +111,14 @@ export interface ApprovePendingViewProps {
|
|
|
111
111
|
redirectUris: string[];
|
|
112
112
|
/** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
|
|
113
113
|
requestedScopes: string[];
|
|
114
|
+
/**
|
|
115
|
+
* Vault hint from the original `/oauth/authorize?vault=<name>` query param,
|
|
116
|
+
* passed by Notes' VaultPopover (notes#115) when kicking the OAuth flow for
|
|
117
|
+
* a specific vault. Rendered alongside scopes so the operator can tell
|
|
118
|
+
* which vault they're approving access for on a multi-vault hub (closes
|
|
119
|
+
* #244). Single-vault hubs leave this absent and the section omits.
|
|
120
|
+
*/
|
|
121
|
+
requestedVault?: string;
|
|
114
122
|
/**
|
|
115
123
|
* When set, render the inline approve form. The form posts to
|
|
116
124
|
* `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
|
|
@@ -245,12 +253,25 @@ function renderVaultPicker(picker: VaultPicker): string {
|
|
|
245
253
|
* context). The button is the easy path; the CLI is always-available.
|
|
246
254
|
*/
|
|
247
255
|
export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
248
|
-
const { clientName, clientId, redirectUris, requestedScopes, approveForm } =
|
|
256
|
+
const { clientName, clientId, redirectUris, requestedScopes, requestedVault, approveForm } =
|
|
257
|
+
props;
|
|
249
258
|
const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
|
|
250
259
|
const scopeRows =
|
|
251
260
|
requestedScopes.length === 0
|
|
252
261
|
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
253
262
|
: requestedScopes.map(renderScopeRow).join("\n");
|
|
263
|
+
// Vault hint (closes #244): Notes' VaultPopover (notes#115) passes
|
|
264
|
+
// `vault=<name>` on `/oauth/authorize` for per-vault grants. Surface it
|
|
265
|
+
// alongside scopes so a multi-vault operator can tell which vault they're
|
|
266
|
+
// approving for. Missing on single-vault hubs / pre-vault-popover clients —
|
|
267
|
+
// section omits when absent.
|
|
268
|
+
const vaultRow = requestedVault
|
|
269
|
+
? `
|
|
270
|
+
<p class="approve-meta-row">
|
|
271
|
+
<span class="approve-meta-label">vault</span>
|
|
272
|
+
<code class="approve-meta-value">${escapeHtml(requestedVault)}</code>
|
|
273
|
+
</p>`
|
|
274
|
+
: "";
|
|
254
275
|
const formSection = approveForm
|
|
255
276
|
? `
|
|
256
277
|
<form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
|
|
@@ -289,7 +310,7 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
|
289
310
|
<p class="approve-meta-row">
|
|
290
311
|
<span class="approve-meta-label">client_id</span>
|
|
291
312
|
<code class="approve-meta-value">${escapeHtml(clientId)}</code>
|
|
292
|
-
</p
|
|
313
|
+
</p>${vaultRow}
|
|
293
314
|
<div class="approve-meta-row approve-meta-row-block">
|
|
294
315
|
<span class="approve-meta-label">redirect_uris</span>
|
|
295
316
|
<ul class="approve-redirect-list">${redirectList}</ul>
|