@openparachute/hub 0.5.10-rc.9 → 0.5.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-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +74 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +216 -3
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +15 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +134 -16
- package/src/users.ts +210 -3
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/jwt-sign.ts
CHANGED
|
@@ -47,6 +47,20 @@ export interface SignAccessTokenOpts {
|
|
|
47
47
|
* or thread it from `OAuthDeps.issuer`.
|
|
48
48
|
*/
|
|
49
49
|
issuer: string;
|
|
50
|
+
/**
|
|
51
|
+
* Per-user vault pin — the multi-user Phase 1 (design
|
|
52
|
+
* [`2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/))
|
|
53
|
+
* vault_scope claim. Non-empty for non-admin users (a single-element list
|
|
54
|
+
* naming their `assigned_vault`); empty `[]` for admin users (the "no
|
|
55
|
+
* per-user vault restriction" sentinel — admins can request any vault on
|
|
56
|
+
* the hub via the consent picker). Always emitted as a claim — defaults
|
|
57
|
+
* to `[]` when callers omit — so a downstream consumer (PR 5's
|
|
58
|
+
* scope-guard at vault / notes / scribe) can unambiguously read it
|
|
59
|
+
* without distinguishing "absent" from "empty." Phase 1 always has
|
|
60
|
+
* length ≤1; the list shape carries Phase 2 multi-vault forward without
|
|
61
|
+
* a wire-shape change.
|
|
62
|
+
*/
|
|
63
|
+
vaultScope?: string[];
|
|
50
64
|
/** Override the jti (defaults to random base64url(16)). Used by tests. */
|
|
51
65
|
jti?: string;
|
|
52
66
|
/**
|
|
@@ -60,7 +74,8 @@ export interface SignAccessTokenOpts {
|
|
|
60
74
|
* `pa_scope_set` (which scope-set the token was minted under) so an
|
|
61
75
|
* auto-rotation can preserve the operator's chosen narrowing across mints.
|
|
62
76
|
* Reserved claims (`scope`, `client_id`, `sub`, `iss`, `iat`, `exp`, `aud`,
|
|
63
|
-
* `jti`) are owned by this function and overwritten if
|
|
77
|
+
* `jti`, `vault_scope`) are owned by this function and overwritten if
|
|
78
|
+
* passed here.
|
|
64
79
|
*/
|
|
65
80
|
extraClaims?: Record<string, unknown>;
|
|
66
81
|
}
|
|
@@ -85,6 +100,7 @@ export async function signAccessToken(
|
|
|
85
100
|
...(opts.extraClaims ?? {}),
|
|
86
101
|
scope: opts.scopes.join(" "),
|
|
87
102
|
client_id: opts.clientId,
|
|
103
|
+
vault_scope: opts.vaultScope ?? [],
|
|
88
104
|
})
|
|
89
105
|
.setProtectedHeader({ alg: SIGNING_ALGORITHM, kid: key.kid })
|
|
90
106
|
.setSubject(opts.sub)
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
renderConsent,
|
|
63
63
|
renderError,
|
|
64
64
|
renderLogin,
|
|
65
|
+
renderUnknownClient,
|
|
65
66
|
} from "./oauth-ui.ts";
|
|
66
67
|
import { isSameOriginRequest } from "./origin-check.ts";
|
|
67
68
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
@@ -79,7 +80,8 @@ import {
|
|
|
79
80
|
findSession,
|
|
80
81
|
parseSessionCookie,
|
|
81
82
|
} from "./sessions.ts";
|
|
82
|
-
import { getUserByUsername, verifyPassword } from "./users.ts";
|
|
83
|
+
import { getUserById, getUserByUsername, verifyPassword } from "./users.ts";
|
|
84
|
+
import { listVaultNames } from "./vault-names.ts";
|
|
83
85
|
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
84
86
|
|
|
85
87
|
/** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
|
|
@@ -95,26 +97,6 @@ function unnamedVaultVerbs(scopes: string[]): string[] {
|
|
|
95
97
|
return verbs;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
/**
|
|
99
|
-
* Vault instance names registered on this host, derived from services.json.
|
|
100
|
-
* Walks both manifest shapes — single-entry-multi-path (`paths: ["/vault/work",
|
|
101
|
-
* "/vault/personal"]`) and per-vault entries (`parachute-vault-work`) — by
|
|
102
|
-
* delegating each (name, path) pair to the canonical `vaultInstanceNameFor`
|
|
103
|
-
* helper. Entries with no paths still resolve to a name via the helper's
|
|
104
|
-
* manifest-suffix fallback (#143).
|
|
105
|
-
*/
|
|
106
|
-
function listVaultNames(manifest: ServicesManifest): string[] {
|
|
107
|
-
const names = new Set<string>();
|
|
108
|
-
for (const svc of manifest.services) {
|
|
109
|
-
if (!isVaultEntry(svc)) continue;
|
|
110
|
-
const paths = svc.paths.length > 0 ? svc.paths : [undefined];
|
|
111
|
-
for (const path of paths) {
|
|
112
|
-
names.add(vaultInstanceNameFor(svc.name, path));
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return Array.from(names).sort();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
100
|
/** Rewrite each unnamed `vault:<verb>` to `vault:<picked>:<verb>`. */
|
|
119
101
|
function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
|
|
120
102
|
return scopes.map((s) => {
|
|
@@ -127,6 +109,34 @@ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
|
|
|
127
109
|
});
|
|
128
110
|
}
|
|
129
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Derive the `vault_scope` claim value for a given hub user. Multi-user
|
|
114
|
+
* Phase 1 (design
|
|
115
|
+
* [`2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/),
|
|
116
|
+
* §oauth-claim-shape).
|
|
117
|
+
*
|
|
118
|
+
* - `userId` resolves to no row → `[]`. Defensive: the caller already
|
|
119
|
+
* validated the user existed (auth-code redemption / refresh row's
|
|
120
|
+
* user_id), but a delete-between-mint-and-now race shouldn't 500. Empty
|
|
121
|
+
* is the safe sentinel — the scope-bearing `scope` claim is still the
|
|
122
|
+
* gate.
|
|
123
|
+
* - User exists with `assignedVault === null` → `[]`. Admin / unpinned
|
|
124
|
+
* posture; the consent picker is the source of truth.
|
|
125
|
+
* - User exists with `assignedVault === "name"` → `["name"]`. The Phase 1
|
|
126
|
+
* single-vault pin; PR 5's scope-guard at vault/notes/scribe consumes
|
|
127
|
+
* this to enforce that an assigned user can't request scope against any
|
|
128
|
+
* vault other than their assigned one.
|
|
129
|
+
*
|
|
130
|
+
* Always returns an array (never undefined) so the JWT carries the claim
|
|
131
|
+
* unconditionally — readers don't have to distinguish "absent" from "empty."
|
|
132
|
+
*/
|
|
133
|
+
export function vaultScopeForUser(db: Database, userId: string): string[] {
|
|
134
|
+
const user = getUserById(db, userId);
|
|
135
|
+
if (!user) return [];
|
|
136
|
+
if (user.assignedVault === null) return [];
|
|
137
|
+
return [user.assignedVault];
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
export interface OAuthDeps {
|
|
131
141
|
/** Hub origin used for `iss`, `authorization_endpoint`, etc. */
|
|
132
142
|
issuer: string;
|
|
@@ -452,6 +462,7 @@ function pendingClientResponse(
|
|
|
452
462
|
redirectUris: client.redirectUris,
|
|
453
463
|
requestedScopes,
|
|
454
464
|
...(requestedVault !== undefined && { requestedVault }),
|
|
465
|
+
hubOrigin: deps.issuer,
|
|
455
466
|
approveForm: { csrfToken: csrf.token, returnTo },
|
|
456
467
|
}),
|
|
457
468
|
403,
|
|
@@ -465,6 +476,7 @@ function pendingClientResponse(
|
|
|
465
476
|
redirectUris: client.redirectUris,
|
|
466
477
|
requestedScopes,
|
|
467
478
|
...(requestedVault !== undefined && { requestedVault }),
|
|
479
|
+
hubOrigin: deps.issuer,
|
|
468
480
|
}),
|
|
469
481
|
403,
|
|
470
482
|
extra,
|
|
@@ -489,6 +501,70 @@ function pendingClientResponse(
|
|
|
489
501
|
* strict spec-shaped handling. The extra fields are spec-permitted
|
|
490
502
|
* extensions ("other parameters").
|
|
491
503
|
*/
|
|
504
|
+
/**
|
|
505
|
+
* Render the "Unknown application" page surfaced by /oauth/authorize when
|
|
506
|
+
* `getClient(db, clientId)` returns null. Promotes the recovery affordance
|
|
507
|
+
* (a "Reset connection" button that clears `lens:dcr:*` localStorage on the
|
|
508
|
+
* hub's own origin) when the request's redirect_uri points at one of the
|
|
509
|
+
* hub's bound origins — that's the Notes-mounted-at-hub-origin case where
|
|
510
|
+
* we can safely run inline JS against the SPA's storage.
|
|
511
|
+
*
|
|
512
|
+
* Root-cause shape (rc.11 fresh-machine connect bug): an operator who wipes
|
|
513
|
+
* `~/.parachute/hub.db` between testing iterations strands their browser's
|
|
514
|
+
* cached client_id, and the SPA has no signal to clear it without
|
|
515
|
+
* operator action. The hub-side fix is to give the operator one click on
|
|
516
|
+
* the error page. See `renderUnknownClient` in oauth-ui.ts for the full
|
|
517
|
+
* design + the localStorage key the snippet clears.
|
|
518
|
+
*
|
|
519
|
+
* Cross-origin SPA redirect_uris fall back to the static error variant —
|
|
520
|
+
* we can't reach a third-party SPA's storage from this page. Malformed
|
|
521
|
+
* redirect_uris likewise fall back (we never trust an unparsed URL).
|
|
522
|
+
*/
|
|
523
|
+
function unknownClientResponse(
|
|
524
|
+
clientId: string,
|
|
525
|
+
redirectUri: string | null,
|
|
526
|
+
deps: OAuthDeps,
|
|
527
|
+
): Response {
|
|
528
|
+
const selfOriginRedirectPath = resolveSelfOriginRedirectPath(redirectUri, deps);
|
|
529
|
+
return htmlResponse(
|
|
530
|
+
renderUnknownClient({
|
|
531
|
+
clientId,
|
|
532
|
+
selfOriginRedirectPath,
|
|
533
|
+
}),
|
|
534
|
+
400,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Parse a redirect_uri and return its pathname iff its origin is one the
|
|
540
|
+
* hub serves itself (any entry in `hubBoundOrigins`). Returns null when
|
|
541
|
+
* the URL is missing, malformed, or points at a non-hub origin — the
|
|
542
|
+
* caller falls back to a static error in those cases. The pathname is
|
|
543
|
+
* what the "Reset connection" button navigates to after clearing the
|
|
544
|
+
* SPA's cached client_id; e.g. for `http://localhost:1939/notes/oauth/callback`
|
|
545
|
+
* we return `/notes/oauth/callback`. The SPA then routes that internally
|
|
546
|
+
* (Notes' /oauth/callback handler harmlessly errors on missing code +
|
|
547
|
+
* state, and the user gets dropped onto the connect screen for a fresh
|
|
548
|
+
* DCR — that's the recovery path).
|
|
549
|
+
*
|
|
550
|
+
* Pathname-only (not the full URL) is deliberate: same-origin navigation
|
|
551
|
+
* is trivially safe, and we don't want to surface a redirect that
|
|
552
|
+
* leaves the hub's origin even when the redirect_uri claims it. If a
|
|
553
|
+
* future SPA needs different recovery semantics, this is the seam.
|
|
554
|
+
*/
|
|
555
|
+
function resolveSelfOriginRedirectPath(redirectUri: string | null, deps: OAuthDeps): string | null {
|
|
556
|
+
if (!redirectUri) return null;
|
|
557
|
+
let parsed: URL;
|
|
558
|
+
try {
|
|
559
|
+
parsed = new URL(redirectUri);
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const bound = resolveBoundOrigins(deps);
|
|
564
|
+
if (!bound.includes(parsed.origin)) return null;
|
|
565
|
+
return parsed.pathname || "/";
|
|
566
|
+
}
|
|
567
|
+
|
|
492
568
|
function pendingClientJson(clientId: string, issuer: string): Response {
|
|
493
569
|
const base = issuer.replace(/\/$/, "");
|
|
494
570
|
return jsonResponse(
|
|
@@ -588,8 +664,12 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
588
664
|
const client = getClient(db, parsed.clientId);
|
|
589
665
|
if (!client) {
|
|
590
666
|
// Can't safely redirect — we don't trust the redirect_uri until we've
|
|
591
|
-
// matched it against a registered client. Render an HTML error
|
|
592
|
-
|
|
667
|
+
// matched it against a registered client. Render an HTML error that
|
|
668
|
+
// promotes a one-click recovery when the redirect_uri points at one
|
|
669
|
+
// of our own origins (the canonical fresh-machine repro: an operator
|
|
670
|
+
// wiped hub.db, the SPA still holds the old client_id in
|
|
671
|
+
// localStorage, the recovery is "clear that key and reload the SPA").
|
|
672
|
+
return unknownClientResponse(parsed.clientId, parsed.redirectUri, deps);
|
|
593
673
|
}
|
|
594
674
|
if (client.status !== "approved") {
|
|
595
675
|
return pendingClientResponse(db, req, client, url, deps);
|
|
@@ -645,10 +725,19 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
645
725
|
return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
|
|
646
726
|
}
|
|
647
727
|
|
|
728
|
+
// Multi-user Phase 1: non-admin users (with `assigned_vault !== null`) see
|
|
729
|
+
// the picker locked to their assigned vault — they can't pick a different
|
|
730
|
+
// one. Admin users (assigned_vault === null) see the full dropdown.
|
|
731
|
+
// Defensive null-coalesce: the session points at a deleted user shouldn't
|
|
732
|
+
// 500; treat as admin posture (the broader scope-validation gate will
|
|
733
|
+
// catch any actual privilege issue).
|
|
734
|
+
const user = getUserById(db, session.userId);
|
|
735
|
+
const lockedVault = user?.assignedVault ?? null;
|
|
736
|
+
|
|
648
737
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
649
738
|
const vaultNames = listVaultNames(manifest);
|
|
650
739
|
return htmlResponse(
|
|
651
|
-
renderConsent(consentProps(client, parsed, vaultNames, csrf.token)),
|
|
740
|
+
renderConsent(consentProps(client, parsed, vaultNames, csrf.token, lockedVault)),
|
|
652
741
|
200,
|
|
653
742
|
extra,
|
|
654
743
|
);
|
|
@@ -784,17 +873,25 @@ async function handleConsentSubmit(
|
|
|
784
873
|
}
|
|
785
874
|
const client = getClient(db, params.clientId);
|
|
786
875
|
if (!client) {
|
|
787
|
-
|
|
876
|
+
// Same shape as the GET handler — see the comment there. Reaching
|
|
877
|
+
// this branch on the consent POST means the client_id was deleted
|
|
878
|
+
// between render and submit (vanishingly rare) or the form was
|
|
879
|
+
// hand-crafted; the recovery affordance is still the right answer
|
|
880
|
+
// for both cases.
|
|
881
|
+
return unknownClientResponse(params.clientId, params.redirectUri, deps);
|
|
788
882
|
}
|
|
789
883
|
if (client.status !== "approved") {
|
|
790
884
|
// Defensive: consent only renders for approved clients, so a non-approved
|
|
791
885
|
// status here means the row was unapproved between render and submit (or
|
|
792
886
|
// the form was hand-crafted). The approve UI requires a known authorize
|
|
793
887
|
// URL to round-trip via `return_to`, which we don't reconstruct here —
|
|
794
|
-
// surface the static error
|
|
888
|
+
// surface the static error pointing at the web approval path (the
|
|
889
|
+
// canonical recovery post-#277; rc.19 retired the CLI mention from
|
|
890
|
+
// every browser-visible surface so the path advertised here matches
|
|
891
|
+
// what the unauth GET-on-pending page now shows).
|
|
795
892
|
return htmlError(
|
|
796
893
|
"App not yet approved",
|
|
797
|
-
`This client_id is registered but has not been approved.
|
|
894
|
+
`This client_id is registered but has not been approved. Sign in as admin and approve at /admin/approve-client/${client.clientId}, or send the link to your hub operator.`,
|
|
798
895
|
403,
|
|
799
896
|
);
|
|
800
897
|
}
|
|
@@ -828,6 +925,19 @@ async function handleConsentSubmit(
|
|
|
828
925
|
params.state,
|
|
829
926
|
);
|
|
830
927
|
}
|
|
928
|
+
// Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md): non-admin
|
|
929
|
+
// users (`assigned_vault !== null`) are pinned to a single vault. The
|
|
930
|
+
// consent screen renders the picker locked to that vault and any named
|
|
931
|
+
// scopes (`vault:<name>:<verb>`) requested by the client must match. The
|
|
932
|
+
// server-side defense here refuses any mint where the user's submission
|
|
933
|
+
// disagrees — Aaron pinned this as "server-side defense refuses mints
|
|
934
|
+
// whose picked vault disagrees with assigned_vault" rather than silent
|
|
935
|
+
// overwrite, so a hand-crafted POST or a misbehaving SPA can't bypass the
|
|
936
|
+
// lock. Admin users (`assigned_vault === null`) keep the existing picker-
|
|
937
|
+
// as-source-of-truth behavior.
|
|
938
|
+
const sessionUser = getUserById(db, session.userId);
|
|
939
|
+
const assignedVault = sessionUser?.assignedVault ?? null;
|
|
940
|
+
|
|
831
941
|
// Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
|
|
832
942
|
// `vault:<verb>` scope is ambiguous about which vault it grants access to.
|
|
833
943
|
// Force the operator to pick before the JWT is minted, then rewrite the
|
|
@@ -852,8 +962,52 @@ async function handleConsentSubmit(
|
|
|
852
962
|
400,
|
|
853
963
|
);
|
|
854
964
|
}
|
|
965
|
+
// Server-side defense: non-admin user submitted a vault that disagrees
|
|
966
|
+
// with their `assigned_vault`. The picker rendered as locked, so a UI-
|
|
967
|
+
// path user couldn't reach this — but a hand-crafted form bypassing the
|
|
968
|
+
// locked input lands here. Refuse the mint instead of silently
|
|
969
|
+
// overwriting; the explicit error tells the operator the assignment is
|
|
970
|
+
// load-bearing.
|
|
971
|
+
if (assignedVault !== null && pickedVault !== assignedVault) {
|
|
972
|
+
return htmlError(
|
|
973
|
+
"Vault assignment mismatch",
|
|
974
|
+
`vault_scope_mismatch: the picked vault "${pickedVault}" does not match your vault assignment. Ask the hub admin to update your assignment, or pick the vault shown on the consent screen.`,
|
|
975
|
+
400,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
855
978
|
scopes = narrowVaultScopes(scopes, pickedVault);
|
|
856
979
|
}
|
|
980
|
+
|
|
981
|
+
// Server-side defense for named-vault scopes (`vault:<name>:<verb>`) too.
|
|
982
|
+
// A non-admin user can't request scope against any vault other than their
|
|
983
|
+
// assigned one — same invariant as the picker check above, applied to
|
|
984
|
+
// scopes that arrived already-named (e.g. a client that knows the user's
|
|
985
|
+
// vault and asked for `vault:bob:read` directly). Admins (assigned_vault
|
|
986
|
+
// null) skip this check.
|
|
987
|
+
if (assignedVault !== null) {
|
|
988
|
+
const mismatched: string[] = [];
|
|
989
|
+
for (const s of scopes) {
|
|
990
|
+
const parts = s.split(":");
|
|
991
|
+
if (
|
|
992
|
+
parts.length === 3 &&
|
|
993
|
+
parts[0] === "vault" &&
|
|
994
|
+
parts[1] &&
|
|
995
|
+
parts[2] &&
|
|
996
|
+
VAULT_VERBS.has(parts[2]) &&
|
|
997
|
+
parts[1] !== assignedVault
|
|
998
|
+
) {
|
|
999
|
+
mismatched.push(s);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (mismatched.length > 0) {
|
|
1003
|
+
return htmlError(
|
|
1004
|
+
"Vault assignment mismatch",
|
|
1005
|
+
`vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault other than your vault assignment.`,
|
|
1006
|
+
400,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
857
1011
|
// Record (or extend) the grant so the next /oauth/authorize for this
|
|
858
1012
|
// (user, client) with these scopes — or any subset — can skip the consent
|
|
859
1013
|
// screen (#75). UNION semantics: if the user previously granted [a, b, c]
|
|
@@ -1155,6 +1309,13 @@ async function handleTokenAuthorizationCode(
|
|
|
1155
1309
|
audience,
|
|
1156
1310
|
clientId: redeemed.clientId,
|
|
1157
1311
|
issuer: deps.issuer,
|
|
1312
|
+
// vault_scope claim — Phase 1 per-user vault pin. Non-empty list for
|
|
1313
|
+
// non-admin users with `assigned_vault` set; empty for admin / unpinned.
|
|
1314
|
+
// The narrowing in `handleConsentSubmit` already rewrote `vault:<verb>` →
|
|
1315
|
+
// `vault:<assigned_vault>:<verb>` for non-admin users, so the auth code's
|
|
1316
|
+
// scopes are pre-aligned; this claim is the explicit "owned vault"
|
|
1317
|
+
// signal PR 5 consumes downstream.
|
|
1318
|
+
vaultScope: vaultScopeForUser(db, redeemed.userId),
|
|
1158
1319
|
now: deps.now,
|
|
1159
1320
|
});
|
|
1160
1321
|
// Phase 1 (#212) registry exemption: code-grant access tokens piggyback
|
|
@@ -1268,6 +1429,14 @@ async function handleTokenRefresh(
|
|
|
1268
1429
|
audience,
|
|
1269
1430
|
clientId: row.clientId,
|
|
1270
1431
|
issuer: deps.issuer,
|
|
1432
|
+
// vault_scope claim — re-derived from the user's *current*
|
|
1433
|
+
// `assigned_vault` at refresh time (not snapshotted onto the refresh-
|
|
1434
|
+
// token row). An admin who changes a user's `assigned_vault` between
|
|
1435
|
+
// mint and refresh sees the new value on the next refresh; existing
|
|
1436
|
+
// access tokens carry their original claim until their 15-minute TTL
|
|
1437
|
+
// elapses. Same posture as the design's "OAuth issuer reads
|
|
1438
|
+
// `assigned_vault` at mint time, not at session-creation time" pin.
|
|
1439
|
+
vaultScope: vaultScopeForUser(db, refreshUserId),
|
|
1271
1440
|
now: deps.now,
|
|
1272
1441
|
});
|
|
1273
1442
|
let refresh: ReturnType<typeof signRefreshToken>;
|
|
@@ -1603,16 +1772,55 @@ function consentProps(
|
|
|
1603
1772
|
params: AuthorizeFormParams,
|
|
1604
1773
|
vaultNames: string[],
|
|
1605
1774
|
csrfToken: string,
|
|
1775
|
+
lockedVault: string | null,
|
|
1606
1776
|
) {
|
|
1607
1777
|
const scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
1608
1778
|
const unnamedVerbs = unnamedVaultVerbs(scopes);
|
|
1779
|
+
// Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md, decision-pin
|
|
1780
|
+
// "consent picker for non-admin users"): non-admin users (assigned_vault
|
|
1781
|
+
// non-null) see the picker locked to their `assigned_vault` rather than a
|
|
1782
|
+
// free dropdown. Phase 2 will hide other vaults entirely; Phase 1 ships
|
|
1783
|
+
// lock-the-picker (the smallest diff that satisfies "user can't pick a
|
|
1784
|
+
// vault they don't own"). Server-side defense in `handleConsentSubmit`
|
|
1785
|
+
// refuses mints whose POST disagrees regardless of how the picker is
|
|
1786
|
+
// rendered.
|
|
1787
|
+
let vaultPicker: VaultPickerProps | undefined;
|
|
1788
|
+
if (unnamedVerbs.length > 0) {
|
|
1789
|
+
vaultPicker =
|
|
1790
|
+
lockedVault !== null
|
|
1791
|
+
? { unnamedVerbs, availableVaults: vaultNames, lockedVault }
|
|
1792
|
+
: { unnamedVerbs, availableVaults: vaultNames };
|
|
1793
|
+
}
|
|
1794
|
+
// Named-scope display: substitute unnamed `vault:<verb>` rows with the
|
|
1795
|
+
// resolved form the operator will actually consent to.
|
|
1796
|
+
// - Non-admin (lockedVault set) → render `vault:<lockedVault>:<verb>`.
|
|
1797
|
+
// - Admin with exactly one vault available → render that vault's name;
|
|
1798
|
+
// the picker pre-checks it and a default-Approve mints scope against
|
|
1799
|
+
// it, so the displayed scope matches the next state of the form.
|
|
1800
|
+
// - Admin with multiple vaults / no vaults → null sentinel; render with
|
|
1801
|
+
// a `<TBD>` placeholder + a hint pointing at the picker. Once the
|
|
1802
|
+
// operator clicks a radio the JS-free form still posts the chosen
|
|
1803
|
+
// name; the displayed scope only changes after the next page render.
|
|
1804
|
+
let displayVault: string | null = null;
|
|
1805
|
+
if (lockedVault !== null) {
|
|
1806
|
+
displayVault = lockedVault;
|
|
1807
|
+
} else if (unnamedVerbs.length > 0 && vaultNames.length === 1) {
|
|
1808
|
+
const only = vaultNames[0];
|
|
1809
|
+
if (only) displayVault = only;
|
|
1810
|
+
}
|
|
1609
1811
|
return {
|
|
1610
1812
|
params,
|
|
1611
1813
|
clientId: client.clientId,
|
|
1612
1814
|
clientName: client.clientName ?? client.clientId,
|
|
1613
1815
|
scopes,
|
|
1614
1816
|
csrfToken,
|
|
1615
|
-
vaultPicker
|
|
1616
|
-
|
|
1817
|
+
vaultPicker,
|
|
1818
|
+
displayVault,
|
|
1617
1819
|
};
|
|
1618
1820
|
}
|
|
1821
|
+
|
|
1822
|
+
interface VaultPickerProps {
|
|
1823
|
+
unnamedVerbs: string[];
|
|
1824
|
+
availableVaults: string[];
|
|
1825
|
+
lockedVault?: string;
|
|
1826
|
+
}
|