@openparachute/hub 0.5.14-rc.8 → 0.6.0
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-ops.test.ts +45 -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__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- 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__/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 +335 -15
- 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 +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -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-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- 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 +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -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/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 +77 -7
- 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 +71 -19
- 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/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 ");
|
package/src/operator-token.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { promises as fs } from "node:fs";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { configDir } from "./config.ts";
|
|
33
33
|
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
34
|
+
import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
|
|
34
35
|
|
|
35
36
|
export const OPERATOR_TOKEN_FILENAME = "operator.token";
|
|
36
37
|
/** Default operator-token lifetime — 90 days, was 365d through 0.5.7 (#213). */
|
|
@@ -462,3 +463,153 @@ export async function useOperatorTokenWithAutoRotate(
|
|
|
462
463
|
status: { kind: "rotated" },
|
|
463
464
|
};
|
|
464
465
|
}
|
|
466
|
+
|
|
467
|
+
export interface SelfHealOperatorTokenOpts {
|
|
468
|
+
/**
|
|
469
|
+
* The hub's CURRENT issuer (its public origin once exposed). The stale
|
|
470
|
+
* on-disk token is re-minted under this value. Must be the resolved hub
|
|
471
|
+
* origin, never a raw flag — callers pass `r.hubOrigin` from lifecycle.
|
|
472
|
+
*/
|
|
473
|
+
issuer: string;
|
|
474
|
+
/** configDir override (where operator.token lives). Defaults to `configDir()`. */
|
|
475
|
+
configDir?: string;
|
|
476
|
+
/** Operator-facing log sink. Defaults to a no-op (silent). */
|
|
477
|
+
log?: (line: string) => void;
|
|
478
|
+
/** Override the JWT-sign clock — tests pin time. Forwarded to `issueOperatorToken`. */
|
|
479
|
+
now?: () => Date;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Disambiguated outcome of {@link selfHealOperatorTokenIssuer}. Modelled on
|
|
484
|
+
* {@link RotationStatus} — a small discriminated union the caller logs and
|
|
485
|
+
* tests assert.
|
|
486
|
+
*
|
|
487
|
+
* - `absent` — no `operator.token` on disk; nothing to heal.
|
|
488
|
+
* - `fresh` — the token's `iss` already matches the current issuer; the file
|
|
489
|
+
* is left byte-identical (no rewrite, no log).
|
|
490
|
+
* - `rotated` — the token was genuine-but-stale; re-minted under the current
|
|
491
|
+
* issuer with the SAME scope-set + sub. `path` / `expiresAt` / `scopeSet`
|
|
492
|
+
* describe the freshly-written token.
|
|
493
|
+
* - `skipped` — a guard fired; the on-disk file is left untouched. `reason`:
|
|
494
|
+
* - `unverifiable`: the on-disk token's signature did NOT verify against
|
|
495
|
+
* this hub's current keys (bad/unknown/expired kid, jose `exp` failure,
|
|
496
|
+
* or a revoked jti). We must NOT resurrect or trust it — the operator
|
|
497
|
+
* recovers via `parachute auth rotate-operator`.
|
|
498
|
+
* - `aud-mismatch`: the token carries a non-operator audience. A
|
|
499
|
+
* hand-stashed scope-narrow JWT must not be silently re-minted as a
|
|
500
|
+
* full operator token (parallels the {@link useOperatorTokenWithAutoRotate}
|
|
501
|
+
* privilege guard).
|
|
502
|
+
* - `no-sub`: the token lacks a `sub` claim, so we can't re-mint (don't
|
|
503
|
+
* know who it belongs to).
|
|
504
|
+
* - `no-scope-set`: the token lacks (or has an unrecognized) `pa_scope_set`
|
|
505
|
+
* claim. Falling back to a default would widen scope (hub#224 hardening);
|
|
506
|
+
* refuse instead. Operator recovers via explicit rotate-operator.
|
|
507
|
+
* - `issuer-loopback`: the TARGET issuer is loopback. Re-minting to a
|
|
508
|
+
* loopback `iss` would downgrade a good public token; never do it.
|
|
509
|
+
*/
|
|
510
|
+
export type OperatorIssuerHealStatus =
|
|
511
|
+
| { kind: "absent" }
|
|
512
|
+
| { kind: "fresh" }
|
|
513
|
+
| { kind: "rotated"; path: string; scopeSet: OperatorScopeSet; expiresAt: string }
|
|
514
|
+
| {
|
|
515
|
+
kind: "skipped";
|
|
516
|
+
reason: "unverifiable" | "aud-mismatch" | "no-sub" | "no-scope-set" | "issuer-loopback";
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Self-heal the operator token's `iss` when the hub's origin changed after
|
|
521
|
+
* the token was minted.
|
|
522
|
+
*
|
|
523
|
+
* The bug this closes (hub#481, same family as the rc.17 Cloudflare 401 P0):
|
|
524
|
+
* `parachute init`/setup mints `~/.parachute/operator.token` stamped with the
|
|
525
|
+
* hub's origin-at-creation-time (`http://127.0.0.1:1939` on a box set up
|
|
526
|
+
* before exposure). After `parachute expose` brings the hub up on a public
|
|
527
|
+
* origin, on-box services validate incoming bearers' `iss` against the hub's
|
|
528
|
+
* CURRENT origin, so the stale-`iss` operator token is rejected on every CLI
|
|
529
|
+
* auth flow with `bearer token invalid — unexpected "iss" claim value`. That
|
|
530
|
+
* breaks `vault create`, `mcp-install`, and `/api/auth/mint-token`. The token
|
|
531
|
+
* is genuine — it just predates the origin change — so re-issuing it under the
|
|
532
|
+
* current issuer (preserving its scope-set + sub) is the right repair.
|
|
533
|
+
*
|
|
534
|
+
* Hooked into `parachute start hub` (parallel to how rc.17 hooked
|
|
535
|
+
* `selfHealVaultHubOrigin` into `start vault`): existing broken deploys
|
|
536
|
+
* self-correct on the next `start/restart hub`.
|
|
537
|
+
*
|
|
538
|
+
* ## Security invariant (reviewer: verify this holds)
|
|
539
|
+
*
|
|
540
|
+
* Re-mint is gated on `validateAccessToken(db, token)` succeeding WITHOUT an
|
|
541
|
+
* `expectedIssuer` argument — i.e. the on-disk token's SIGNATURE verifies
|
|
542
|
+
* against THIS hub's current public keys (by kid) and passes jose's `exp`
|
|
543
|
+
* check and the revocation check, while NOT pinning `iss`. An attacker cannot
|
|
544
|
+
* forge that signature, so the ONLY tokens that can ever be re-minted are ones
|
|
545
|
+
* this hub itself previously minted. There is no path to mint a token from an
|
|
546
|
+
* untrusted/forged input. Further:
|
|
547
|
+
* - scope-set is preserved verbatim (`payload.pa_scope_set`) — no widening;
|
|
548
|
+
* - expired tokens are refused (jose `exp` → `skipped: unverifiable`);
|
|
549
|
+
* - revoked tokens are refused (validateAccessToken's revocation check);
|
|
550
|
+
* - a non-operator audience is refused (`skipped: aud-mismatch`);
|
|
551
|
+
* - a loopback TARGET issuer is refused (`skipped: issuer-loopback`) — never
|
|
552
|
+
* downgrade a good public token back to loopback.
|
|
553
|
+
* This is strictly a re-issue of the hub's own still-valid credential under
|
|
554
|
+
* the hub's own new issuer.
|
|
555
|
+
*/
|
|
556
|
+
export async function selfHealOperatorTokenIssuer(
|
|
557
|
+
db: Database,
|
|
558
|
+
opts: SelfHealOperatorTokenOpts,
|
|
559
|
+
): Promise<OperatorIssuerHealStatus> {
|
|
560
|
+
const dir = opts.configDir ?? configDir();
|
|
561
|
+
const token = await readOperatorTokenFile(dir);
|
|
562
|
+
if (!token) return { kind: "absent" };
|
|
563
|
+
|
|
564
|
+
// Target-issuer loopback guard FIRST. Re-minting to a loopback `iss` would
|
|
565
|
+
// downgrade a good public token to a non-reachable issuer, recreating the
|
|
566
|
+
// exact iss-mismatch this fix prevents (mirrors `isLoopbackOrigin`'s role in
|
|
567
|
+
// vault-hub-origin-env.ts). Never do it — bail before touching the token.
|
|
568
|
+
if (isLoopbackOrigin(opts.issuer)) return { kind: "skipped", reason: "issuer-loopback" };
|
|
569
|
+
|
|
570
|
+
// Verify the on-disk token WITHOUT pinning `iss`: this checks the signature
|
|
571
|
+
// against the hub's current keys (by kid) + jose's `exp` + revocation, but
|
|
572
|
+
// deliberately does NOT reject a stale issuer. A throw here means the token
|
|
573
|
+
// is unverifiable (bad/unknown/expired kid, expired-by-jose, revoked) — we
|
|
574
|
+
// must NOT resurrect or trust it. Leave the disk file untouched; the operator
|
|
575
|
+
// recovers via `parachute auth rotate-operator`.
|
|
576
|
+
let payload: Awaited<ReturnType<typeof validateAccessToken>>["payload"];
|
|
577
|
+
try {
|
|
578
|
+
({ payload } = await validateAccessToken(db, token));
|
|
579
|
+
} catch {
|
|
580
|
+
return { kind: "skipped", reason: "unverifiable" };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// `iss` already current → no-op. Do NOT rewrite the file; it must stay
|
|
584
|
+
// byte-identical so repeated `start hub`s don't churn it.
|
|
585
|
+
if (payload.iss === opts.issuer) return { kind: "fresh" };
|
|
586
|
+
|
|
587
|
+
// `iss` differs → genuine-but-stale. Apply the same provenance guards
|
|
588
|
+
// `useOperatorTokenWithAutoRotate` uses before re-minting.
|
|
589
|
+
if (payload.aud !== OPERATOR_TOKEN_AUDIENCE) {
|
|
590
|
+
return { kind: "skipped", reason: "aud-mismatch" };
|
|
591
|
+
}
|
|
592
|
+
const sub = typeof payload.sub === "string" && payload.sub.length > 0 ? payload.sub : null;
|
|
593
|
+
if (!sub) return { kind: "skipped", reason: "no-sub" };
|
|
594
|
+
const claimedSet = payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM];
|
|
595
|
+
if (!isOperatorScopeSet(claimedSet)) {
|
|
596
|
+
// No recognized scope-set → falling back to a default would widen scope
|
|
597
|
+
// (hub#224). Refuse; never widen.
|
|
598
|
+
return { kind: "skipped", reason: "no-scope-set" };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Re-mint preserving scope-set + sub. `issueOperatorToken` writes the new
|
|
602
|
+
// token to disk atomically (mint → writeOperatorTokenFile).
|
|
603
|
+
const issued = await issueOperatorToken(db, sub, {
|
|
604
|
+
dir,
|
|
605
|
+
issuer: opts.issuer,
|
|
606
|
+
scopeSet: claimedSet,
|
|
607
|
+
...(opts.now !== undefined ? { now: opts.now } : {}),
|
|
608
|
+
});
|
|
609
|
+
return {
|
|
610
|
+
kind: "rotated",
|
|
611
|
+
path: issued.path,
|
|
612
|
+
scopeSet: issued.scopeSet,
|
|
613
|
+
expiresAt: issued.expiresAt,
|
|
614
|
+
};
|
|
615
|
+
}
|