@openparachute/hub 0.5.7 → 0.5.10-rc.2
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-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__/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 +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- 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 +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -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-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.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- 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 +238 -54
- 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/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -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
|
@@ -126,6 +126,54 @@ describe("validateModuleManifest", () => {
|
|
|
126
126
|
).toThrow(/http:.*https:/);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test("uiUrl accepts a leading-slash path (Phase D)", () => {
|
|
130
|
+
const m = validateModuleManifest({ ...VALID, uiUrl: "/notes" }, "x");
|
|
131
|
+
expect(m.uiUrl).toBe("/notes");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("uiUrl accepts an absolute https URL", () => {
|
|
135
|
+
const m = validateModuleManifest({ ...VALID, uiUrl: "https://app.example.com/" }, "x");
|
|
136
|
+
expect(m.uiUrl).toBe("https://app.example.com/");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("uiUrl rejects empty / non-string / non-url-or-path (mirrors managementUrl)", () => {
|
|
140
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "" }, "x")).toThrow(/uiUrl/);
|
|
141
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: 7 }, "x")).toThrow(/uiUrl/);
|
|
142
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "no-slash" }, "x")).toThrow(
|
|
143
|
+
/path starting with "\/" or a full http\(s\) URL/,
|
|
144
|
+
);
|
|
145
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "ftp://example.com" }, "x")).toThrow(
|
|
146
|
+
/http:.*https:/,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("uiUrl absent stays absent", () => {
|
|
151
|
+
const m = validateModuleManifest(VALID, "x");
|
|
152
|
+
expect(m.uiUrl).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Open-redirect regression: protocol-relative paths like "//evil.com" pass
|
|
156
|
+
// a naive `startsWith("/")` check but `new URL("//evil.com", base)` resolves
|
|
157
|
+
// to the foreign origin. A malicious third-party module could plant such a
|
|
158
|
+
// value in module.json:uiUrl and turn a discovery tile into an off-origin
|
|
159
|
+
// redirect. Both uiUrl and managementUrl are validated by the shared
|
|
160
|
+
// asPathOrUrl helper, so cover both.
|
|
161
|
+
test("uiUrl rejects protocol-relative paths (open-redirect regression)", () => {
|
|
162
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com" }, "x")).toThrow(/uiUrl/);
|
|
163
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "//evil.com/path" }, "x")).toThrow(
|
|
164
|
+
/uiUrl/,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("managementUrl rejects protocol-relative paths (open-redirect regression)", () => {
|
|
169
|
+
expect(() => validateModuleManifest({ ...VALID, managementUrl: "//evil.com" }, "x")).toThrow(
|
|
170
|
+
/managementUrl/,
|
|
171
|
+
);
|
|
172
|
+
expect(() =>
|
|
173
|
+
validateModuleManifest({ ...VALID, managementUrl: "//evil.com/admin" }, "x"),
|
|
174
|
+
).toThrow(/managementUrl/);
|
|
175
|
+
});
|
|
176
|
+
|
|
129
177
|
test("managementUrl absent stays absent", () => {
|
|
130
178
|
const m = validateModuleManifest(VALID, "x");
|
|
131
179
|
expect(m.managementUrl).toBeUndefined();
|
|
@@ -6,7 +6,7 @@ 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 { validateAccessToken } from "../jwt-sign.ts";
|
|
9
|
+
import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
|
|
10
10
|
import {
|
|
11
11
|
authorizationServerMetadata,
|
|
12
12
|
buildServicesCatalog,
|
|
@@ -1034,10 +1034,37 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
1034
1034
|
|
|
1035
1035
|
// closes #81 — services catalog tells the client where vault lives so
|
|
1036
1036
|
// notes doesn't have to re-probe /.well-known/parachute.json. A
|
|
1037
|
-
// vault:read token
|
|
1037
|
+
// `vault:default:read` token sees both the collapsed `vault` key
|
|
1038
|
+
// (backwards compat) AND the per-vault `vault:default` key (closes
|
|
1039
|
+
// #247 — pre-#247 only the collapsed key was emitted; consumers on
|
|
1040
|
+
// multi-vault hubs were forced to assume `/vault/default` and
|
|
1041
|
+
// collided).
|
|
1038
1042
|
expect(tokenBody.services).toEqual({
|
|
1039
1043
|
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1040
|
-
|
|
1044
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// closes #215 reviewer F2 — Phase 1 code-grant access-token registry
|
|
1048
|
+
// exemption pinning. The access token and refresh token share `jti`
|
|
1049
|
+
// by design (signRefreshToken({ jti: access.jti, ... }) at the mint
|
|
1050
|
+
// site), so the `tokens` row keyed by the access-token jti IS the
|
|
1051
|
+
// shared row — refresh_token_hash is non-null, created_via is
|
|
1052
|
+
// 'oauth_refresh'. We deliberately don't write a separate per-jti
|
|
1053
|
+
// access-token row; revocation acts on the shared jti / family,
|
|
1054
|
+
// bounded by the 15-min access TTL.
|
|
1055
|
+
expect(payload.jti).toBeTruthy();
|
|
1056
|
+
const row = findTokenRowByJti(db, payload.jti as string);
|
|
1057
|
+
expect(row).not.toBeNull();
|
|
1058
|
+
expect(row?.createdVia).toBe("oauth_refresh");
|
|
1059
|
+
expect(row?.familyId).toBeTruthy();
|
|
1060
|
+
// Verify the registry has exactly one row for this code-grant
|
|
1061
|
+
// (not two — no separate access-token row).
|
|
1062
|
+
const rowCount = (
|
|
1063
|
+
db
|
|
1064
|
+
.query<{ n: number }, [string]>("SELECT COUNT(*) as n FROM tokens WHERE jti = ?")
|
|
1065
|
+
.get(payload.jti as string) ?? { n: 0 }
|
|
1066
|
+
).n;
|
|
1067
|
+
expect(rowCount).toBe(1);
|
|
1041
1068
|
} finally {
|
|
1042
1069
|
cleanup();
|
|
1043
1070
|
}
|
|
@@ -1523,6 +1550,156 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
1523
1550
|
vault: { url: `${ISSUER}/vault/work`, version: "0.3.0" },
|
|
1524
1551
|
});
|
|
1525
1552
|
});
|
|
1553
|
+
|
|
1554
|
+
// closes #247 — multi-vault correctness. Pre-#247 every vault collapsed
|
|
1555
|
+
// under the single `vault` key, so Notes' OAuthCallback always wrote
|
|
1556
|
+
// VaultRecord URL = paths[0] of the first vault row regardless of which
|
|
1557
|
+
// vault the token actually granted. Per-vault `vault:<name>` keys let
|
|
1558
|
+
// consumers route each grant to the correct vault URL.
|
|
1559
|
+
describe("services catalog — multi-vault per-vault keys (#247)", () => {
|
|
1560
|
+
// Real shape from a multi-vault hub: one `parachute-vault` row with N
|
|
1561
|
+
// paths, each path naming an instance. Aaron's setup verbatim (4
|
|
1562
|
+
// vaults: default, boulder, gitcoin, techne).
|
|
1563
|
+
const multiVaultManifest: ServicesManifest = {
|
|
1564
|
+
services: [
|
|
1565
|
+
{
|
|
1566
|
+
name: "parachute-vault",
|
|
1567
|
+
port: 1940,
|
|
1568
|
+
paths: ["/vault/default", "/vault/boulder", "/vault/gitcoin", "/vault/techne"],
|
|
1569
|
+
health: "/health",
|
|
1570
|
+
version: "0.4.4",
|
|
1571
|
+
},
|
|
1572
|
+
],
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
test("single-vault hub with broad scope: only collapsed `vault` key (unchanged)", () => {
|
|
1576
|
+
// Per-vault keys are noise on single-vault hubs — no disambiguation
|
|
1577
|
+
// is needed. Backwards compat for pre-popover clients matters here.
|
|
1578
|
+
expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:read"])).toEqual({
|
|
1579
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1580
|
+
});
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
test("single-vault hub with per-vault-narrowed scope: emits per-vault key too", () => {
|
|
1584
|
+
// A `vault:default:read` token is an explicit consumer signal that
|
|
1585
|
+
// the per-vault key matters — emit it even on a single-vault hub so
|
|
1586
|
+
// the consumer's `services["vault:default"]` lookup works uniformly
|
|
1587
|
+
// regardless of how many vaults the hub has.
|
|
1588
|
+
expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:default:read"])).toEqual({
|
|
1589
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1590
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
test("multi-vault hub with broad scope: emits every per-vault key + collapsed `vault`", () => {
|
|
1595
|
+
// Broad `vault:read` admits every vault on the hub. Per-#247
|
|
1596
|
+
// guidance: emit per-vault keys for all admitted vaults so the
|
|
1597
|
+
// consumer (Notes popover) can pick its target by name without
|
|
1598
|
+
// re-probing /.well-known/parachute.json.
|
|
1599
|
+
expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read"])).toEqual({
|
|
1600
|
+
// Collapsed key still emitted (first admitted path); backwards compat.
|
|
1601
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1602
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1603
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1604
|
+
"vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
|
|
1605
|
+
"vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
test("multi-vault hub with per-vault scope: only that vault's per-vault key", () => {
|
|
1610
|
+
// Aaron's "Connect boulder" flow: token has `vault:boulder:write`,
|
|
1611
|
+
// scope admits only boulder. Pre-#247 the catalog said `vault.url =
|
|
1612
|
+
// /vault/default` (WRONG), so Notes stored a /vault/default record
|
|
1613
|
+
// with scope `vault:boulder:write` — collision city as more vaults
|
|
1614
|
+
// got connected. Post-#247 the consumer reads
|
|
1615
|
+
// `services["vault:boulder"].url` which correctly says /vault/boulder.
|
|
1616
|
+
expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:boulder:write"])).toEqual({
|
|
1617
|
+
// Collapsed `vault` points at boulder too — the only admitted
|
|
1618
|
+
// vault — so legacy consumers happen to land on the right URL even
|
|
1619
|
+
// though they have no per-vault awareness.
|
|
1620
|
+
vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1621
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
test("multi-vault hub with mixed scopes: per-vault keys for each narrowed vault", () => {
|
|
1626
|
+
// A token granting both `vault:boulder:read` and `vault:gitcoin:write`
|
|
1627
|
+
// admits exactly those two vaults; default and techne aren't reachable.
|
|
1628
|
+
expect(
|
|
1629
|
+
buildServicesCatalog(multiVaultManifest, ISSUER, [
|
|
1630
|
+
"vault:boulder:read",
|
|
1631
|
+
"vault:gitcoin:write",
|
|
1632
|
+
]),
|
|
1633
|
+
).toEqual({
|
|
1634
|
+
vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1635
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1636
|
+
"vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
|
|
1637
|
+
});
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
test("multi-vault hub: broad + per-vault scopes coexist; broad opens all vaults", () => {
|
|
1641
|
+
// A token that carries BOTH `vault:read` (broad) AND
|
|
1642
|
+
// `vault:boulder:write` (narrow) should land in the broad bucket
|
|
1643
|
+
// because the broad scope is more permissive — narrowing one verb
|
|
1644
|
+
// can't take away access the unnamed scope already granted.
|
|
1645
|
+
expect(
|
|
1646
|
+
buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read", "vault:boulder:write"]),
|
|
1647
|
+
).toEqual({
|
|
1648
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1649
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1650
|
+
"vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
|
|
1651
|
+
"vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
|
|
1652
|
+
"vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
test("legacy per-vault rows (parachute-vault-<name>) also produce per-vault keys", () => {
|
|
1657
|
+
// Older multi-vault layout — one row per vault — should produce the
|
|
1658
|
+
// same catalog shape as the single-row-multi-path layout. The
|
|
1659
|
+
// `vaultInstanceNameFor` helper handles both via its
|
|
1660
|
+
// manifest-suffix fallback.
|
|
1661
|
+
const legacyManifest: ServicesManifest = {
|
|
1662
|
+
services: [
|
|
1663
|
+
{
|
|
1664
|
+
name: "parachute-vault",
|
|
1665
|
+
port: 1940,
|
|
1666
|
+
paths: ["/vault/default"],
|
|
1667
|
+
health: "/health",
|
|
1668
|
+
version: "0.4.4",
|
|
1669
|
+
},
|
|
1670
|
+
{
|
|
1671
|
+
name: "parachute-vault-work",
|
|
1672
|
+
port: 1941,
|
|
1673
|
+
paths: ["/vault/work"],
|
|
1674
|
+
health: "/health",
|
|
1675
|
+
version: "0.4.4",
|
|
1676
|
+
},
|
|
1677
|
+
],
|
|
1678
|
+
};
|
|
1679
|
+
expect(buildServicesCatalog(legacyManifest, ISSUER, ["vault:read"])).toEqual({
|
|
1680
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1681
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
|
|
1682
|
+
"vault:work": { url: `${ISSUER}/vault/work`, version: "0.4.4" },
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
test("non-vault services unaffected — only one key per service, no per-instance variant", () => {
|
|
1687
|
+
// The per-vault-key expansion is vault-specific. scribe / notes /
|
|
1688
|
+
// third-party rows still emit one key per service.
|
|
1689
|
+
expect(
|
|
1690
|
+
buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, [
|
|
1691
|
+
"vault:default:read",
|
|
1692
|
+
"scribe:transcribe",
|
|
1693
|
+
"notes:read",
|
|
1694
|
+
]),
|
|
1695
|
+
).toEqual({
|
|
1696
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1697
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1698
|
+
scribe: { url: `${ISSUER}/scribe`, version: "0.3.0-rc.1" },
|
|
1699
|
+
notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
|
|
1700
|
+
});
|
|
1701
|
+
});
|
|
1702
|
+
});
|
|
1526
1703
|
});
|
|
1527
1704
|
|
|
1528
1705
|
// closes #72 — RFC 6749 §3.2.1 + §2.3.1: confidential clients must
|
|
@@ -2054,6 +2231,76 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2054
2231
|
const html = await res.text();
|
|
2055
2232
|
expect(html).toContain("App not yet approved");
|
|
2056
2233
|
expect(html).toContain("approve-client");
|
|
2234
|
+
// No vault hint → no vault row in approve-meta. Single-vault hubs +
|
|
2235
|
+
// pre-vault-popover clients leave the section omitted (#244).
|
|
2236
|
+
expect(html).not.toContain('approve-meta-label">vault');
|
|
2237
|
+
} finally {
|
|
2238
|
+
cleanup();
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
// closes #244 — vault hint surfaced in approve-pending UI. Notes#115
|
|
2243
|
+
// passes `vault=<name>` on `/oauth/authorize` for per-vault grants; hub's
|
|
2244
|
+
// approve page now displays it alongside the other client metadata so a
|
|
2245
|
+
// multi-vault operator can tell which vault they're approving for.
|
|
2246
|
+
test("authorize: pending client with vault hint → approve UI renders 'vault: <name>'", async () => {
|
|
2247
|
+
const { db, cleanup } = await makeDb();
|
|
2248
|
+
try {
|
|
2249
|
+
const reg = registerClient(db, {
|
|
2250
|
+
redirectUris: ["https://app.example/cb"],
|
|
2251
|
+
status: "pending",
|
|
2252
|
+
});
|
|
2253
|
+
const { challenge } = makePkce();
|
|
2254
|
+
const req = new Request(
|
|
2255
|
+
authorizeUrl({
|
|
2256
|
+
client_id: reg.client.clientId,
|
|
2257
|
+
redirect_uri: "https://app.example/cb",
|
|
2258
|
+
response_type: "code",
|
|
2259
|
+
code_challenge: challenge,
|
|
2260
|
+
code_challenge_method: "S256",
|
|
2261
|
+
scope: "vault:read",
|
|
2262
|
+
vault: "boulder",
|
|
2263
|
+
}),
|
|
2264
|
+
);
|
|
2265
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
2266
|
+
expect(res.status).toBe(403);
|
|
2267
|
+
const html = await res.text();
|
|
2268
|
+
expect(html).toContain("App not yet approved");
|
|
2269
|
+
// The vault hint surfaces as a labeled row in the approve-meta block.
|
|
2270
|
+
expect(html).toContain('approve-meta-label">vault');
|
|
2271
|
+
expect(html).toContain("boulder");
|
|
2272
|
+
} finally {
|
|
2273
|
+
cleanup();
|
|
2274
|
+
}
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
test("authorize: pending client with empty vault param → no vault row", async () => {
|
|
2278
|
+
// Defensive: `vault=` with empty value normalizes to undefined so the
|
|
2279
|
+
// UI doesn't render a blank vault label. Easy to hit if a client builds
|
|
2280
|
+
// the URL via URLSearchParams.set("vault", someMaybeEmptyVar).
|
|
2281
|
+
const { db, cleanup } = await makeDb();
|
|
2282
|
+
try {
|
|
2283
|
+
const reg = registerClient(db, {
|
|
2284
|
+
redirectUris: ["https://app.example/cb"],
|
|
2285
|
+
status: "pending",
|
|
2286
|
+
});
|
|
2287
|
+
const { challenge } = makePkce();
|
|
2288
|
+
const req = new Request(
|
|
2289
|
+
authorizeUrl({
|
|
2290
|
+
client_id: reg.client.clientId,
|
|
2291
|
+
redirect_uri: "https://app.example/cb",
|
|
2292
|
+
response_type: "code",
|
|
2293
|
+
code_challenge: challenge,
|
|
2294
|
+
code_challenge_method: "S256",
|
|
2295
|
+
scope: "vault:read",
|
|
2296
|
+
vault: "",
|
|
2297
|
+
}),
|
|
2298
|
+
);
|
|
2299
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
2300
|
+
expect(res.status).toBe(403);
|
|
2301
|
+
const html = await res.text();
|
|
2302
|
+
expect(html).toContain("App not yet approved");
|
|
2303
|
+
expect(html).not.toContain('approve-meta-label">vault');
|
|
2057
2304
|
} finally {
|
|
2058
2305
|
cleanup();
|
|
2059
2306
|
}
|
|
@@ -2103,6 +2350,13 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2103
2350
|
const body = (await res.json()) as Record<string, unknown>;
|
|
2104
2351
|
expect(body.error).toBe("invalid_client");
|
|
2105
2352
|
expect(body.error_description).toContain("not been approved");
|
|
2353
|
+
// Surface the inline-approval affordances so consumers (Notes, future
|
|
2354
|
+
// cross-origin SPAs) can deep-link the operator to a browser-based
|
|
2355
|
+
// approve flow without dropping to a terminal.
|
|
2356
|
+
expect(body.approve_url).toBe(
|
|
2357
|
+
`${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
2358
|
+
);
|
|
2359
|
+
expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
|
|
2106
2360
|
} finally {
|
|
2107
2361
|
cleanup();
|
|
2108
2362
|
}
|
|
@@ -2132,6 +2386,13 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2132
2386
|
expect(res.status).toBe(401);
|
|
2133
2387
|
const body = (await res.json()) as Record<string, unknown>;
|
|
2134
2388
|
expect(body.error).toBe("invalid_client");
|
|
2389
|
+
// Same pending-affordance shape on the refresh path: a long-lived
|
|
2390
|
+
// OAuth client whose row was unapproved between issuance and refresh
|
|
2391
|
+
// hits this branch and surfaces the same approve_url + cli_alternative.
|
|
2392
|
+
expect(body.approve_url).toBe(
|
|
2393
|
+
`${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
2394
|
+
);
|
|
2395
|
+
expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
|
|
2135
2396
|
} finally {
|
|
2136
2397
|
cleanup();
|
|
2137
2398
|
}
|
|
@@ -2309,7 +2570,7 @@ describe("DCR auto-approve via session cookie (#199)", () => {
|
|
|
2309
2570
|
// Sandbox iframes (`<iframe sandbox>` without `allow-same-origin`),
|
|
2310
2571
|
// `data:`/`file:` documents, and some privacy contexts send the literal
|
|
2311
2572
|
// string `Origin: null` rather than omitting the header. `new URL("null")`
|
|
2312
|
-
// throws →
|
|
2573
|
+
// throws → isSameOriginRequest's try/catch returns false → DCR stays
|
|
2313
2574
|
// pending. This test pins that invariant: an opaque-origin caller does
|
|
2314
2575
|
// NOT ride the cookie path even with a valid session, because we can't
|
|
2315
2576
|
// prove the request came from the issuer's own origin.
|
|
@@ -2944,6 +3205,115 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
|
|
|
2944
3205
|
// to the auth-code redirect. Strict superset (incremental scope) and
|
|
2945
3206
|
// revoked grants still show consent.
|
|
2946
3207
|
describe("handleAuthorizeGet — skip consent when scope already granted (#75)", () => {
|
|
3208
|
+
// hub#236 — pin the full silent-approve flow end-to-end in one test.
|
|
3209
|
+
// The per-branch tests below this one cover individual branches (subset,
|
|
3210
|
+
// superset, revoke, unnamed-vault, re-registered-client); this test
|
|
3211
|
+
// walks the operator-visible state machine in a single body so a
|
|
3212
|
+
// regression at any step surfaces immediately, and the JSDoc on
|
|
3213
|
+
// handleAuthorizeGet's silent-approve flow (1-5) has a single load-
|
|
3214
|
+
// bearing test to point at.
|
|
3215
|
+
test("first-use consent → silent-approve → novel-scope re-prompts (full silent-approve flow, #236)", async () => {
|
|
3216
|
+
const { db, cleanup } = await makeDb();
|
|
3217
|
+
try {
|
|
3218
|
+
const user = await createUser(db, "owner", "pw");
|
|
3219
|
+
const session = createSession(db, { userId: user.id });
|
|
3220
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
3221
|
+
const { challenge } = makePkce();
|
|
3222
|
+
const sessionCookie = buildSessionCookie(session.id, 86400);
|
|
3223
|
+
|
|
3224
|
+
// Step 1: first use — no grant exists; consent screen renders.
|
|
3225
|
+
const firstReq = new Request(
|
|
3226
|
+
authorizeUrl({
|
|
3227
|
+
client_id: reg.client.clientId,
|
|
3228
|
+
redirect_uri: "https://app.example/cb",
|
|
3229
|
+
response_type: "code",
|
|
3230
|
+
scope: "vault:default:read",
|
|
3231
|
+
code_challenge: challenge,
|
|
3232
|
+
code_challenge_method: "S256",
|
|
3233
|
+
state: "step1",
|
|
3234
|
+
}),
|
|
3235
|
+
{ headers: { cookie: sessionCookie } },
|
|
3236
|
+
);
|
|
3237
|
+
const firstRes = handleAuthorizeGet(db, firstReq, { issuer: ISSUER });
|
|
3238
|
+
expect(firstRes.status).toBe(200);
|
|
3239
|
+
expect(firstRes.headers.get("content-type")).toContain("text/html");
|
|
3240
|
+
|
|
3241
|
+
// Step 1b: user approves via the consent form — grant gets recorded.
|
|
3242
|
+
const consentRes = await handleAuthorizePost(
|
|
3243
|
+
db,
|
|
3244
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
3245
|
+
method: "POST",
|
|
3246
|
+
body: new URLSearchParams({
|
|
3247
|
+
__action: "consent",
|
|
3248
|
+
__csrf: TEST_CSRF,
|
|
3249
|
+
approve: "yes",
|
|
3250
|
+
client_id: reg.client.clientId,
|
|
3251
|
+
redirect_uri: "https://app.example/cb",
|
|
3252
|
+
response_type: "code",
|
|
3253
|
+
scope: "vault:default:read",
|
|
3254
|
+
code_challenge: challenge,
|
|
3255
|
+
code_challenge_method: "S256",
|
|
3256
|
+
}),
|
|
3257
|
+
headers: {
|
|
3258
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3259
|
+
cookie: `${CSRF_COOKIE}; ${sessionCookie}`,
|
|
3260
|
+
},
|
|
3261
|
+
}),
|
|
3262
|
+
{ issuer: ISSUER },
|
|
3263
|
+
);
|
|
3264
|
+
expect(consentRes.status).toBe(302);
|
|
3265
|
+
|
|
3266
|
+
// Step 2: subsequent use, same scopes — silent-approve fires.
|
|
3267
|
+
// Authoritative assertion: 302 redirect with auth code, NOT a 200
|
|
3268
|
+
// HTML consent screen. This is the operator-visible payoff.
|
|
3269
|
+
const secondReq = new Request(
|
|
3270
|
+
authorizeUrl({
|
|
3271
|
+
client_id: reg.client.clientId,
|
|
3272
|
+
redirect_uri: "https://app.example/cb",
|
|
3273
|
+
response_type: "code",
|
|
3274
|
+
scope: "vault:default:read",
|
|
3275
|
+
code_challenge: challenge,
|
|
3276
|
+
code_challenge_method: "S256",
|
|
3277
|
+
state: "step2",
|
|
3278
|
+
}),
|
|
3279
|
+
{ headers: { cookie: sessionCookie } },
|
|
3280
|
+
);
|
|
3281
|
+
const secondRes = handleAuthorizeGet(db, secondReq, { issuer: ISSUER });
|
|
3282
|
+
expect(secondRes.status).toBe(302);
|
|
3283
|
+
const secondLoc = new URL(secondRes.headers.get("location") ?? "");
|
|
3284
|
+
expect(secondLoc.origin + secondLoc.pathname).toBe("https://app.example/cb");
|
|
3285
|
+
expect(secondLoc.searchParams.get("code")?.length).toBeGreaterThan(20);
|
|
3286
|
+
expect(secondLoc.searchParams.get("state")).toBe("step2");
|
|
3287
|
+
|
|
3288
|
+
// Step 3: subsequent use, novel scope NOT in the grant — gate must
|
|
3289
|
+
// NOT fire; consent re-renders with the new scope explicit. This is
|
|
3290
|
+
// the load-bearing security property: silent-approve must not
|
|
3291
|
+
// silently approve scopes the user never consented to.
|
|
3292
|
+
const novelReq = new Request(
|
|
3293
|
+
authorizeUrl({
|
|
3294
|
+
client_id: reg.client.clientId,
|
|
3295
|
+
redirect_uri: "https://app.example/cb",
|
|
3296
|
+
response_type: "code",
|
|
3297
|
+
// Adds scribe:transcribe to the original vault:default:read.
|
|
3298
|
+
scope: "vault:default:read scribe:transcribe",
|
|
3299
|
+
code_challenge: challenge,
|
|
3300
|
+
code_challenge_method: "S256",
|
|
3301
|
+
state: "step3",
|
|
3302
|
+
}),
|
|
3303
|
+
{ headers: { cookie: sessionCookie } },
|
|
3304
|
+
);
|
|
3305
|
+
const novelRes = handleAuthorizeGet(db, novelReq, { issuer: ISSUER });
|
|
3306
|
+
expect(novelRes.status).toBe(200);
|
|
3307
|
+
expect(novelRes.headers.get("content-type")).toContain("text/html");
|
|
3308
|
+
const novelBody = await novelRes.text();
|
|
3309
|
+
// The new scope appears on the consent page — the user must approve
|
|
3310
|
+
// it explicitly.
|
|
3311
|
+
expect(novelBody).toContain("scribe:transcribe");
|
|
3312
|
+
} finally {
|
|
3313
|
+
cleanup();
|
|
3314
|
+
}
|
|
3315
|
+
});
|
|
3316
|
+
|
|
2947
3317
|
test("first approval records grant; second flow with same scopes skips consent", async () => {
|
|
2948
3318
|
const { db, cleanup } = await makeDb();
|
|
2949
3319
|
try {
|
|
@@ -3556,7 +3926,7 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3556
3926
|
// Opaque-origin contexts (sandboxed iframes, some `data:` and `file:`
|
|
3557
3927
|
// pages) send the literal string "null" as the Origin header. The DCR
|
|
3558
3928
|
// /register path covers this; the inline-approve endpoint must reject it
|
|
3559
|
-
// too.
|
|
3929
|
+
// too. isSameOriginRequest() handles this correctly because new URL("null")
|
|
3560
3930
|
// throws → returns false; this test pins that contract.
|
|
3561
3931
|
const { db, cleanup } = await makeDb();
|
|
3562
3932
|
try {
|