@openparachute/hub 0.5.7 → 0.5.10-rc.10
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-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -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__/csrf.test.ts +40 -1
- 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 +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- 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 +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -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-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -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-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- 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 +262 -56
- 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/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -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
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "./clients.ts";
|
|
46
46
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
47
47
|
import { isCoveredByGrant, recordGrant } from "./grants.ts";
|
|
48
|
+
import { consumeFirstClientAutoApproveWindow } from "./hub-settings.ts";
|
|
48
49
|
import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
|
|
49
50
|
import {
|
|
50
51
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
@@ -62,6 +63,8 @@ import {
|
|
|
62
63
|
renderError,
|
|
63
64
|
renderLogin,
|
|
64
65
|
} from "./oauth-ui.ts";
|
|
66
|
+
import { isSameOriginRequest } from "./origin-check.ts";
|
|
67
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
65
68
|
import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
|
|
66
69
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
67
70
|
import {
|
|
@@ -143,6 +146,18 @@ export interface OAuthDeps {
|
|
|
143
146
|
* `~/.parachute/services.json`; tests inject a fixture.
|
|
144
147
|
*/
|
|
145
148
|
loadServicesManifest?: () => ServicesManifest;
|
|
149
|
+
/**
|
|
150
|
+
* Set of origins (`scheme://host:port`) the hub considers itself bound to.
|
|
151
|
+
* Drives the same-origin defense on cookie-based POST endpoints: a request
|
|
152
|
+
* whose Origin/Referer matches any bound origin is accepted; everything
|
|
153
|
+
* else is rejected as cross-origin. Production wires this from
|
|
154
|
+
* `buildHubBoundOrigins` with the hub's port + expose-state hostname so
|
|
155
|
+
* loopback + tailnet + funnel access all work without restarting hub
|
|
156
|
+
* after `parachute expose`. Tests inject deterministic sets. When absent,
|
|
157
|
+
* the gate falls back to `[issuer]` — pre-#245 behavior — so callers that
|
|
158
|
+
* don't yet thread this through stay correct on a single-origin hub.
|
|
159
|
+
*/
|
|
160
|
+
hubBoundOrigins?: () => readonly string[];
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
export interface ServicesCatalogEntry {
|
|
@@ -158,41 +173,129 @@ export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
|
|
|
158
173
|
* version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
|
|
159
174
|
* to know where vault lives.
|
|
160
175
|
*
|
|
161
|
-
* URL source: `entry.paths[
|
|
176
|
+
* URL source: `entry.paths[*]` from services.json verbatim — never hardcode
|
|
162
177
|
* `/vault/default`. Users who installed with `parachute install vault
|
|
163
178
|
* --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
|
|
164
179
|
* catalog URL must follow that. The custom-vault-name regression test in
|
|
165
|
-
* oauth-handlers.test.ts pins this.
|
|
180
|
+
* oauth-handlers.test.ts pins this for single-vault.
|
|
166
181
|
*
|
|
167
182
|
* Filtering: only services for which the token has at least one scope are
|
|
168
183
|
* included. A scope `vault:read` admits the `vault` service; a token with only
|
|
169
184
|
* `scribe:transcribe` gets a catalog with no vault entry. The check is on the
|
|
170
185
|
* audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
|
|
171
186
|
*
|
|
172
|
-
* Multi-vault
|
|
173
|
-
* `vault
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
187
|
+
* Multi-vault (closes #247): emits per-vault keys `vault:<name>` alongside
|
|
188
|
+
* the collapsed `vault` key. A scope `vault:boulder:write` admits only
|
|
189
|
+
* boulder → emits `vault:boulder` (and the legacy `vault` key, pointing at
|
|
190
|
+
* boulder so it resolves consistently). A broad scope `vault:read` admits
|
|
191
|
+
* every vault on the hub → emits `vault:<name>` for each vault path plus
|
|
192
|
+
* the legacy `vault` key (pointing at `entry.paths[0]` of the first vault,
|
|
193
|
+
* unchanged from Phase 1). Notes' OAuthCallback (notes#115 ships the
|
|
194
|
+
* picker; per-vault consumer change is the post-#247 Notes-side PR) reads
|
|
195
|
+
* `services["vault:<name>"]` so it stops collapsing multi-vault grants
|
|
196
|
+
* onto a single VaultRecord URL.
|
|
197
|
+
*
|
|
198
|
+
* Pre-popover clients still see `services.vault` and behave unchanged —
|
|
199
|
+
* that key never goes away. Per-vault keys are additive.
|
|
178
200
|
*/
|
|
179
201
|
export function buildServicesCatalog(
|
|
180
202
|
manifest: ServicesManifest,
|
|
181
203
|
issuer: string,
|
|
182
204
|
scopes: readonly string[],
|
|
183
205
|
): ServicesCatalog {
|
|
206
|
+
// Two scope-derived sets:
|
|
207
|
+
// - audiences: bare service prefix (`vault`, `scribe`) → admits the
|
|
208
|
+
// collapsed key + every per-vault key.
|
|
209
|
+
// - namedVaults: per-vault narrowed scopes (`vault:<name>:<verb>`) →
|
|
210
|
+
// admits only `vault:<name>` and the collapsed `vault`.
|
|
211
|
+
//
|
|
212
|
+
// A token with both `vault:read` and `vault:boulder:write` should land in
|
|
213
|
+
// the "any vault" bucket — the bare scope is permissive, the named one
|
|
214
|
+
// is informational. Detect this via the bare-prefix presence; the named
|
|
215
|
+
// scope's per-vault narrowing still works for clients that prefer it.
|
|
184
216
|
const audiences = new Set<string>();
|
|
217
|
+
const namedVaults = new Set<string>();
|
|
185
218
|
for (const s of scopes) {
|
|
219
|
+
const parts = s.split(":");
|
|
220
|
+
if (
|
|
221
|
+
parts.length === 3 &&
|
|
222
|
+
parts[0] === "vault" &&
|
|
223
|
+
parts[1] &&
|
|
224
|
+
parts[2] &&
|
|
225
|
+
VAULT_VERBS.has(parts[2])
|
|
226
|
+
) {
|
|
227
|
+
namedVaults.add(parts[1]);
|
|
228
|
+
audiences.add("vault");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
186
231
|
const colon = s.indexOf(":");
|
|
187
232
|
if (colon > 0) audiences.add(s.slice(0, colon));
|
|
188
233
|
}
|
|
234
|
+
const broadVaultScope =
|
|
235
|
+
audiences.has("vault") &&
|
|
236
|
+
scopes.some((s) => {
|
|
237
|
+
const parts = s.split(":");
|
|
238
|
+
return (
|
|
239
|
+
parts.length === 2 &&
|
|
240
|
+
parts[0] === "vault" &&
|
|
241
|
+
parts[1] !== undefined &&
|
|
242
|
+
VAULT_VERBS.has(parts[1])
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Count total admitted vault paths across the manifest. Per-vault keys
|
|
247
|
+
// are only worth emitting when there are >1 admitted vaults to
|
|
248
|
+
// disambiguate (or when the token's own scopes are per-vault narrowed —
|
|
249
|
+
// a per-vault scope is an explicit consumer signal that the per-vault
|
|
250
|
+
// key matters even on a single-vault hub). The check is on admitted
|
|
251
|
+
// paths, not raw vault rows: a broad token on a multi-path vault row
|
|
252
|
+
// sees N paths; a per-vault token sees only its own.
|
|
253
|
+
let admittedVaultPathCount = 0;
|
|
254
|
+
if (audiences.has("vault")) {
|
|
255
|
+
for (const entry of manifest.services) {
|
|
256
|
+
if (!isVaultEntry(entry)) continue;
|
|
257
|
+
const paths = entry.paths.length > 0 ? entry.paths : ["/"];
|
|
258
|
+
for (const path of paths) {
|
|
259
|
+
const instance = vaultInstanceNameFor(entry.name, path);
|
|
260
|
+
if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const emitPerVaultKeys = admittedVaultPathCount > 1 || namedVaults.size > 0;
|
|
265
|
+
|
|
189
266
|
const base = issuer.replace(/\/$/, "");
|
|
190
267
|
const catalog: ServicesCatalog = {};
|
|
191
268
|
for (const entry of manifest.services) {
|
|
192
|
-
|
|
193
|
-
|
|
269
|
+
if (isVaultEntry(entry)) {
|
|
270
|
+
if (!audiences.has("vault")) continue;
|
|
271
|
+
// Walk every path the row exposes. Real multi-vault on the hub is a
|
|
272
|
+
// single `parachute-vault` row with N paths (one per vault instance);
|
|
273
|
+
// legacy per-vault rows (`parachute-vault-<name>`) are handled by the
|
|
274
|
+
// same loop because each contributes one path.
|
|
275
|
+
const paths = entry.paths.length > 0 ? entry.paths : ["/"];
|
|
276
|
+
for (const path of paths) {
|
|
277
|
+
const instance = vaultInstanceNameFor(entry.name, path);
|
|
278
|
+
const admit = broadVaultScope || namedVaults.has(instance);
|
|
279
|
+
if (!admit) continue;
|
|
280
|
+
if (emitPerVaultKeys) {
|
|
281
|
+
const perVaultKey = `vault:${instance}`;
|
|
282
|
+
if (!catalog[perVaultKey]) {
|
|
283
|
+
catalog[perVaultKey] = { url: `${base}${path}`, version: entry.version };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Collapsed `vault` key stays for backwards compat. First admitted
|
|
287
|
+
// vault wins (deterministic — `entry.paths[0]` for a broad scope,
|
|
288
|
+
// or the only admitted instance for a per-vault scope).
|
|
289
|
+
if (!catalog.vault) {
|
|
290
|
+
catalog.vault = { url: `${base}${path}`, version: entry.version };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const key = shortName(entry.name);
|
|
194
296
|
if (!audiences.has(key)) continue;
|
|
195
|
-
if (catalog[key]) continue;
|
|
297
|
+
if (catalog[key]) continue;
|
|
298
|
+
const path = entry.paths[0] ?? "/";
|
|
196
299
|
catalog[key] = { url: `${base}${path}`, version: entry.version };
|
|
197
300
|
}
|
|
198
301
|
return catalog;
|
|
@@ -330,8 +433,14 @@ function pendingClientResponse(
|
|
|
330
433
|
const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
|
|
331
434
|
.split(" ")
|
|
332
435
|
.filter((s) => s.length > 0);
|
|
436
|
+
// Vault hint (closes #244): Notes' VaultPopover (notes#115) sets this on
|
|
437
|
+
// the authorize URL when kicking OAuth for a specific vault. Empty-string
|
|
438
|
+
// values normalize to undefined so the approve UI omits the row rather
|
|
439
|
+
// than rendering a blank vault label.
|
|
440
|
+
const vaultParam = authorizeUrl.searchParams.get("vault");
|
|
441
|
+
const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
|
|
333
442
|
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
334
|
-
const sameOrigin =
|
|
443
|
+
const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
|
|
335
444
|
const csrf = ensureCsrfToken(req);
|
|
336
445
|
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
337
446
|
if (session && sameOrigin) {
|
|
@@ -342,6 +451,7 @@ function pendingClientResponse(
|
|
|
342
451
|
clientId: client.clientId,
|
|
343
452
|
redirectUris: client.redirectUris,
|
|
344
453
|
requestedScopes,
|
|
454
|
+
...(requestedVault !== undefined && { requestedVault }),
|
|
345
455
|
approveForm: { csrfToken: csrf.token, returnTo },
|
|
346
456
|
}),
|
|
347
457
|
403,
|
|
@@ -354,18 +464,39 @@ function pendingClientResponse(
|
|
|
354
464
|
clientId: client.clientId,
|
|
355
465
|
redirectUris: client.redirectUris,
|
|
356
466
|
requestedScopes,
|
|
467
|
+
...(requestedVault !== undefined && { requestedVault }),
|
|
357
468
|
}),
|
|
358
469
|
403,
|
|
359
470
|
extra,
|
|
360
471
|
);
|
|
361
472
|
}
|
|
362
473
|
|
|
363
|
-
/**
|
|
364
|
-
|
|
474
|
+
/**
|
|
475
|
+
* JSON response for pending clients hitting /oauth/token. Carries two
|
|
476
|
+
* actionability hints alongside the OAuth error so consumers (Notes, future
|
|
477
|
+
* cross-origin SPAs) can surface an inline approval path instead of dead-
|
|
478
|
+
* ending on a CLI message:
|
|
479
|
+
*
|
|
480
|
+
* - `approve_url` — hub-served SPA route the operator can open in a
|
|
481
|
+
* browser to approve the client in one click. Same-origin to the hub.
|
|
482
|
+
* - `cli_alternative` — the `parachute auth approve-client <id>` shell
|
|
483
|
+
* command, retained for terminal-first operators or scripted flows.
|
|
484
|
+
*
|
|
485
|
+
* Spec note: the OAuth error class stays `invalid_client` per RFC 6749 §5.2
|
|
486
|
+
* — "this client cannot use this endpoint right now" is the semantic match.
|
|
487
|
+
* `access_denied` is reserved for /authorize "user said no" flows; using it
|
|
488
|
+
* here would conflate two distinct error families and break clients doing
|
|
489
|
+
* strict spec-shaped handling. The extra fields are spec-permitted
|
|
490
|
+
* extensions ("other parameters").
|
|
491
|
+
*/
|
|
492
|
+
function pendingClientJson(clientId: string, issuer: string): Response {
|
|
493
|
+
const base = issuer.replace(/\/$/, "");
|
|
365
494
|
return jsonResponse(
|
|
366
495
|
{
|
|
367
496
|
error: "invalid_client",
|
|
368
497
|
error_description: "client is registered but has not been approved by the hub operator (#74)",
|
|
498
|
+
approve_url: `${base}/admin/approve-client/${encodeURIComponent(clientId)}`,
|
|
499
|
+
cli_alternative: `parachute auth approve-client ${clientId}`,
|
|
369
500
|
},
|
|
370
501
|
401,
|
|
371
502
|
);
|
|
@@ -376,6 +507,61 @@ function pendingClientJson(): Response {
|
|
|
376
507
|
* either renders the login form (no session) or the consent screen (session
|
|
377
508
|
* present). All authorize-time params are echoed back via hidden inputs so
|
|
378
509
|
* the form POST keeps the binding intact.
|
|
510
|
+
*
|
|
511
|
+
* ## Silent-approve flow (skip-consent gate, hub#75, hub#236)
|
|
512
|
+
*
|
|
513
|
+
* Cross-surface session smoothness ("first Notes use prompts for consent;
|
|
514
|
+
* subsequent uses are seamless") rides on a single gate further down in
|
|
515
|
+
* this function. The end-to-end flow:
|
|
516
|
+
*
|
|
517
|
+
* 1. **First use.** A client lands on `/oauth/authorize` with scope `S`.
|
|
518
|
+
* The user has a session but no prior `grants` row for this
|
|
519
|
+
* (user, client) pair. `isCoveredByGrant` returns false; the gate
|
|
520
|
+
* falls through; the consent screen renders. User clicks approve →
|
|
521
|
+
* `handleAuthorizePost` records a `grants` row keyed on
|
|
522
|
+
* (user_id, client_id) with the approved scopes, then mints the
|
|
523
|
+
* auth code.
|
|
524
|
+
* 2. **Subsequent use, same scopes.** Same client lands on
|
|
525
|
+
* `/oauth/authorize` with scope `S` again. `isCoveredByGrant` finds
|
|
526
|
+
* the row and returns true. The gate fires: auth code minted
|
|
527
|
+
* directly via `issueAuthCodeRedirect`; no consent screen renders;
|
|
528
|
+
* operator sees a silent redirect. This is the seamless second-use
|
|
529
|
+
* experience.
|
|
530
|
+
* 3. **Subsequent use, subset.** Client asks for scope `S' ⊂ S`. The
|
|
531
|
+
* grant covers every requested scope; gate fires.
|
|
532
|
+
* 4. **Subsequent use, novel scope.** Client asks for scope `S''`
|
|
533
|
+
* where `S'' ⊄ S` (a strict superset, or any new scope). The grant
|
|
534
|
+
* doesn't cover the new ask; gate falls through; consent re-renders
|
|
535
|
+
* with the new scope explicit. User must approve to extend the grant.
|
|
536
|
+
* 5. **Grant revoked.** Operator revokes via `/admin/permissions` or
|
|
537
|
+
* `parachute auth revoke-grant`. The next /authorize re-renders
|
|
538
|
+
* consent — already-minted refresh tokens keep working until they
|
|
539
|
+
* expire (or are revoked separately via `/oauth/revoke`).
|
|
540
|
+
*
|
|
541
|
+
* Two important constraints on the gate itself:
|
|
542
|
+
*
|
|
543
|
+
* - **Unnamed vault verbs (`vault:read`) always render consent.** The
|
|
544
|
+
* vault-picker UI is the only path that binds an unnamed scope to a
|
|
545
|
+
* specific vault (grants store narrowed `vault:<name>:<verb>`, so
|
|
546
|
+
* `vault:read` never matches a stored grant literally). Re-flowing
|
|
547
|
+
* with `vault:read` must always show the picker even if any prior
|
|
548
|
+
* grant exists.
|
|
549
|
+
* - **Client re-registration breaks the grant link.** Dynamic Client
|
|
550
|
+
* Registration mints a fresh `client_id` each time; grants are keyed
|
|
551
|
+
* on `(user_id, client_id)` so a re-registered client looks brand-
|
|
552
|
+
* new and re-prompts for consent. (Intentional: the operator should
|
|
553
|
+
* re-consent to an app whose registration was destroyed and re-made
|
|
554
|
+
* — that's a stronger signal of "this is the same app I trusted"
|
|
555
|
+
* than the redirect URI alone.)
|
|
556
|
+
*
|
|
557
|
+
* The full grant-scope subset semantics live in `grants.ts`
|
|
558
|
+
* `isCoveredByGrant`; the gate itself is the if-block below the
|
|
559
|
+
* "Skip-consent gate" comment in this function.
|
|
560
|
+
*
|
|
561
|
+
* Pinned by the regression test "first-use consent → silent-approve →
|
|
562
|
+
* novel scope re-prompts" in `oauth-handlers.test.ts` (hub#236), plus
|
|
563
|
+
* the per-branch tests in the same describe block (subset / superset /
|
|
564
|
+
* revoke / unnamed-vault / re-registered-client).
|
|
379
565
|
*/
|
|
380
566
|
export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps): Response {
|
|
381
567
|
const url = new URL(req.url);
|
|
@@ -533,7 +719,7 @@ export async function handleAuthorizePost(
|
|
|
533
719
|
|
|
534
720
|
async function handleLoginSubmit(
|
|
535
721
|
db: Database,
|
|
536
|
-
|
|
722
|
+
req: Request,
|
|
537
723
|
form: Awaited<ReturnType<Request["formData"]>>,
|
|
538
724
|
_deps: OAuthDeps,
|
|
539
725
|
csrfToken: string,
|
|
@@ -562,7 +748,9 @@ async function handleLoginSubmit(
|
|
|
562
748
|
);
|
|
563
749
|
}
|
|
564
750
|
const session = createSession(db, { userId: user.id });
|
|
565
|
-
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)
|
|
751
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
752
|
+
secure: isHttpsRequest(req),
|
|
753
|
+
});
|
|
566
754
|
// Redirect back to GET /oauth/authorize with the original query string so
|
|
567
755
|
// the user lands on the consent screen with full params re-validated.
|
|
568
756
|
const u = new URL("/oauth/authorize", "http://placeholder");
|
|
@@ -692,9 +880,12 @@ async function handleConsentSubmit(
|
|
|
692
880
|
* 2. Active operator session (`findActiveSession`). The operator must be
|
|
693
881
|
* logged into this hub from the browser submitting the form — no
|
|
694
882
|
* 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
|
|
883
|
+
* 3. Origin/Referer matches a hub-bound origin (`isSameOriginRequest`).
|
|
884
|
+
* Same shape as the DCR auto-approve gate (#199, #200, #245): a same-
|
|
885
|
+
* origin POST proves the form was rendered by *this hub*, not a forged
|
|
886
|
+
* page. Bound origins include issuer + loopback + tailnet hostname
|
|
887
|
+
* (#245); pre-#245 was issuer-only and rejected legitimate operator
|
|
888
|
+
* paths from loopback / tailnet.
|
|
698
889
|
*
|
|
699
890
|
* `return_to` validation: the form embeds the original authorize URL so
|
|
700
891
|
* the post-approve redirect lands the operator back on `/oauth/authorize`
|
|
@@ -725,7 +916,7 @@ export async function handleApproveClientPost(
|
|
|
725
916
|
401,
|
|
726
917
|
);
|
|
727
918
|
}
|
|
728
|
-
if (!
|
|
919
|
+
if (!isSameOriginRequest(req, resolveBoundOrigins(deps))) {
|
|
729
920
|
return htmlError(
|
|
730
921
|
"Cross-origin request rejected",
|
|
731
922
|
"The approve form must be submitted from this hub's own origin.",
|
|
@@ -931,7 +1122,7 @@ async function handleTokenAuthorizationCode(
|
|
|
931
1122
|
if (!client) {
|
|
932
1123
|
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
933
1124
|
}
|
|
934
|
-
if (client.status !== "approved") return pendingClientJson();
|
|
1125
|
+
if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
|
|
935
1126
|
const authFailure = authenticateClient(client, req, form, clientId);
|
|
936
1127
|
if (authFailure) return authFailure;
|
|
937
1128
|
let redeemed: ReturnType<typeof redeemAuthCode>;
|
|
@@ -966,6 +1157,13 @@ async function handleTokenAuthorizationCode(
|
|
|
966
1157
|
issuer: deps.issuer,
|
|
967
1158
|
now: deps.now,
|
|
968
1159
|
});
|
|
1160
|
+
// Phase 1 (#212) registry exemption: code-grant access tokens piggyback
|
|
1161
|
+
// on the paired refresh token's `tokens` row (they share `jti` by
|
|
1162
|
+
// design). We don't write a separate access-token row — revocation acts
|
|
1163
|
+
// on the shared jti / family, and the 15-min access TTL bounds the
|
|
1164
|
+
// window before per-jti re-validation is needed. A separate per-jti
|
|
1165
|
+
// access-token row would double registry write volume on every OAuth
|
|
1166
|
+
// grant + every refresh rotation; not worth the trade today.
|
|
969
1167
|
const refresh = signRefreshToken(db, {
|
|
970
1168
|
jti: access.jti,
|
|
971
1169
|
userId: redeemed.userId,
|
|
@@ -1006,7 +1204,7 @@ async function handleTokenRefresh(
|
|
|
1006
1204
|
if (!client) {
|
|
1007
1205
|
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
1008
1206
|
}
|
|
1009
|
-
if (client.status !== "approved") return pendingClientJson();
|
|
1207
|
+
if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
|
|
1010
1208
|
const authFailure = authenticateClient(client, req, form, clientId);
|
|
1011
1209
|
if (authFailure) return authFailure;
|
|
1012
1210
|
const row = findRefreshToken(db, refreshToken);
|
|
@@ -1019,6 +1217,18 @@ async function handleTokenRefresh(
|
|
|
1019
1217
|
if (row.clientId !== clientId) {
|
|
1020
1218
|
return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
|
|
1021
1219
|
}
|
|
1220
|
+
// Refresh-token rows always have a non-null user_id (the caller's hub
|
|
1221
|
+
// user). Post-v6 the column is nullable to accommodate non-OAuth mints
|
|
1222
|
+
// (operator/cli mints), but those rows have no `refresh_token_hash` so
|
|
1223
|
+
// `findRefreshToken` can't return them. Defensive: surface a clean
|
|
1224
|
+
// invalid_grant if a hand-crafted row shows up here without a user.
|
|
1225
|
+
if (!row.userId) {
|
|
1226
|
+
return jsonResponse(
|
|
1227
|
+
{ error: "invalid_grant", error_description: "refresh_token has no associated user" },
|
|
1228
|
+
400,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
const refreshUserId: string = row.userId;
|
|
1022
1232
|
const now = deps.now?.() ?? new Date();
|
|
1023
1233
|
if (row.revokedAt) {
|
|
1024
1234
|
// Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
|
|
@@ -1053,7 +1263,7 @@ async function handleTokenRefresh(
|
|
|
1053
1263
|
// (#107).
|
|
1054
1264
|
const audience = inferAudience(row.scopes);
|
|
1055
1265
|
const access = await signAccessToken(db, {
|
|
1056
|
-
sub:
|
|
1266
|
+
sub: refreshUserId,
|
|
1057
1267
|
scopes: row.scopes,
|
|
1058
1268
|
audience,
|
|
1059
1269
|
clientId: row.clientId,
|
|
@@ -1066,7 +1276,7 @@ async function handleTokenRefresh(
|
|
|
1066
1276
|
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
|
|
1067
1277
|
return signRefreshToken(db, {
|
|
1068
1278
|
jti: access.jti,
|
|
1069
|
-
userId:
|
|
1279
|
+
userId: refreshUserId,
|
|
1070
1280
|
clientId: row.clientId,
|
|
1071
1281
|
scopes: row.scopes,
|
|
1072
1282
|
familyId: row.familyId,
|
|
@@ -1236,37 +1446,15 @@ interface RegisterRequestBody {
|
|
|
1236
1446
|
}
|
|
1237
1447
|
|
|
1238
1448
|
/**
|
|
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.
|
|
1449
|
+
* Resolve the hub-bound origin set for a given `OAuthDeps`. Pre-#245 this
|
|
1450
|
+
* was implicit (just `deps.issuer`); post-#245 callers can thread a richer
|
|
1451
|
+
* set through `deps.hubBoundOrigins` so loopback + tailnet + funnel access
|
|
1452
|
+
* all match. Fallback to `[issuer]` keeps callers that haven't migrated
|
|
1453
|
+
* correct on single-origin hubs.
|
|
1251
1454
|
*/
|
|
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;
|
|
1455
|
+
function resolveBoundOrigins(deps: OAuthDeps): readonly string[] {
|
|
1456
|
+
if (deps.hubBoundOrigins) return deps.hubBoundOrigins();
|
|
1457
|
+
return [deps.issuer];
|
|
1270
1458
|
}
|
|
1271
1459
|
|
|
1272
1460
|
/**
|
|
@@ -1284,7 +1472,7 @@ function originMatchesIssuer(req: Request, issuer: string): boolean {
|
|
|
1284
1472
|
* plus a same-origin `Origin`/`Referer` header. The browser path: an
|
|
1285
1473
|
* operator hitting their own SPA from their own browser is by definition
|
|
1286
1474
|
* operator-authenticated, so re-requiring approval is friction without
|
|
1287
|
-
* benefit. CSRF defense is `
|
|
1475
|
+
* benefit. CSRF defense is `isSameOriginRequest` + the cookie's
|
|
1288
1476
|
* `SameSite=Lax` attribute.
|
|
1289
1477
|
*
|
|
1290
1478
|
* If a bearer is presented but invalid or insufficient, we reject with the
|
|
@@ -1357,10 +1545,23 @@ export async function handleRegister(
|
|
|
1357
1545
|
// public-DCR shape.
|
|
1358
1546
|
if (status === "pending") {
|
|
1359
1547
|
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
1360
|
-
if (session &&
|
|
1548
|
+
if (session && isSameOriginRequest(req, resolveBoundOrigins(deps))) {
|
|
1361
1549
|
status = "approved";
|
|
1362
1550
|
}
|
|
1363
1551
|
}
|
|
1552
|
+
// First-client auto-approve window (hub#268 Item 3). The wizard's expose
|
|
1553
|
+
// step opens a 60-minute window where the very next registration is
|
|
1554
|
+
// auto-approved. Single-use — the consume call clears the row on
|
|
1555
|
+
// success, so client #2 falls through to the standard pending-approval
|
|
1556
|
+
// flow. Logged so an operator chasing odd behavior can see it fired
|
|
1557
|
+
// and which client got the free pass.
|
|
1558
|
+
let autoApprovedByWizardWindow = false;
|
|
1559
|
+
if (status === "pending") {
|
|
1560
|
+
if (consumeFirstClientAutoApproveWindow(db, deps.now ?? (() => new Date()))) {
|
|
1561
|
+
status = "approved";
|
|
1562
|
+
autoApprovedByWizardWindow = true;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1364
1565
|
const confidential = body.token_endpoint_auth_method === "client_secret_post";
|
|
1365
1566
|
const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
|
|
1366
1567
|
let registered: RegisteredClient;
|
|
@@ -1377,6 +1578,11 @@ export async function handleRegister(
|
|
|
1377
1578
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1378
1579
|
return jsonResponse({ error: "invalid_client_metadata", error_description: msg }, 400);
|
|
1379
1580
|
}
|
|
1581
|
+
if (autoApprovedByWizardWindow) {
|
|
1582
|
+
console.log(
|
|
1583
|
+
`[oauth] auto-approved first client clientId=${registered.client.clientId} within wizard window (hub#268 Item 3)`,
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1380
1586
|
const respBody: Record<string, unknown> = {
|
|
1381
1587
|
client_id: registered.client.clientId,
|
|
1382
1588
|
redirect_uris: registered.client.redirectUris,
|
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>
|