@openparachute/hub 0.5.7 → 0.5.10-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -6,7 +6,13 @@ import { join } from "node:path";
|
|
|
6
6
|
import { getClient, registerClient } from "../clients.ts";
|
|
7
7
|
import { CSRF_COOKIE_NAME } from "../csrf.ts";
|
|
8
8
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
11
|
+
getSetting,
|
|
12
|
+
openFirstClientAutoApproveWindow,
|
|
13
|
+
setSetting,
|
|
14
|
+
} from "../hub-settings.ts";
|
|
15
|
+
import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
|
|
10
16
|
import {
|
|
11
17
|
authorizationServerMetadata,
|
|
12
18
|
buildServicesCatalog,
|
|
@@ -1034,10 +1040,37 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
1034
1040
|
|
|
1035
1041
|
// closes #81 — services catalog tells the client where vault lives so
|
|
1036
1042
|
// notes doesn't have to re-probe /.well-known/parachute.json. A
|
|
1037
|
-
// vault:read token
|
|
1043
|
+
// `vault:default:read` token sees both the collapsed `vault` key
|
|
1044
|
+
// (backwards compat) AND the per-vault `vault:default` key (closes
|
|
1045
|
+
// #247 — pre-#247 only the collapsed key was emitted; consumers on
|
|
1046
|
+
// multi-vault hubs were forced to assume `/vault/default` and
|
|
1047
|
+
// collided).
|
|
1038
1048
|
expect(tokenBody.services).toEqual({
|
|
1039
1049
|
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1040
|
-
|
|
1050
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// closes #215 reviewer F2 — Phase 1 code-grant access-token registry
|
|
1054
|
+
// exemption pinning. The access token and refresh token share `jti`
|
|
1055
|
+
// by design (signRefreshToken({ jti: access.jti, ... }) at the mint
|
|
1056
|
+
// site), so the `tokens` row keyed by the access-token jti IS the
|
|
1057
|
+
// shared row — refresh_token_hash is non-null, created_via is
|
|
1058
|
+
// 'oauth_refresh'. We deliberately don't write a separate per-jti
|
|
1059
|
+
// access-token row; revocation acts on the shared jti / family,
|
|
1060
|
+
// bounded by the 15-min access TTL.
|
|
1061
|
+
expect(payload.jti).toBeTruthy();
|
|
1062
|
+
const row = findTokenRowByJti(db, payload.jti as string);
|
|
1063
|
+
expect(row).not.toBeNull();
|
|
1064
|
+
expect(row?.createdVia).toBe("oauth_refresh");
|
|
1065
|
+
expect(row?.familyId).toBeTruthy();
|
|
1066
|
+
// Verify the registry has exactly one row for this code-grant
|
|
1067
|
+
// (not two — no separate access-token row).
|
|
1068
|
+
const rowCount = (
|
|
1069
|
+
db
|
|
1070
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) as n FROM tokens WHERE jti = ?")
|
|
1071
|
+
.get(payload.jti as string) ?? { n: 0 }
|
|
1072
|
+
).n;
|
|
1073
|
+
expect(rowCount).toBe(1);
|
|
1041
1074
|
} finally {
|
|
1042
1075
|
cleanup();
|
|
1043
1076
|
}
|
|
@@ -1523,6 +1556,156 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
1523
1556
|
vault: { url: `${ISSUER}/vault/work`, version: "0.3.0" },
|
|
1524
1557
|
});
|
|
1525
1558
|
});
|
|
1559
|
+
|
|
1560
|
+
// closes #247 — multi-vault correctness. Pre-#247 every vault collapsed
|
|
1561
|
+
// under the single `vault` key, so Notes' OAuthCallback always wrote
|
|
1562
|
+
// VaultRecord URL = paths[0] of the first vault row regardless of which
|
|
1563
|
+
// vault the token actually granted. Per-vault `vault:<name>` keys let
|
|
1564
|
+
// consumers route each grant to the correct vault URL.
|
|
1565
|
+
describe("services catalog — multi-vault per-vault keys (#247)", () => {
|
|
1566
|
+
// Real shape from a multi-vault hub: one `parachute-vault` row with N
|
|
1567
|
+
// paths, each path naming an instance. Aaron's setup verbatim (4
|
|
1568
|
+
// vaults: default, boulder, gitcoin, techne).
|
|
1569
|
+
const multiVaultManifest: ServicesManifest = {
|
|
1570
|
+
services: [
|
|
1571
|
+
{
|
|
1572
|
+
name: "parachute-vault",
|
|
1573
|
+
port: 1940,
|
|
1574
|
+
paths: ["/vault/default", "/vault/boulder", "/vault/gitcoin", "/vault/techne"],
|
|
1575
|
+
health: "/health",
|
|
1576
|
+
version: "0.4.4",
|
|
1577
|
+
},
|
|
1578
|
+
],
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
test("single-vault hub with broad scope: only collapsed `vault` key (unchanged)", () => {
|
|
1582
|
+
// Per-vault keys are noise on single-vault hubs — no disambiguation
|
|
1583
|
+
// is needed. Backwards compat for pre-popover clients matters here.
|
|
1584
|
+
expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:read"])).toEqual({
|
|
1585
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
test("single-vault hub with per-vault-narrowed scope: emits per-vault key too", () => {
|
|
1590
|
+
// A `vault:default:read` token is an explicit consumer signal that
|
|
1591
|
+
// the per-vault key matters — emit it even on a single-vault hub so
|
|
1592
|
+
// the consumer's `services["vault:default"]` lookup works uniformly
|
|
1593
|
+
// regardless of how many vaults the hub has.
|
|
1594
|
+
expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:default:read"])).toEqual({
|
|
1595
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1596
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1597
|
+
});
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
test("multi-vault hub with broad scope: emits every per-vault key + collapsed `vault`", () => {
|
|
1601
|
+
// Broad `vault:read` admits every vault on the hub. Per-#247
|
|
1602
|
+
// guidance: emit per-vault keys for all admitted vaults so the
|
|
1603
|
+
// consumer (Notes popover) can pick its target by name without
|
|
1604
|
+
// re-probing /.well-known/parachute.json.
|
|
1605
|
+
expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read"])).toEqual({
|
|
1606
|
+
// Collapsed key still emitted (first admitted path); backwards compat.
|
|
1607
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1608
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1609
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1610
|
+
"vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
|
|
1611
|
+
"vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
|
|
1612
|
+
});
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("multi-vault hub with per-vault scope: only that vault's per-vault key", () => {
|
|
1616
|
+
// Aaron's "Connect boulder" flow: token has `vault:boulder:write`,
|
|
1617
|
+
// scope admits only boulder. Pre-#247 the catalog said `vault.url =
|
|
1618
|
+
// /vault/default` (WRONG), so Notes stored a /vault/default record
|
|
1619
|
+
// with scope `vault:boulder:write` — collision city as more vaults
|
|
1620
|
+
// got connected. Post-#247 the consumer reads
|
|
1621
|
+
// `services["vault:boulder"].url` which correctly says /vault/boulder.
|
|
1622
|
+
expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:boulder:write"])).toEqual({
|
|
1623
|
+
// Collapsed `vault` points at boulder too — the only admitted
|
|
1624
|
+
// vault — so legacy consumers happen to land on the right URL even
|
|
1625
|
+
// though they have no per-vault awareness.
|
|
1626
|
+
vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1627
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1628
|
+
});
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
test("multi-vault hub with mixed scopes: per-vault keys for each narrowed vault", () => {
|
|
1632
|
+
// A token granting both `vault:boulder:read` and `vault:gitcoin:write`
|
|
1633
|
+
// admits exactly those two vaults; default and techne aren't reachable.
|
|
1634
|
+
expect(
|
|
1635
|
+
buildServicesCatalog(multiVaultManifest, ISSUER, [
|
|
1636
|
+
"vault:boulder:read",
|
|
1637
|
+
"vault:gitcoin:write",
|
|
1638
|
+
]),
|
|
1639
|
+
).toEqual({
|
|
1640
|
+
vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1641
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1642
|
+
"vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
test("multi-vault hub: broad + per-vault scopes coexist; broad opens all vaults", () => {
|
|
1647
|
+
// A token that carries BOTH `vault:read` (broad) AND
|
|
1648
|
+
// `vault:boulder:write` (narrow) should land in the broad bucket
|
|
1649
|
+
// because the broad scope is more permissive — narrowing one verb
|
|
1650
|
+
// can't take away access the unnamed scope already granted.
|
|
1651
|
+
expect(
|
|
1652
|
+
buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read", "vault:boulder:write"]),
|
|
1653
|
+
).toEqual({
|
|
1654
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1655
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1656
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1657
|
+
"vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
|
|
1658
|
+
"vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
|
|
1659
|
+
});
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
test("legacy per-vault rows (parachute-vault-<name>) also produce per-vault keys", () => {
|
|
1663
|
+
// Older multi-vault layout — one row per vault — should produce the
|
|
1664
|
+
// same catalog shape as the single-row-multi-path layout. The
|
|
1665
|
+
// `vaultInstanceNameFor` helper handles both via its
|
|
1666
|
+
// manifest-suffix fallback.
|
|
1667
|
+
const legacyManifest: ServicesManifest = {
|
|
1668
|
+
services: [
|
|
1669
|
+
{
|
|
1670
|
+
name: "parachute-vault",
|
|
1671
|
+
port: 1940,
|
|
1672
|
+
paths: ["/vault/default"],
|
|
1673
|
+
health: "/health",
|
|
1674
|
+
version: "0.4.4",
|
|
1675
|
+
},
|
|
1676
|
+
{
|
|
1677
|
+
name: "parachute-vault-work",
|
|
1678
|
+
port: 1941,
|
|
1679
|
+
paths: ["/vault/work"],
|
|
1680
|
+
health: "/health",
|
|
1681
|
+
version: "0.4.4",
|
|
1682
|
+
},
|
|
1683
|
+
],
|
|
1684
|
+
};
|
|
1685
|
+
expect(buildServicesCatalog(legacyManifest, ISSUER, ["vault:read"])).toEqual({
|
|
1686
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1687
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1688
|
+
"vault:work": { url: `${ISSUER}/vault/work`, version: "0.4.4" },
|
|
1689
|
+
});
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
test("non-vault services unaffected — only one key per service, no per-instance variant", () => {
|
|
1693
|
+
// The per-vault-key expansion is vault-specific. scribe / notes /
|
|
1694
|
+
// third-party rows still emit one key per service.
|
|
1695
|
+
expect(
|
|
1696
|
+
buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, [
|
|
1697
|
+
"vault:default:read",
|
|
1698
|
+
"scribe:transcribe",
|
|
1699
|
+
"notes:read",
|
|
1700
|
+
]),
|
|
1701
|
+
).toEqual({
|
|
1702
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1703
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1704
|
+
scribe: { url: `${ISSUER}/scribe`, version: "0.3.0-rc.1" },
|
|
1705
|
+
notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
|
|
1706
|
+
});
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1526
1709
|
});
|
|
1527
1710
|
|
|
1528
1711
|
// closes #72 — RFC 6749 §3.2.1 + §2.3.1: confidential clients must
|
|
@@ -2054,6 +2237,76 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2054
2237
|
const html = await res.text();
|
|
2055
2238
|
expect(html).toContain("App not yet approved");
|
|
2056
2239
|
expect(html).toContain("approve-client");
|
|
2240
|
+
// No vault hint → no vault row in approve-meta. Single-vault hubs +
|
|
2241
|
+
// pre-vault-popover clients leave the section omitted (#244).
|
|
2242
|
+
expect(html).not.toContain('approve-meta-label">vault');
|
|
2243
|
+
} finally {
|
|
2244
|
+
cleanup();
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
// closes #244 — vault hint surfaced in approve-pending UI. Notes#115
|
|
2249
|
+
// passes `vault=<name>` on `/oauth/authorize` for per-vault grants; hub's
|
|
2250
|
+
// approve page now displays it alongside the other client metadata so a
|
|
2251
|
+
// multi-vault operator can tell which vault they're approving for.
|
|
2252
|
+
test("authorize: pending client with vault hint → approve UI renders 'vault: <name>'", async () => {
|
|
2253
|
+
const { db, cleanup } = await makeDb();
|
|
2254
|
+
try {
|
|
2255
|
+
const reg = registerClient(db, {
|
|
2256
|
+
redirectUris: ["https://app.example/cb"],
|
|
2257
|
+
status: "pending",
|
|
2258
|
+
});
|
|
2259
|
+
const { challenge } = makePkce();
|
|
2260
|
+
const req = new Request(
|
|
2261
|
+
authorizeUrl({
|
|
2262
|
+
client_id: reg.client.clientId,
|
|
2263
|
+
redirect_uri: "https://app.example/cb",
|
|
2264
|
+
response_type: "code",
|
|
2265
|
+
code_challenge: challenge,
|
|
2266
|
+
code_challenge_method: "S256",
|
|
2267
|
+
scope: "vault:read",
|
|
2268
|
+
vault: "boulder",
|
|
2269
|
+
}),
|
|
2270
|
+
);
|
|
2271
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
2272
|
+
expect(res.status).toBe(403);
|
|
2273
|
+
const html = await res.text();
|
|
2274
|
+
expect(html).toContain("App not yet approved");
|
|
2275
|
+
// The vault hint surfaces as a labeled row in the approve-meta block.
|
|
2276
|
+
expect(html).toContain('approve-meta-label">vault');
|
|
2277
|
+
expect(html).toContain("boulder");
|
|
2278
|
+
} finally {
|
|
2279
|
+
cleanup();
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
test("authorize: pending client with empty vault param → no vault row", async () => {
|
|
2284
|
+
// Defensive: `vault=` with empty value normalizes to undefined so the
|
|
2285
|
+
// UI doesn't render a blank vault label. Easy to hit if a client builds
|
|
2286
|
+
// the URL via URLSearchParams.set("vault", someMaybeEmptyVar).
|
|
2287
|
+
const { db, cleanup } = await makeDb();
|
|
2288
|
+
try {
|
|
2289
|
+
const reg = registerClient(db, {
|
|
2290
|
+
redirectUris: ["https://app.example/cb"],
|
|
2291
|
+
status: "pending",
|
|
2292
|
+
});
|
|
2293
|
+
const { challenge } = makePkce();
|
|
2294
|
+
const req = new Request(
|
|
2295
|
+
authorizeUrl({
|
|
2296
|
+
client_id: reg.client.clientId,
|
|
2297
|
+
redirect_uri: "https://app.example/cb",
|
|
2298
|
+
response_type: "code",
|
|
2299
|
+
code_challenge: challenge,
|
|
2300
|
+
code_challenge_method: "S256",
|
|
2301
|
+
scope: "vault:read",
|
|
2302
|
+
vault: "",
|
|
2303
|
+
}),
|
|
2304
|
+
);
|
|
2305
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
2306
|
+
expect(res.status).toBe(403);
|
|
2307
|
+
const html = await res.text();
|
|
2308
|
+
expect(html).toContain("App not yet approved");
|
|
2309
|
+
expect(html).not.toContain('approve-meta-label">vault');
|
|
2057
2310
|
} finally {
|
|
2058
2311
|
cleanup();
|
|
2059
2312
|
}
|
|
@@ -2103,6 +2356,13 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2103
2356
|
const body = (await res.json()) as Record<string, unknown>;
|
|
2104
2357
|
expect(body.error).toBe("invalid_client");
|
|
2105
2358
|
expect(body.error_description).toContain("not been approved");
|
|
2359
|
+
// Surface the inline-approval affordances so consumers (Notes, future
|
|
2360
|
+
// cross-origin SPAs) can deep-link the operator to a browser-based
|
|
2361
|
+
// approve flow without dropping to a terminal.
|
|
2362
|
+
expect(body.approve_url).toBe(
|
|
2363
|
+
`${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
2364
|
+
);
|
|
2365
|
+
expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
|
|
2106
2366
|
} finally {
|
|
2107
2367
|
cleanup();
|
|
2108
2368
|
}
|
|
@@ -2132,6 +2392,13 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2132
2392
|
expect(res.status).toBe(401);
|
|
2133
2393
|
const body = (await res.json()) as Record<string, unknown>;
|
|
2134
2394
|
expect(body.error).toBe("invalid_client");
|
|
2395
|
+
// Same pending-affordance shape on the refresh path: a long-lived
|
|
2396
|
+
// OAuth client whose row was unapproved between issuance and refresh
|
|
2397
|
+
// hits this branch and surfaces the same approve_url + cli_alternative.
|
|
2398
|
+
expect(body.approve_url).toBe(
|
|
2399
|
+
`${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
2400
|
+
);
|
|
2401
|
+
expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
|
|
2135
2402
|
} finally {
|
|
2136
2403
|
cleanup();
|
|
2137
2404
|
}
|
|
@@ -2309,7 +2576,7 @@ describe("DCR auto-approve via session cookie (#199)", () => {
|
|
|
2309
2576
|
// Sandbox iframes (`<iframe sandbox>` without `allow-same-origin`),
|
|
2310
2577
|
// `data:`/`file:` documents, and some privacy contexts send the literal
|
|
2311
2578
|
// string `Origin: null` rather than omitting the header. `new URL("null")`
|
|
2312
|
-
// throws →
|
|
2579
|
+
// throws → isSameOriginRequest's try/catch returns false → DCR stays
|
|
2313
2580
|
// pending. This test pins that invariant: an opaque-origin caller does
|
|
2314
2581
|
// NOT ride the cookie path even with a valid session, because we can't
|
|
2315
2582
|
// prove the request came from the issuer's own origin.
|
|
@@ -2944,6 +3211,115 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
|
|
|
2944
3211
|
// to the auth-code redirect. Strict superset (incremental scope) and
|
|
2945
3212
|
// revoked grants still show consent.
|
|
2946
3213
|
describe("handleAuthorizeGet — skip consent when scope already granted (#75)", () => {
|
|
3214
|
+
// hub#236 — pin the full silent-approve flow end-to-end in one test.
|
|
3215
|
+
// The per-branch tests below this one cover individual branches (subset,
|
|
3216
|
+
// superset, revoke, unnamed-vault, re-registered-client); this test
|
|
3217
|
+
// walks the operator-visible state machine in a single body so a
|
|
3218
|
+
// regression at any step surfaces immediately, and the JSDoc on
|
|
3219
|
+
// handleAuthorizeGet's silent-approve flow (1-5) has a single load-
|
|
3220
|
+
// bearing test to point at.
|
|
3221
|
+
test("first-use consent → silent-approve → novel-scope re-prompts (full silent-approve flow, #236)", async () => {
|
|
3222
|
+
const { db, cleanup } = await makeDb();
|
|
3223
|
+
try {
|
|
3224
|
+
const user = await createUser(db, "owner", "pw");
|
|
3225
|
+
const session = createSession(db, { userId: user.id });
|
|
3226
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
3227
|
+
const { challenge } = makePkce();
|
|
3228
|
+
const sessionCookie = buildSessionCookie(session.id, 86400);
|
|
3229
|
+
|
|
3230
|
+
// Step 1: first use — no grant exists; consent screen renders.
|
|
3231
|
+
const firstReq = new Request(
|
|
3232
|
+
authorizeUrl({
|
|
3233
|
+
client_id: reg.client.clientId,
|
|
3234
|
+
redirect_uri: "https://app.example/cb",
|
|
3235
|
+
response_type: "code",
|
|
3236
|
+
scope: "vault:default:read",
|
|
3237
|
+
code_challenge: challenge,
|
|
3238
|
+
code_challenge_method: "S256",
|
|
3239
|
+
state: "step1",
|
|
3240
|
+
}),
|
|
3241
|
+
{ headers: { cookie: sessionCookie } },
|
|
3242
|
+
);
|
|
3243
|
+
const firstRes = handleAuthorizeGet(db, firstReq, { issuer: ISSUER });
|
|
3244
|
+
expect(firstRes.status).toBe(200);
|
|
3245
|
+
expect(firstRes.headers.get("content-type")).toContain("text/html");
|
|
3246
|
+
|
|
3247
|
+
// Step 1b: user approves via the consent form — grant gets recorded.
|
|
3248
|
+
const consentRes = await handleAuthorizePost(
|
|
3249
|
+
db,
|
|
3250
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
3251
|
+
method: "POST",
|
|
3252
|
+
body: new URLSearchParams({
|
|
3253
|
+
__action: "consent",
|
|
3254
|
+
__csrf: TEST_CSRF,
|
|
3255
|
+
approve: "yes",
|
|
3256
|
+
client_id: reg.client.clientId,
|
|
3257
|
+
redirect_uri: "https://app.example/cb",
|
|
3258
|
+
response_type: "code",
|
|
3259
|
+
scope: "vault:default:read",
|
|
3260
|
+
code_challenge: challenge,
|
|
3261
|
+
code_challenge_method: "S256",
|
|
3262
|
+
}),
|
|
3263
|
+
headers: {
|
|
3264
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3265
|
+
cookie: `${CSRF_COOKIE}; ${sessionCookie}`,
|
|
3266
|
+
},
|
|
3267
|
+
}),
|
|
3268
|
+
{ issuer: ISSUER },
|
|
3269
|
+
);
|
|
3270
|
+
expect(consentRes.status).toBe(302);
|
|
3271
|
+
|
|
3272
|
+
// Step 2: subsequent use, same scopes — silent-approve fires.
|
|
3273
|
+
// Authoritative assertion: 302 redirect with auth code, NOT a 200
|
|
3274
|
+
// HTML consent screen. This is the operator-visible payoff.
|
|
3275
|
+
const secondReq = new Request(
|
|
3276
|
+
authorizeUrl({
|
|
3277
|
+
client_id: reg.client.clientId,
|
|
3278
|
+
redirect_uri: "https://app.example/cb",
|
|
3279
|
+
response_type: "code",
|
|
3280
|
+
scope: "vault:default:read",
|
|
3281
|
+
code_challenge: challenge,
|
|
3282
|
+
code_challenge_method: "S256",
|
|
3283
|
+
state: "step2",
|
|
3284
|
+
}),
|
|
3285
|
+
{ headers: { cookie: sessionCookie } },
|
|
3286
|
+
);
|
|
3287
|
+
const secondRes = handleAuthorizeGet(db, secondReq, { issuer: ISSUER });
|
|
3288
|
+
expect(secondRes.status).toBe(302);
|
|
3289
|
+
const secondLoc = new URL(secondRes.headers.get("location") ?? "");
|
|
3290
|
+
expect(secondLoc.origin + secondLoc.pathname).toBe("https://app.example/cb");
|
|
3291
|
+
expect(secondLoc.searchParams.get("code")?.length).toBeGreaterThan(20);
|
|
3292
|
+
expect(secondLoc.searchParams.get("state")).toBe("step2");
|
|
3293
|
+
|
|
3294
|
+
// Step 3: subsequent use, novel scope NOT in the grant — gate must
|
|
3295
|
+
// NOT fire; consent re-renders with the new scope explicit. This is
|
|
3296
|
+
// the load-bearing security property: silent-approve must not
|
|
3297
|
+
// silently approve scopes the user never consented to.
|
|
3298
|
+
const novelReq = new Request(
|
|
3299
|
+
authorizeUrl({
|
|
3300
|
+
client_id: reg.client.clientId,
|
|
3301
|
+
redirect_uri: "https://app.example/cb",
|
|
3302
|
+
response_type: "code",
|
|
3303
|
+
// Adds scribe:transcribe to the original vault:default:read.
|
|
3304
|
+
scope: "vault:default:read scribe:transcribe",
|
|
3305
|
+
code_challenge: challenge,
|
|
3306
|
+
code_challenge_method: "S256",
|
|
3307
|
+
state: "step3",
|
|
3308
|
+
}),
|
|
3309
|
+
{ headers: { cookie: sessionCookie } },
|
|
3310
|
+
);
|
|
3311
|
+
const novelRes = handleAuthorizeGet(db, novelReq, { issuer: ISSUER });
|
|
3312
|
+
expect(novelRes.status).toBe(200);
|
|
3313
|
+
expect(novelRes.headers.get("content-type")).toContain("text/html");
|
|
3314
|
+
const novelBody = await novelRes.text();
|
|
3315
|
+
// The new scope appears on the consent page — the user must approve
|
|
3316
|
+
// it explicitly.
|
|
3317
|
+
expect(novelBody).toContain("scribe:transcribe");
|
|
3318
|
+
} finally {
|
|
3319
|
+
cleanup();
|
|
3320
|
+
}
|
|
3321
|
+
});
|
|
3322
|
+
|
|
2947
3323
|
test("first approval records grant; second flow with same scopes skips consent", async () => {
|
|
2948
3324
|
const { db, cleanup } = await makeDb();
|
|
2949
3325
|
try {
|
|
@@ -3556,7 +3932,7 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3556
3932
|
// Opaque-origin contexts (sandboxed iframes, some `data:` and `file:`
|
|
3557
3933
|
// pages) send the literal string "null" as the Origin header. The DCR
|
|
3558
3934
|
// /register path covers this; the inline-approve endpoint must reject it
|
|
3559
|
-
// too.
|
|
3935
|
+
// too. isSameOriginRequest() handles this correctly because new URL("null")
|
|
3560
3936
|
// throws → returns false; this test pins that contract.
|
|
3561
3937
|
const { db, cleanup } = await makeDb();
|
|
3562
3938
|
try {
|
|
@@ -3846,3 +4222,144 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3846
4222
|
}
|
|
3847
4223
|
});
|
|
3848
4224
|
});
|
|
4225
|
+
|
|
4226
|
+
// DCR first-client auto-approve window (hub#268 Item 3). The wizard's
|
|
4227
|
+
// expose-step POST opens a 60-minute window where the very next
|
|
4228
|
+
// `/oauth/register` registration is auto-approved + the window cleared.
|
|
4229
|
+
// Single-use: client #2 within the same window falls through to the
|
|
4230
|
+
// standard pending-approval flow.
|
|
4231
|
+
describe("DCR first-client auto-approve window (hub#268 Item 3)", () => {
|
|
4232
|
+
function registerRequest(): Request {
|
|
4233
|
+
return new Request(`${ISSUER}/oauth/register`, {
|
|
4234
|
+
method: "POST",
|
|
4235
|
+
body: JSON.stringify({
|
|
4236
|
+
redirect_uris: ["https://app.example/cb"],
|
|
4237
|
+
client_name: "first-client",
|
|
4238
|
+
}),
|
|
4239
|
+
headers: { "content-type": "application/json" },
|
|
4240
|
+
});
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
test("client registered within the open window → status approved + window cleared", async () => {
|
|
4244
|
+
const { db, cleanup } = await makeDb();
|
|
4245
|
+
try {
|
|
4246
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4247
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4248
|
+
const res = await handleRegister(db, registerRequest(), {
|
|
4249
|
+
issuer: ISSUER,
|
|
4250
|
+
now: () => t0,
|
|
4251
|
+
});
|
|
4252
|
+
expect(res.status).toBe(201);
|
|
4253
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4254
|
+
expect(body.status).toBe("approved");
|
|
4255
|
+
// Persisted, not just response-shaped.
|
|
4256
|
+
const row = getClient(db, body.client_id as string);
|
|
4257
|
+
expect(row?.status).toBe("approved");
|
|
4258
|
+
// Window cleared on consume (single-use).
|
|
4259
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
4260
|
+
} finally {
|
|
4261
|
+
cleanup();
|
|
4262
|
+
}
|
|
4263
|
+
});
|
|
4264
|
+
|
|
4265
|
+
test("client registered AFTER the window has expired → status pending", async () => {
|
|
4266
|
+
const { db, cleanup } = await makeDb();
|
|
4267
|
+
try {
|
|
4268
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4269
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4270
|
+
const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
|
|
4271
|
+
const res = await handleRegister(db, registerRequest(), {
|
|
4272
|
+
issuer: ISSUER,
|
|
4273
|
+
now: () => past,
|
|
4274
|
+
});
|
|
4275
|
+
expect(res.status).toBe(201);
|
|
4276
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4277
|
+
expect(body.status).toBe("pending");
|
|
4278
|
+
} finally {
|
|
4279
|
+
cleanup();
|
|
4280
|
+
}
|
|
4281
|
+
});
|
|
4282
|
+
|
|
4283
|
+
test("second client within window after first auto-approved → status pending (single-use)", async () => {
|
|
4284
|
+
const { db, cleanup } = await makeDb();
|
|
4285
|
+
try {
|
|
4286
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4287
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4288
|
+
// Client #1: approved.
|
|
4289
|
+
const res1 = await handleRegister(db, registerRequest(), {
|
|
4290
|
+
issuer: ISSUER,
|
|
4291
|
+
now: () => t0,
|
|
4292
|
+
});
|
|
4293
|
+
const body1 = (await res1.json()) as Record<string, unknown>;
|
|
4294
|
+
expect(body1.status).toBe("approved");
|
|
4295
|
+
// Client #2 within the (still-not-expired) window: pending.
|
|
4296
|
+
const stillWithinWindow = new Date(t0.getTime() + 30 * 60 * 1000);
|
|
4297
|
+
const res2 = await handleRegister(db, registerRequest(), {
|
|
4298
|
+
issuer: ISSUER,
|
|
4299
|
+
now: () => stillWithinWindow,
|
|
4300
|
+
});
|
|
4301
|
+
const body2 = (await res2.json()) as Record<string, unknown>;
|
|
4302
|
+
expect(body2.status).toBe("pending");
|
|
4303
|
+
} finally {
|
|
4304
|
+
cleanup();
|
|
4305
|
+
}
|
|
4306
|
+
});
|
|
4307
|
+
|
|
4308
|
+
test("no window set → status pending (default public-DCR flow)", async () => {
|
|
4309
|
+
const { db, cleanup } = await makeDb();
|
|
4310
|
+
try {
|
|
4311
|
+
const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
|
|
4312
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4313
|
+
expect(body.status).toBe("pending");
|
|
4314
|
+
// Settings row untouched.
|
|
4315
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
4316
|
+
} finally {
|
|
4317
|
+
cleanup();
|
|
4318
|
+
}
|
|
4319
|
+
});
|
|
4320
|
+
|
|
4321
|
+
test("operator-bearer auto-approve still takes precedence over the window (no double-consume)", async () => {
|
|
4322
|
+
// Bearer-authenticated registration approves directly; the
|
|
4323
|
+
// auto-approve window should NOT be consumed in that case — it's
|
|
4324
|
+
// still available for the first un-authenticated client.
|
|
4325
|
+
const { db, cleanup } = await makeDb();
|
|
4326
|
+
try {
|
|
4327
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4328
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4329
|
+
// We can't easily mint an operator bearer in this test layer, so
|
|
4330
|
+
// simulate by using the session-cookie path (issuer-trusted) which
|
|
4331
|
+
// also auto-approves before falling through to the window check.
|
|
4332
|
+
const user = await createUser(db, "owner", "pw");
|
|
4333
|
+
const session = createSession(db, { userId: user.id });
|
|
4334
|
+
const req = new Request(`${ISSUER}/oauth/register`, {
|
|
4335
|
+
method: "POST",
|
|
4336
|
+
body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
|
|
4337
|
+
headers: {
|
|
4338
|
+
"content-type": "application/json",
|
|
4339
|
+
cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
|
|
4340
|
+
origin: ISSUER,
|
|
4341
|
+
},
|
|
4342
|
+
});
|
|
4343
|
+
const res = await handleRegister(db, req, { issuer: ISSUER, now: () => t0 });
|
|
4344
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4345
|
+
expect(body.status).toBe("approved");
|
|
4346
|
+
// Window NOT consumed — still set, still open. The session-cookie
|
|
4347
|
+
// path approved first, never reaching the window-consume code.
|
|
4348
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
|
|
4349
|
+
} finally {
|
|
4350
|
+
cleanup();
|
|
4351
|
+
}
|
|
4352
|
+
});
|
|
4353
|
+
|
|
4354
|
+
test("malformed timestamp in the setting → treated as no-window, status pending", async () => {
|
|
4355
|
+
const { db, cleanup } = await makeDb();
|
|
4356
|
+
try {
|
|
4357
|
+
setSetting(db, "pending_first_client_auto_approve_until", "not-a-real-iso-string");
|
|
4358
|
+
const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
|
|
4359
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4360
|
+
expect(body.status).toBe("pending");
|
|
4361
|
+
} finally {
|
|
4362
|
+
cleanup();
|
|
4363
|
+
}
|
|
4364
|
+
});
|
|
4365
|
+
});
|