@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/hub.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { CANONICAL_TAGLINE, WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
4
4
|
import { CONFIG_DIR } from "./config.ts";
|
|
5
5
|
import { CSRF_FIELD_NAME } from "./csrf.ts";
|
|
6
6
|
|
|
@@ -84,15 +84,34 @@ function buildHtml({ session }: RenderHubOpts): string {
|
|
|
84
84
|
const authBlock = session
|
|
85
85
|
? renderSignedIn(session.displayName, session.csrfToken)
|
|
86
86
|
: renderSignedOut();
|
|
87
|
-
|
|
87
|
+
// Gate the verbose discovery sections (Get started / Services / Admin)
|
|
88
|
+
// and their data-loading script on auth state. A signed-out visitor sees
|
|
89
|
+
// a clean, minimal landing — brand + tagline + a clear "Sign in" call —
|
|
90
|
+
// not the hub's service catalog, vault listings, or admin links. The
|
|
91
|
+
// detail un-gates the moment they sign in (the server already knows auth
|
|
92
|
+
// state from the session cookie, so this stays a no-JS-required,
|
|
93
|
+
// session-aware render). Operator feedback from a live multi-user deploy:
|
|
94
|
+
// the signed-out page exposed too much to anonymous visitors.
|
|
95
|
+
const body = session ? SIGNED_IN_BODY : SIGNED_OUT_BODY;
|
|
96
|
+
const script = session ? DISCOVERY_SCRIPT : "";
|
|
97
|
+
return HTML_TEMPLATE.replace("<!--AUTH-INDICATOR-->", authBlock)
|
|
98
|
+
.replace("<!--DISCOVERY-BODY-->", body)
|
|
99
|
+
.replace("<!--DISCOVERY-SCRIPT-->", script);
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
function renderSignedIn(displayName: string, csrfToken: string): string {
|
|
91
103
|
// Inline POST form so sign-out works without JS. Submit button is
|
|
92
104
|
// styled as a text link via `.auth-signout` so the visual weight
|
|
93
105
|
// matches the surrounding "Signed in as <name>" text.
|
|
106
|
+
//
|
|
107
|
+
// The "Account" link is the single breadcrumb to `/account/` — the
|
|
108
|
+
// self-service home where any signed-in user (admin or invited
|
|
109
|
+
// member) can change their password, see their vault, and sign out.
|
|
110
|
+
// Without it, a friend who's been handed credentials has no way to
|
|
111
|
+
// discover the change-password surface after the first-login prompt.
|
|
94
112
|
return `<div class="auth-indicator">
|
|
95
113
|
<span class="muted">Signed in as <strong>${escapeHtml(displayName)}</strong></span>
|
|
114
|
+
<a href="/account/" class="auth-account">Account</a>
|
|
96
115
|
<form method="POST" action="/logout" class="auth-signout-form">
|
|
97
116
|
<input type="hidden" name="${CSRF_FIELD_NAME}" value="${escapeAttr(csrfToken)}" />
|
|
98
117
|
<button type="submit" class="auth-signout">Sign out</button>
|
|
@@ -203,7 +222,7 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
203
222
|
margin: 0;
|
|
204
223
|
display: inline;
|
|
205
224
|
}
|
|
206
|
-
.auth-signout, .auth-signin {
|
|
225
|
+
.auth-signout, .auth-signin, .auth-account {
|
|
207
226
|
background: none;
|
|
208
227
|
border: none;
|
|
209
228
|
padding: 0;
|
|
@@ -214,10 +233,10 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
214
233
|
text-decoration-thickness: 1px;
|
|
215
234
|
text-underline-offset: 2px;
|
|
216
235
|
}
|
|
217
|
-
.auth-signout:hover, .auth-signin:hover {
|
|
236
|
+
.auth-signout:hover, .auth-signin:hover, .auth-account:hover {
|
|
218
237
|
color: var(--accent-hover);
|
|
219
238
|
}
|
|
220
|
-
a.auth-signin {
|
|
239
|
+
a.auth-signin, a.auth-account {
|
|
221
240
|
/* Anchor needs explicit reset since the a element has its own
|
|
222
241
|
color/decoration. */
|
|
223
242
|
border-bottom: none;
|
|
@@ -262,6 +281,34 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
262
281
|
font-size: 0.92rem;
|
|
263
282
|
margin: 0 0 1.25rem;
|
|
264
283
|
}
|
|
284
|
+
/* Signed-out landing: a single centered "Sign in" call under the brand.
|
|
285
|
+
Minimal by design — the service catalog + admin surfaces stay hidden
|
|
286
|
+
until the visitor authenticates. */
|
|
287
|
+
.signed-out-cta {
|
|
288
|
+
text-align: center;
|
|
289
|
+
margin-bottom: 0;
|
|
290
|
+
}
|
|
291
|
+
.signed-out-lede {
|
|
292
|
+
color: var(--fg-muted);
|
|
293
|
+
font-size: 1.05rem;
|
|
294
|
+
margin: 0 0 1.5rem;
|
|
295
|
+
}
|
|
296
|
+
.btn-signin {
|
|
297
|
+
display: inline-block;
|
|
298
|
+
background: var(--accent);
|
|
299
|
+
color: var(--card-bg);
|
|
300
|
+
font-family: var(--sans);
|
|
301
|
+
font-size: 1rem;
|
|
302
|
+
font-weight: 500;
|
|
303
|
+
text-decoration: none;
|
|
304
|
+
padding: 0.65rem 1.6rem;
|
|
305
|
+
border-radius: 8px;
|
|
306
|
+
transition: background 0.15s ease, transform 0.15s ease;
|
|
307
|
+
}
|
|
308
|
+
.btn-signin:hover {
|
|
309
|
+
background: var(--accent-hover);
|
|
310
|
+
transform: translateY(-1px);
|
|
311
|
+
}
|
|
265
312
|
.grid {
|
|
266
313
|
display: grid;
|
|
267
314
|
gap: 1.25rem;
|
|
@@ -371,7 +418,22 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
371
418
|
<h1>${WORDMARK_TEXT}</h1>
|
|
372
419
|
<p class="tagline">${CANONICAL_TAGLINE}</p>
|
|
373
420
|
</header>
|
|
421
|
+
<!--DISCOVERY-BODY-->
|
|
422
|
+
<footer>
|
|
423
|
+
<a href="/.well-known/parachute.json">discovery</a>
|
|
424
|
+
</footer>
|
|
425
|
+
</main>
|
|
426
|
+
<!--DISCOVERY-SCRIPT-->
|
|
427
|
+
</body>
|
|
428
|
+
</html>
|
|
429
|
+
`;
|
|
374
430
|
|
|
431
|
+
// The verbose discovery body — the service catalog, admin surfaces, and the
|
|
432
|
+
// "Get started" CTA. Rendered ONLY for a signed-in visitor (`buildHtml`
|
|
433
|
+
// selects this vs SIGNED_OUT_BODY on `session`). Anonymous visitors get the
|
|
434
|
+
// slim landing below instead, so the hub's internal surface isn't exposed
|
|
435
|
+
// pre-auth.
|
|
436
|
+
const SIGNED_IN_BODY = `
|
|
375
437
|
<section class="section" id="get-started-section" hidden>
|
|
376
438
|
<h2>Get started</h2>
|
|
377
439
|
<p class="section-sub">Jump straight into what you came here for.</p>
|
|
@@ -391,12 +453,21 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
391
453
|
<p class="section-sub">Manage this hub — vaults, permissions, tokens.</p>
|
|
392
454
|
<div class="grid" id="admin-grid"></div>
|
|
393
455
|
</section>
|
|
456
|
+
`;
|
|
394
457
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
<
|
|
458
|
+
// The slim signed-out landing. Brand + tagline (in the header above) plus a
|
|
459
|
+
// single clear "Sign in" call — no service catalog, no vault listings, no
|
|
460
|
+
// admin links. Keep it tasteful and minimal; the detail un-gates on sign-in.
|
|
461
|
+
const SIGNED_OUT_BODY = `
|
|
462
|
+
<section class="section signed-out-cta" id="signed-out-cta">
|
|
463
|
+
<p class="signed-out-lede">Sign in to reach your vault and the services on this hub.</p>
|
|
464
|
+
<a href="/login?next=/" class="btn-signin" data-testid="signed-out-signin">Sign in →</a>
|
|
465
|
+
</section>
|
|
466
|
+
`;
|
|
467
|
+
|
|
468
|
+
// The data-loading script for the signed-in discovery body. Emitted only
|
|
469
|
+
// when signed in (the signed-out body has nothing for it to populate).
|
|
470
|
+
const DISCOVERY_SCRIPT = `<script>
|
|
400
471
|
(async () => {
|
|
401
472
|
const servicesGrid = document.getElementById('services-grid');
|
|
402
473
|
const adminGrid = document.getElementById('admin-grid');
|
|
@@ -607,7 +678,4 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
607
678
|
|
|
608
679
|
void loadServices();
|
|
609
680
|
})();
|
|
610
|
-
</script
|
|
611
|
-
</body>
|
|
612
|
-
</html>
|
|
613
|
-
`;
|
|
681
|
+
</script>`;
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { Database } from "bun:sqlite";
|
|
25
25
|
import { AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
26
|
+
import { renderTotpChallenge } from "./admin-login-ui.ts";
|
|
26
27
|
import {
|
|
27
28
|
AuthCodeExpiredError,
|
|
28
29
|
AuthCodeNotFoundError,
|
|
@@ -65,7 +66,9 @@ import {
|
|
|
65
66
|
renderUnknownClient,
|
|
66
67
|
} from "./oauth-ui.ts";
|
|
67
68
|
import { isSameOriginRequest } from "./origin-check.ts";
|
|
69
|
+
import { buildPendingLoginCookie, createPendingLogin } from "./pending-login.ts";
|
|
68
70
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
71
|
+
import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
|
|
69
72
|
import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
|
|
70
73
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
71
74
|
import {
|
|
@@ -83,7 +86,14 @@ import {
|
|
|
83
86
|
findSession,
|
|
84
87
|
parseSessionCookie,
|
|
85
88
|
} from "./sessions.ts";
|
|
86
|
-
import {
|
|
89
|
+
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
90
|
+
import {
|
|
91
|
+
getUserById,
|
|
92
|
+
getUserByUsername,
|
|
93
|
+
isFirstAdmin,
|
|
94
|
+
vaultVerbsForUserVault,
|
|
95
|
+
verifyPassword,
|
|
96
|
+
} from "./users.ts";
|
|
87
97
|
import { listVaultNames } from "./vault-names.ts";
|
|
88
98
|
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
89
99
|
|
|
@@ -460,6 +470,9 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
|
|
|
460
470
|
codeChallenge,
|
|
461
471
|
codeChallengeMethod,
|
|
462
472
|
state: url.searchParams.get("state"),
|
|
473
|
+
// RFC 8707 resource indicator (optional). When present and resolvable to
|
|
474
|
+
// a per-vault MCP resource, drives the narrow-consent + named-scope path.
|
|
475
|
+
resource: url.searchParams.get("resource"),
|
|
463
476
|
};
|
|
464
477
|
}
|
|
465
478
|
|
|
@@ -821,7 +834,7 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
821
834
|
parsed.state,
|
|
822
835
|
);
|
|
823
836
|
}
|
|
824
|
-
|
|
837
|
+
let client = getClient(db, parsed.clientId);
|
|
825
838
|
if (!client) {
|
|
826
839
|
// Can't safely redirect — we don't trust the redirect_uri until we've
|
|
827
840
|
// matched it against a registered client. Render an HTML error that
|
|
@@ -832,7 +845,71 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
832
845
|
return unknownClientResponse(parsed.clientId, parsed.redirectUri, deps);
|
|
833
846
|
}
|
|
834
847
|
if (client.status !== "approved") {
|
|
835
|
-
|
|
848
|
+
// Single-consent change (2026-05-29): the separate operator "approve this
|
|
849
|
+
// client" gate is retired — the user's OAuth consent IS the authorization.
|
|
850
|
+
// A request carrying a valid session auto-approves the pending client and
|
|
851
|
+
// FALLS THROUGH into the normal consent path below (resource-narrow →
|
|
852
|
+
// non-requestable gate → skip-consent / same-hub → consent render). A
|
|
853
|
+
// session-less request still renders the unauth "App not yet approved"
|
|
854
|
+
// page (`pendingClientResponse`), whose sign-in CTA (`loginNextUrl`)
|
|
855
|
+
// round-trips back to this authorize URL; after login the user re-enters
|
|
856
|
+
// WITH a session → hits this auto-approve branch → consent.
|
|
857
|
+
//
|
|
858
|
+
// We resolve the session via the same `parseSessionCookie` + `findSession`
|
|
859
|
+
// pair the consent path uses below (not `findActiveSession`) so the
|
|
860
|
+
// auto-approve predicate and the consent render agree on session identity.
|
|
861
|
+
const earlySessionId = parseSessionCookie(req.headers.get("cookie"));
|
|
862
|
+
const earlySession = earlySessionId ? findSession(db, earlySessionId) : null;
|
|
863
|
+
if (!earlySession) {
|
|
864
|
+
return pendingClientResponse(db, req, client, url, deps);
|
|
865
|
+
}
|
|
866
|
+
console.log(
|
|
867
|
+
`[oauth] auto-approved client on user consent (single-consent) client_id=${client.clientId} user_id=${earlySession.userId} (2026-05-29)`,
|
|
868
|
+
);
|
|
869
|
+
approveClient(db, client.clientId);
|
|
870
|
+
|
|
871
|
+
// Trust-by-client_name carry-over (hub#409, preserved through the
|
|
872
|
+
// single-consent change). Notes/Claude DCR a fresh client_id per session;
|
|
873
|
+
// when the user has a prior grant under the SAME client_name that covers
|
|
874
|
+
// the requested scopes, re-link must stay SILENT. The skip-consent gate
|
|
875
|
+
// below keys on (user, client_id) and the fresh client_id has no grant
|
|
876
|
+
// yet — so we re-record that prior coverage onto the fresh client_id here.
|
|
877
|
+
// The mint downstream goes through `issueAuthCodeRedirect`, which caps to
|
|
878
|
+
// held authority, so this carry-over can never silently re-grant an
|
|
879
|
+
// un-held verb. Guarded identically to the in-`pendingClientResponse`
|
|
880
|
+
// block: same-origin + non-empty client_name + non-admin requested scopes
|
|
881
|
+
// (`scopeIsAdmin` recognizes the named admin form now — load-bearing) +
|
|
882
|
+
// prior-grant coverage. When it doesn't apply, fall through to the consent
|
|
883
|
+
// render (the single-consent payoff: one consent screen, then silent).
|
|
884
|
+
const earlyRequested = parsed.scope.split(" ").filter((s) => s.length > 0);
|
|
885
|
+
if (
|
|
886
|
+
isSameOriginRequest(req, resolveBoundOrigins(deps)) &&
|
|
887
|
+
client.clientName &&
|
|
888
|
+
earlyRequested.length > 0 &&
|
|
889
|
+
!earlyRequested.some(scopeIsAdmin) &&
|
|
890
|
+
isCoveredByGrantForClientName(db, earlySession.userId, client.clientName, earlyRequested)
|
|
891
|
+
) {
|
|
892
|
+
console.log(
|
|
893
|
+
`[oauth] carried prior client_name trust onto fresh client_id=${client.clientId} client_name=${JSON.stringify(client.clientName)} user_id=${earlySession.userId} scopes=${earlyRequested.join(" ")} (hub#409)`,
|
|
894
|
+
);
|
|
895
|
+
recordGrant(
|
|
896
|
+
db,
|
|
897
|
+
earlySession.userId,
|
|
898
|
+
client.clientId,
|
|
899
|
+
earlyRequested,
|
|
900
|
+
deps.now?.() ?? new Date(),
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Re-fetch so `client.status` reflects `approved` for the rest of this
|
|
905
|
+
// function (the same-hub gate and consent props read `client`). Fall
|
|
906
|
+
// through on success; if the refresh somehow failed, render the unauth
|
|
907
|
+
// pending page defensively (should never happen given approveClient).
|
|
908
|
+
const refreshed = getClient(db, client.clientId);
|
|
909
|
+
if (!refreshed || refreshed.status !== "approved") {
|
|
910
|
+
return pendingClientResponse(db, req, client, url, deps);
|
|
911
|
+
}
|
|
912
|
+
client = refreshed;
|
|
836
913
|
}
|
|
837
914
|
try {
|
|
838
915
|
requireRegisteredRedirectUri(client, parsed.redirectUri);
|
|
@@ -844,6 +921,41 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
844
921
|
);
|
|
845
922
|
}
|
|
846
923
|
|
|
924
|
+
// RFC 8707 resource binding. When the client named a per-vault MCP
|
|
925
|
+
// resource (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the
|
|
926
|
+
// requested vault verbs to the named `vault:<name>:<verb>` form BEFORE any
|
|
927
|
+
// downstream processing. Two effects:
|
|
928
|
+
//
|
|
929
|
+
// 1. The consent screen shows ONLY that vault's scopes (the picker locks
|
|
930
|
+
// to <name>) instead of the whole-hub catalog — a friend connecting to
|
|
931
|
+
// one vault no longer sees `hub:admin`, `scribe:admin`, or every other
|
|
932
|
+
// vault's verbs.
|
|
933
|
+
// 2. The minted token carries the named scope, so `inferAudience` stamps
|
|
934
|
+
// `aud=vault.<name>` and a current-line vault accepts it (an unnamed
|
|
935
|
+
// `vault:read` token is rejected by `findBroadVaultScopes`).
|
|
936
|
+
//
|
|
937
|
+
// Narrowing happens before the non-requestable gate (below) on purpose: if
|
|
938
|
+
// a resource-bound client somehow asked for `vault:admin`, narrowing makes
|
|
939
|
+
// it `vault:<name>:admin`, which IS non-requestable — so the gate correctly
|
|
940
|
+
// blocks it. Read/write narrow to the requestable named form. Non-vault
|
|
941
|
+
// scopes and already-named scopes for other vaults pass through unchanged.
|
|
942
|
+
//
|
|
943
|
+
// No resource, or a resource that isn't one of our per-vault MCP resources
|
|
944
|
+
// (off-origin, malformed, non-vault path) → `boundVault` is null and the
|
|
945
|
+
// flow is byte-for-byte the pre-#461 behavior (manual picker, etc.).
|
|
946
|
+
const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
|
|
947
|
+
if (boundVault) {
|
|
948
|
+
const narrowed = narrowResourceVaultScopes(
|
|
949
|
+
parsed.scope.split(" ").filter((s) => s.length > 0),
|
|
950
|
+
boundVault,
|
|
951
|
+
);
|
|
952
|
+
// Rewrite `parsed.scope` so the narrowed named scopes flow through every
|
|
953
|
+
// downstream consumer: the login-redirect query round-trip, the consent
|
|
954
|
+
// props + hidden inputs, the skip-consent grant lookup, and the
|
|
955
|
+
// auth-code mint.
|
|
956
|
+
parsed.scope = narrowed.join(" ");
|
|
957
|
+
}
|
|
958
|
+
|
|
847
959
|
// Operator-only scope gate (#96). Reject any request that names a scope
|
|
848
960
|
// we'll never mint via this flow — `parachute:host:admin` and friends.
|
|
849
961
|
// Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
|
|
@@ -991,10 +1103,13 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
991
1103
|
console.log(
|
|
992
1104
|
`[oauth] auto-approved same-hub client client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#312)`,
|
|
993
1105
|
);
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
|
|
1106
|
+
// The grant is recorded INSIDE issueAuthCodeRedirect with the CAPPED
|
|
1107
|
+
// scopes (single choke-point, single source of truth) so the next
|
|
1108
|
+
// /authorize for this (user, client, scopes) hits the standard
|
|
1109
|
+
// skip-consent path (#75) — and can never replay an un-held verb. The
|
|
1110
|
+
// `!hasAdminScope` guard above already keeps admin scopes off this path
|
|
1111
|
+
// (they fall through to consent), so the cap is a no-op here for the
|
|
1112
|
+
// common case, but it still runs unconditionally for defense in depth.
|
|
998
1113
|
return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
|
|
999
1114
|
}
|
|
1000
1115
|
|
|
@@ -1008,10 +1123,89 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
1008
1123
|
}
|
|
1009
1124
|
|
|
1010
1125
|
/**
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1126
|
+
* Anti-privilege-escalation cap (THE security crux of the single-consent
|
|
1127
|
+
* change, 2026-05-29). A user may only delegate authority they themselves
|
|
1128
|
+
* hold: the OAuth consent that authorizes a client must never grant a named
|
|
1129
|
+
* vault verb the consenting user doesn't actually hold on that vault.
|
|
1130
|
+
*
|
|
1131
|
+
* For each scope shaped `vault:<name>:<verb>` (verb ∈ VAULT_VERBS) when the
|
|
1132
|
+
* user is NOT the hub owner, the verb is admitted only if it appears in
|
|
1133
|
+
* `vaultVerbsForUserVault(db, userId, name)` (the verbs the user holds on
|
|
1134
|
+
* that vault, derived from their `user_vaults` role). Otherwise the scope is
|
|
1135
|
+
* DROPPED. Non-vault scopes and unnamed `vault:<verb>` (which never reach
|
|
1136
|
+
* mint without picker-narrowing) pass through untouched.
|
|
1137
|
+
*
|
|
1138
|
+
* The owner (`isFirstAdmin`) bypasses the cap entirely — they hold admin on
|
|
1139
|
+
* every vault by construction (admin posture is the unrestricted sentinel;
|
|
1140
|
+
* see `vaultScopeForUser`). Owner=isFirstAdmin is the Phase-1 definition of
|
|
1141
|
+
* "holds admin everywhere"; revisit when multi-admin lands.
|
|
1142
|
+
*
|
|
1143
|
+
* Security argument (documented at the call site too):
|
|
1144
|
+
* - The authority source of truth today is `isFirstAdmin` for owner-wide
|
|
1145
|
+
* authority and `user_vaults.role` (via `vaultVerbsForRole`) for assigned
|
|
1146
|
+
* users.
|
|
1147
|
+
* - `vaultVerbsForRole` provably never returns `admin` for an assigned user
|
|
1148
|
+
* (it maps write→[read,write], read→[read], unknown→[]), so this helper
|
|
1149
|
+
* drops `vault:<name>:admin` for every non-owner BY CONSTRUCTION — without
|
|
1150
|
+
* hardcoding "drop admin". It reads the held verb set and admits only
|
|
1151
|
+
* held verbs, so it's forward-compatible: if a future role ever granted
|
|
1152
|
+
* admin, the cap would admit it automatically.
|
|
1153
|
+
* - Applied inside `issueAuthCodeRedirect` (the single choke-point ALL mint
|
|
1154
|
+
* paths funnel through: consent-submit, skip-consent, and same-hub
|
|
1155
|
+
* auto-trust), the CAPPED set is what gets both recorded (`recordGrant`)
|
|
1156
|
+
* and minted (`issueAuthCode`). No mint path can bypass it, and a later
|
|
1157
|
+
* skip-consent flow can never replay an un-held admin verb because it was
|
|
1158
|
+
* never recorded.
|
|
1159
|
+
*/
|
|
1160
|
+
function capScopesToUserAuthority(
|
|
1161
|
+
db: Database,
|
|
1162
|
+
userId: string,
|
|
1163
|
+
scopes: readonly string[],
|
|
1164
|
+
opts: { userIsAdmin: boolean },
|
|
1165
|
+
): string[] {
|
|
1166
|
+
if (opts.userIsAdmin) return [...scopes];
|
|
1167
|
+
return scopes.filter((s) => {
|
|
1168
|
+
const parts = s.split(":");
|
|
1169
|
+
if (parts.length !== 3 || parts[0] !== "vault") return true; // non-named — pass through
|
|
1170
|
+
const name = parts[1];
|
|
1171
|
+
const verb = parts[2];
|
|
1172
|
+
if (name === undefined || verb === undefined || !VAULT_VERBS.has(verb)) return true;
|
|
1173
|
+
// Named vault verb requested by a non-owner: admit only if the user holds
|
|
1174
|
+
// it. `vaultVerbsForUserVault` returns null for an unassigned vault (drop)
|
|
1175
|
+
// or the held verb list (today read/write only — never admin).
|
|
1176
|
+
const held = vaultVerbsForUserVault(db, userId, name);
|
|
1177
|
+
return held !== null && (held as readonly string[]).includes(verb);
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Mint an auth code and redirect to the client's redirect_uri. The SINGLE
|
|
1183
|
+
* mint choke-point — shared by the consent-submit path (`handleConsentSubmit`),
|
|
1184
|
+
* the skip-consent path (#75), and the same-hub auto-trust path (hub#312) in
|
|
1185
|
+
* `handleAuthorizeGet`. Caller is responsible for having already validated the
|
|
1186
|
+
* client + redirect_uri and for having narrowed unnamed `vault:<verb>` scopes
|
|
1187
|
+
* to their named form (so the cap below sees final shapes).
|
|
1188
|
+
*
|
|
1189
|
+
* This is the single choke-point for two responsibilities, so NO mint path can
|
|
1190
|
+
* bypass them:
|
|
1191
|
+
* 1. Anti-privilege-escalation cap (`capScopesToUserAuthority`): a non-owner
|
|
1192
|
+
* can only delegate vault verbs they hold; un-held verbs (notably admin)
|
|
1193
|
+
* are dropped. An admin-only request from a non-owner caps to EMPTY → we
|
|
1194
|
+
* refuse with `invalid_scope` rather than mint a zero-scope token. EVERY
|
|
1195
|
+
* auth code is minted through here, so the cap runs before every mint —
|
|
1196
|
+
* even when a stale `grants` row already lists an un-held admin verb (the
|
|
1197
|
+
* cap, not the grant lookup, is what blocks the mint).
|
|
1198
|
+
* 2. Grant recording (`recordGrant`) with the CAPPED scopes for THIS mint —
|
|
1199
|
+
* so a later skip-consent flow re-entering with the same (user, client)
|
|
1200
|
+
* can never replay an un-held verb. UNION semantics make this idempotent.
|
|
1201
|
+
*
|
|
1202
|
+
* Not the ONLY `recordGrant` call in this module, though: two other guarded
|
|
1203
|
+
* fast-path records exist — the trust-by-client_name auto-promote in
|
|
1204
|
+
* `pendingClientResponse` (~L585) and the auto-approve carry-over in
|
|
1205
|
+
* `handleAuthorizeGet` (~L895). Both are gated by `!some(scopeIsAdmin)`, so
|
|
1206
|
+
* neither can ever record an admin verb, and any mint they unlock still flows
|
|
1207
|
+
* back through this function's cap. So the invariant "no minted token, and no
|
|
1208
|
+
* grant row, ever carries an un-held admin verb" holds across all paths.
|
|
1015
1209
|
*/
|
|
1016
1210
|
function issueAuthCodeRedirect(
|
|
1017
1211
|
db: Database,
|
|
@@ -1020,11 +1214,37 @@ function issueAuthCodeRedirect(
|
|
|
1020
1214
|
userId: string,
|
|
1021
1215
|
deps: OAuthDeps,
|
|
1022
1216
|
): Response {
|
|
1217
|
+
// Anti-privesc cap at the single choke-point. Runs AFTER any narrowing the
|
|
1218
|
+
// callers did (unnamed `vault:admin` → `vault:<picked>:admin`), so it sees
|
|
1219
|
+
// the final named shapes. Owner (isFirstAdmin) bypasses — holds admin
|
|
1220
|
+
// everywhere by construction.
|
|
1221
|
+
const userIsAdmin = isFirstAdmin(db, userId);
|
|
1222
|
+
const cappedScopes = capScopesToUserAuthority(db, userId, scopes, { userIsAdmin });
|
|
1223
|
+
|
|
1224
|
+
// Drop-not-refuse UX, with one hard floor: if capping leaves an EMPTY set
|
|
1225
|
+
// (e.g. a non-owner requested ONLY `vault:<name>:admin`, which they don't
|
|
1226
|
+
// hold), never mint a zero-scope token — refuse with a clear invalid_scope.
|
|
1227
|
+
// A request that started empty (no scopes at all) is a separate, legitimate
|
|
1228
|
+
// "session token only" case the consent UI supports, so we only refuse when
|
|
1229
|
+
// the cap itself removed every scope.
|
|
1230
|
+
if (cappedScopes.length === 0 && scopes.length > 0) {
|
|
1231
|
+
return oauthErrorRedirect(
|
|
1232
|
+
params.redirectUri,
|
|
1233
|
+
"invalid_scope",
|
|
1234
|
+
"You can grant only the access you hold on this vault; an admin grant requires hub-owner authority.",
|
|
1235
|
+
params.state,
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Record the grant with the CAPPED scopes (single source of truth) so
|
|
1240
|
+
// skip-consent re-entry can never widen back to an un-held verb.
|
|
1241
|
+
recordGrant(db, userId, params.clientId, cappedScopes, deps.now?.() ?? new Date());
|
|
1242
|
+
|
|
1023
1243
|
const code = issueAuthCode(db, {
|
|
1024
1244
|
clientId: params.clientId,
|
|
1025
1245
|
userId,
|
|
1026
1246
|
redirectUri: params.redirectUri,
|
|
1027
|
-
scopes,
|
|
1247
|
+
scopes: cappedScopes,
|
|
1028
1248
|
codeChallenge: params.codeChallenge,
|
|
1029
1249
|
codeChallengeMethod: params.codeChallengeMethod,
|
|
1030
1250
|
now: deps.now,
|
|
@@ -1100,17 +1320,49 @@ async function handleLoginSubmit(
|
|
|
1100
1320
|
401,
|
|
1101
1321
|
);
|
|
1102
1322
|
}
|
|
1323
|
+
|
|
1324
|
+
// Where to land after a successful sign-in: back at GET /oauth/authorize
|
|
1325
|
+
// with the original query string so the user resumes the OAuth flow on the
|
|
1326
|
+
// consent screen with full params re-validated.
|
|
1327
|
+
const authorizeReturnUrl = buildAuthorizeReturnUrl(params);
|
|
1328
|
+
|
|
1329
|
+
// 2FA gate (hub#473 — security fix). The OAuth login POST is the MORE-common
|
|
1330
|
+
// sign-in path (every OAuth client: vault, notes-ui, `parachute auth login`).
|
|
1331
|
+
// It must enforce the second factor exactly like `/login` does: after the
|
|
1332
|
+
// password verifies, if the user has TOTP enrolled, do NOT mint a session.
|
|
1333
|
+
// Stash a pending-login whose `next` is the FULL /oauth/authorize return URL
|
|
1334
|
+
// (so the post-TOTP redirect resumes the OAuth flow), and render the TOTP
|
|
1335
|
+
// challenge. The challenge form posts to `/login/2fa` — the shared
|
|
1336
|
+
// completion path (`handleAdminLoginTotpPost`) — which verifies the factor,
|
|
1337
|
+
// mints the session, and 302s to the stored `next`. The pending-login cookie
|
|
1338
|
+
// is scoped `Path=/login`, so it rides the `/login/2fa` POST. Without this
|
|
1339
|
+
// gate a 2FA-enrolled user could obtain a full session with password ONLY.
|
|
1340
|
+
if (isTotpEnrolled(db, user.id)) {
|
|
1341
|
+
const pendingToken = createPendingLogin(user.id, authorizeReturnUrl);
|
|
1342
|
+
return htmlResponse(renderTotpChallenge({ next: authorizeReturnUrl, csrfToken }), 200, {
|
|
1343
|
+
"set-cookie": buildPendingLoginCookie(pendingToken, req),
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1103
1347
|
const session = createSession(db, { userId: user.id });
|
|
1104
1348
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1105
1349
|
secure: isHttpsRequest(req),
|
|
1106
1350
|
});
|
|
1107
|
-
|
|
1108
|
-
|
|
1351
|
+
return redirectResponse(authorizeReturnUrl, { "set-cookie": cookie });
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Build the `/oauth/authorize?...` return URL (path + query, same-origin) that
|
|
1356
|
+
* a successful sign-in redirects back to so the OAuth flow resumes on the
|
|
1357
|
+
* consent screen. Shared by the password-only path and the post-TOTP path
|
|
1358
|
+
* (the latter via the pending-login's `next`).
|
|
1359
|
+
*/
|
|
1360
|
+
function buildAuthorizeReturnUrl(params: AuthorizeFormParams): string {
|
|
1109
1361
|
const u = new URL("/oauth/authorize", "http://placeholder");
|
|
1110
1362
|
for (const [k, v] of Object.entries(authorizeParamsToQuery(params))) {
|
|
1111
1363
|
u.searchParams.set(k, v);
|
|
1112
1364
|
}
|
|
1113
|
-
return
|
|
1365
|
+
return `${u.pathname}${u.search}`;
|
|
1114
1366
|
}
|
|
1115
1367
|
|
|
1116
1368
|
async function handleConsentSubmit(
|
|
@@ -1121,6 +1373,21 @@ async function handleConsentSubmit(
|
|
|
1121
1373
|
csrfToken: string,
|
|
1122
1374
|
): Promise<Response> {
|
|
1123
1375
|
const params = paramsFromForm(form);
|
|
1376
|
+
// RFC 8707 resource binding — defense-in-depth (mirror of the GET handler).
|
|
1377
|
+
// The consent form's hidden inputs already carry the narrowed named scopes
|
|
1378
|
+
// (the GET handler rewrote `parsed.scope` before rendering), but a hand-
|
|
1379
|
+
// crafted POST could re-supply an unnamed `vault:read` alongside the
|
|
1380
|
+
// `resource` field. Re-narrow here so the minted token is always named +
|
|
1381
|
+
// correctly-audienced regardless of what the form body claims. Same
|
|
1382
|
+
// semantics as the GET path: only when `resource` resolves to one of our
|
|
1383
|
+
// per-vault MCP resources; no-op otherwise (manual-pick path unchanged).
|
|
1384
|
+
const boundVault = resolveResourceVault(params.resource, resolveBoundOrigins(deps));
|
|
1385
|
+
if (boundVault) {
|
|
1386
|
+
params.scope = narrowResourceVaultScopes(
|
|
1387
|
+
params.scope.split(" ").filter((s) => s.length > 0),
|
|
1388
|
+
boundVault,
|
|
1389
|
+
).join(" ");
|
|
1390
|
+
}
|
|
1124
1391
|
const approve = String(form.get("approve") ?? "") === "yes";
|
|
1125
1392
|
const sessionId = parseSessionCookie(req.headers.get("cookie"));
|
|
1126
1393
|
const session = sessionId ? findSession(db, sessionId) : null;
|
|
@@ -1201,6 +1468,11 @@ async function handleConsentSubmit(
|
|
|
1201
1468
|
const sessionUser = getUserById(db, session.userId);
|
|
1202
1469
|
const assignedVaults: string[] = userIsAdmin ? [] : (sessionUser?.assignedVaults ?? []);
|
|
1203
1470
|
const isPinned = assignedVaults.length > 0;
|
|
1471
|
+
// By design: the resource-bound re-narrow above does NOT check the bound
|
|
1472
|
+
// vault exists in services.json for the admin path — admin (isPinned=false)
|
|
1473
|
+
// can already consent to any vault via the manual picker, so the asymmetry
|
|
1474
|
+
// (named-scope mint against a possibly-missing vault) is deliberate, not an
|
|
1475
|
+
// oversight. Non-admins still hit the assignment + stale-vault defenses below.
|
|
1204
1476
|
|
|
1205
1477
|
// Zero-vault non-admin gate (Phase 2 PR 2 reviewer fold). A non-admin
|
|
1206
1478
|
// user with no `user_vaults` rows is a known-but-not-yet-assigned
|
|
@@ -1362,12 +1634,12 @@ async function handleConsentSubmit(
|
|
|
1362
1634
|
}
|
|
1363
1635
|
}
|
|
1364
1636
|
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
|
|
1637
|
+
// The grant is recorded (or extended) INSIDE issueAuthCodeRedirect with the
|
|
1638
|
+
// CAPPED scopes — the single mint choke-point owns both the anti-privesc cap
|
|
1639
|
+
// and the recordGrant so no path can record an un-held verb. UNION semantics
|
|
1640
|
+
// there mean a subset re-flow still matches a prior grant, and an admin-only
|
|
1641
|
+
// request from a non-owner caps to empty → refused (no zero-scope token).
|
|
1642
|
+
// (#75 skip-consent depends on this recording.)
|
|
1371
1643
|
return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
|
|
1372
1644
|
}
|
|
1373
1645
|
|
|
@@ -1554,6 +1826,7 @@ function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): Authori
|
|
|
1554
1826
|
codeChallenge: String(form.get("code_challenge") ?? ""),
|
|
1555
1827
|
codeChallengeMethod: String(form.get("code_challenge_method") ?? "S256"),
|
|
1556
1828
|
state: (form.get("state") as string | null) ?? null,
|
|
1829
|
+
resource: (form.get("resource") as string | null) ?? null,
|
|
1557
1830
|
};
|
|
1558
1831
|
}
|
|
1559
1832
|
|
|
@@ -1567,6 +1840,10 @@ function authorizeParamsToQuery(p: AuthorizeFormParams): Record<string, string>
|
|
|
1567
1840
|
code_challenge_method: p.codeChallengeMethod,
|
|
1568
1841
|
};
|
|
1569
1842
|
if (p.state) q.state = p.state;
|
|
1843
|
+
// Round-trip the RFC 8707 resource indicator through the login redirect so
|
|
1844
|
+
// the resource-bound narrowing survives a sign-in (it re-enters GET
|
|
1845
|
+
// /oauth/authorize with the original params).
|
|
1846
|
+
if (p.resource) q.resource = p.resource;
|
|
1570
1847
|
return q;
|
|
1571
1848
|
}
|
|
1572
1849
|
|
package/src/oauth-ui.ts
CHANGED
|
@@ -62,6 +62,12 @@ export interface AuthorizeFormParams {
|
|
|
62
62
|
codeChallenge: string;
|
|
63
63
|
codeChallengeMethod: string;
|
|
64
64
|
state: string | null;
|
|
65
|
+
/**
|
|
66
|
+
* RFC 8707 resource indicator. Carried through the login → consent →
|
|
67
|
+
* form-post round-trip so the resource-bound vault narrowing survives a
|
|
68
|
+
* sign-in redirect. Null when the client sent no `resource` param.
|
|
69
|
+
*/
|
|
70
|
+
resource: string | null;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
export interface LoginViewProps {
|
|
@@ -932,6 +938,10 @@ export function renderHiddenInputs(p: AuthorizeFormParams): string {
|
|
|
932
938
|
["code_challenge_method", p.codeChallengeMethod],
|
|
933
939
|
];
|
|
934
940
|
if (p.state) fields.push(["state", p.state]);
|
|
941
|
+
// RFC 8707 resource indicator — round-tripped through the consent form so
|
|
942
|
+
// the resource-bound vault narrowing survives the POST (the consent submit
|
|
943
|
+
// path re-derives the bound vault from it).
|
|
944
|
+
if (p.resource) fields.push(["resource", p.resource]);
|
|
935
945
|
return fields
|
|
936
946
|
.map(([k, v]) => `<input type="hidden" name="${k}" value="${escapeHtml(v)}" />`)
|
|
937
947
|
.join("\n ");
|