@openparachute/hub 0.5.10-rc.6 → 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 +139 -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-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -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 +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -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 +30 -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 +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -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 +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- 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/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,
|
|
@@ -61,6 +62,7 @@ import {
|
|
|
61
62
|
renderConsent,
|
|
62
63
|
renderError,
|
|
63
64
|
renderLogin,
|
|
65
|
+
renderUnknownClient,
|
|
64
66
|
} from "./oauth-ui.ts";
|
|
65
67
|
import { isSameOriginRequest } from "./origin-check.ts";
|
|
66
68
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
@@ -78,7 +80,8 @@ import {
|
|
|
78
80
|
findSession,
|
|
79
81
|
parseSessionCookie,
|
|
80
82
|
} from "./sessions.ts";
|
|
81
|
-
import { getUserByUsername, verifyPassword } from "./users.ts";
|
|
83
|
+
import { getUserById, getUserByUsername, verifyPassword } from "./users.ts";
|
|
84
|
+
import { listVaultNames } from "./vault-names.ts";
|
|
82
85
|
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
83
86
|
|
|
84
87
|
/** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
|
|
@@ -94,26 +97,6 @@ function unnamedVaultVerbs(scopes: string[]): string[] {
|
|
|
94
97
|
return verbs;
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
/**
|
|
98
|
-
* Vault instance names registered on this host, derived from services.json.
|
|
99
|
-
* Walks both manifest shapes — single-entry-multi-path (`paths: ["/vault/work",
|
|
100
|
-
* "/vault/personal"]`) and per-vault entries (`parachute-vault-work`) — by
|
|
101
|
-
* delegating each (name, path) pair to the canonical `vaultInstanceNameFor`
|
|
102
|
-
* helper. Entries with no paths still resolve to a name via the helper's
|
|
103
|
-
* manifest-suffix fallback (#143).
|
|
104
|
-
*/
|
|
105
|
-
function listVaultNames(manifest: ServicesManifest): string[] {
|
|
106
|
-
const names = new Set<string>();
|
|
107
|
-
for (const svc of manifest.services) {
|
|
108
|
-
if (!isVaultEntry(svc)) continue;
|
|
109
|
-
const paths = svc.paths.length > 0 ? svc.paths : [undefined];
|
|
110
|
-
for (const path of paths) {
|
|
111
|
-
names.add(vaultInstanceNameFor(svc.name, path));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return Array.from(names).sort();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
100
|
/** Rewrite each unnamed `vault:<verb>` to `vault:<picked>:<verb>`. */
|
|
118
101
|
function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
|
|
119
102
|
return scopes.map((s) => {
|
|
@@ -126,6 +109,34 @@ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
|
|
|
126
109
|
});
|
|
127
110
|
}
|
|
128
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
|
+
|
|
129
140
|
export interface OAuthDeps {
|
|
130
141
|
/** Hub origin used for `iss`, `authorization_endpoint`, etc. */
|
|
131
142
|
issuer: string;
|
|
@@ -451,6 +462,7 @@ function pendingClientResponse(
|
|
|
451
462
|
redirectUris: client.redirectUris,
|
|
452
463
|
requestedScopes,
|
|
453
464
|
...(requestedVault !== undefined && { requestedVault }),
|
|
465
|
+
hubOrigin: deps.issuer,
|
|
454
466
|
approveForm: { csrfToken: csrf.token, returnTo },
|
|
455
467
|
}),
|
|
456
468
|
403,
|
|
@@ -464,6 +476,7 @@ function pendingClientResponse(
|
|
|
464
476
|
redirectUris: client.redirectUris,
|
|
465
477
|
requestedScopes,
|
|
466
478
|
...(requestedVault !== undefined && { requestedVault }),
|
|
479
|
+
hubOrigin: deps.issuer,
|
|
467
480
|
}),
|
|
468
481
|
403,
|
|
469
482
|
extra,
|
|
@@ -488,6 +501,70 @@ function pendingClientResponse(
|
|
|
488
501
|
* strict spec-shaped handling. The extra fields are spec-permitted
|
|
489
502
|
* extensions ("other parameters").
|
|
490
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
|
+
|
|
491
568
|
function pendingClientJson(clientId: string, issuer: string): Response {
|
|
492
569
|
const base = issuer.replace(/\/$/, "");
|
|
493
570
|
return jsonResponse(
|
|
@@ -587,8 +664,12 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
587
664
|
const client = getClient(db, parsed.clientId);
|
|
588
665
|
if (!client) {
|
|
589
666
|
// Can't safely redirect — we don't trust the redirect_uri until we've
|
|
590
|
-
// matched it against a registered client. Render an HTML error
|
|
591
|
-
|
|
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);
|
|
592
673
|
}
|
|
593
674
|
if (client.status !== "approved") {
|
|
594
675
|
return pendingClientResponse(db, req, client, url, deps);
|
|
@@ -644,10 +725,19 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
644
725
|
return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
|
|
645
726
|
}
|
|
646
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
|
+
|
|
647
737
|
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
648
738
|
const vaultNames = listVaultNames(manifest);
|
|
649
739
|
return htmlResponse(
|
|
650
|
-
renderConsent(consentProps(client, parsed, vaultNames, csrf.token)),
|
|
740
|
+
renderConsent(consentProps(client, parsed, vaultNames, csrf.token, lockedVault)),
|
|
651
741
|
200,
|
|
652
742
|
extra,
|
|
653
743
|
);
|
|
@@ -783,17 +873,25 @@ async function handleConsentSubmit(
|
|
|
783
873
|
}
|
|
784
874
|
const client = getClient(db, params.clientId);
|
|
785
875
|
if (!client) {
|
|
786
|
-
|
|
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);
|
|
787
882
|
}
|
|
788
883
|
if (client.status !== "approved") {
|
|
789
884
|
// Defensive: consent only renders for approved clients, so a non-approved
|
|
790
885
|
// status here means the row was unapproved between render and submit (or
|
|
791
886
|
// the form was hand-crafted). The approve UI requires a known authorize
|
|
792
887
|
// URL to round-trip via `return_to`, which we don't reconstruct here —
|
|
793
|
-
// 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).
|
|
794
892
|
return htmlError(
|
|
795
893
|
"App not yet approved",
|
|
796
|
-
`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.`,
|
|
797
895
|
403,
|
|
798
896
|
);
|
|
799
897
|
}
|
|
@@ -827,6 +925,19 @@ async function handleConsentSubmit(
|
|
|
827
925
|
params.state,
|
|
828
926
|
);
|
|
829
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
|
+
|
|
830
941
|
// Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
|
|
831
942
|
// `vault:<verb>` scope is ambiguous about which vault it grants access to.
|
|
832
943
|
// Force the operator to pick before the JWT is minted, then rewrite the
|
|
@@ -851,8 +962,52 @@ async function handleConsentSubmit(
|
|
|
851
962
|
400,
|
|
852
963
|
);
|
|
853
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
|
+
}
|
|
854
978
|
scopes = narrowVaultScopes(scopes, pickedVault);
|
|
855
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
|
+
|
|
856
1011
|
// Record (or extend) the grant so the next /oauth/authorize for this
|
|
857
1012
|
// (user, client) with these scopes — or any subset — can skip the consent
|
|
858
1013
|
// screen (#75). UNION semantics: if the user previously granted [a, b, c]
|
|
@@ -1154,6 +1309,13 @@ async function handleTokenAuthorizationCode(
|
|
|
1154
1309
|
audience,
|
|
1155
1310
|
clientId: redeemed.clientId,
|
|
1156
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),
|
|
1157
1319
|
now: deps.now,
|
|
1158
1320
|
});
|
|
1159
1321
|
// Phase 1 (#212) registry exemption: code-grant access tokens piggyback
|
|
@@ -1267,6 +1429,14 @@ async function handleTokenRefresh(
|
|
|
1267
1429
|
audience,
|
|
1268
1430
|
clientId: row.clientId,
|
|
1269
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),
|
|
1270
1440
|
now: deps.now,
|
|
1271
1441
|
});
|
|
1272
1442
|
let refresh: ReturnType<typeof signRefreshToken>;
|
|
@@ -1548,6 +1718,19 @@ export async function handleRegister(
|
|
|
1548
1718
|
status = "approved";
|
|
1549
1719
|
}
|
|
1550
1720
|
}
|
|
1721
|
+
// First-client auto-approve window (hub#268 Item 3). The wizard's expose
|
|
1722
|
+
// step opens a 60-minute window where the very next registration is
|
|
1723
|
+
// auto-approved. Single-use — the consume call clears the row on
|
|
1724
|
+
// success, so client #2 falls through to the standard pending-approval
|
|
1725
|
+
// flow. Logged so an operator chasing odd behavior can see it fired
|
|
1726
|
+
// and which client got the free pass.
|
|
1727
|
+
let autoApprovedByWizardWindow = false;
|
|
1728
|
+
if (status === "pending") {
|
|
1729
|
+
if (consumeFirstClientAutoApproveWindow(db, deps.now ?? (() => new Date()))) {
|
|
1730
|
+
status = "approved";
|
|
1731
|
+
autoApprovedByWizardWindow = true;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1551
1734
|
const confidential = body.token_endpoint_auth_method === "client_secret_post";
|
|
1552
1735
|
const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
|
|
1553
1736
|
let registered: RegisteredClient;
|
|
@@ -1564,6 +1747,11 @@ export async function handleRegister(
|
|
|
1564
1747
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1565
1748
|
return jsonResponse({ error: "invalid_client_metadata", error_description: msg }, 400);
|
|
1566
1749
|
}
|
|
1750
|
+
if (autoApprovedByWizardWindow) {
|
|
1751
|
+
console.log(
|
|
1752
|
+
`[oauth] auto-approved first client clientId=${registered.client.clientId} within wizard window (hub#268 Item 3)`,
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1567
1755
|
const respBody: Record<string, unknown> = {
|
|
1568
1756
|
client_id: registered.client.clientId,
|
|
1569
1757
|
redirect_uris: registered.client.redirectUris,
|
|
@@ -1584,16 +1772,55 @@ function consentProps(
|
|
|
1584
1772
|
params: AuthorizeFormParams,
|
|
1585
1773
|
vaultNames: string[],
|
|
1586
1774
|
csrfToken: string,
|
|
1775
|
+
lockedVault: string | null,
|
|
1587
1776
|
) {
|
|
1588
1777
|
const scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
1589
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
|
+
}
|
|
1590
1811
|
return {
|
|
1591
1812
|
params,
|
|
1592
1813
|
clientId: client.clientId,
|
|
1593
1814
|
clientName: client.clientName ?? client.clientId,
|
|
1594
1815
|
scopes,
|
|
1595
1816
|
csrfToken,
|
|
1596
|
-
vaultPicker
|
|
1597
|
-
|
|
1817
|
+
vaultPicker,
|
|
1818
|
+
displayVault,
|
|
1598
1819
|
};
|
|
1599
1820
|
}
|
|
1821
|
+
|
|
1822
|
+
interface VaultPickerProps {
|
|
1823
|
+
unnamedVerbs: string[];
|
|
1824
|
+
availableVaults: string[];
|
|
1825
|
+
lockedVault?: string;
|
|
1826
|
+
}
|