@openparachute/hub 0.5.2 → 0.5.9-rc.6
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 +159 -320
- 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 +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- 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/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- 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/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -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
|
@@ -3,13 +3,14 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { registerClient } from "../clients.ts";
|
|
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,
|
|
13
|
+
handleApproveClientPost,
|
|
13
14
|
handleAuthorizeGet,
|
|
14
15
|
handleAuthorizePost,
|
|
15
16
|
handleRegister,
|
|
@@ -1033,10 +1034,37 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
1033
1034
|
|
|
1034
1035
|
// closes #81 — services catalog tells the client where vault lives so
|
|
1035
1036
|
// notes doesn't have to re-probe /.well-known/parachute.json. A
|
|
1036
|
-
// 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).
|
|
1037
1042
|
expect(tokenBody.services).toEqual({
|
|
1038
1043
|
vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1044
|
+
"vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
|
|
1039
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);
|
|
1040
1068
|
} finally {
|
|
1041
1069
|
cleanup();
|
|
1042
1070
|
}
|
|
@@ -1522,6 +1550,156 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
1522
1550
|
vault: { url: `${ISSUER}/vault/work`, version: "0.3.0" },
|
|
1523
1551
|
});
|
|
1524
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
|
+
});
|
|
1525
1703
|
});
|
|
1526
1704
|
|
|
1527
1705
|
// closes #72 — RFC 6749 §3.2.1 + §2.3.1: confidential clients must
|
|
@@ -2053,6 +2231,76 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2053
2231
|
const html = await res.text();
|
|
2054
2232
|
expect(html).toContain("App not yet approved");
|
|
2055
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');
|
|
2056
2304
|
} finally {
|
|
2057
2305
|
cleanup();
|
|
2058
2306
|
}
|
|
@@ -2102,6 +2350,13 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2102
2350
|
const body = (await res.json()) as Record<string, unknown>;
|
|
2103
2351
|
expect(body.error).toBe("invalid_client");
|
|
2104
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}`);
|
|
2105
2360
|
} finally {
|
|
2106
2361
|
cleanup();
|
|
2107
2362
|
}
|
|
@@ -2131,6 +2386,13 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2131
2386
|
expect(res.status).toBe(401);
|
|
2132
2387
|
const body = (await res.json()) as Record<string, unknown>;
|
|
2133
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}`);
|
|
2134
2396
|
} finally {
|
|
2135
2397
|
cleanup();
|
|
2136
2398
|
}
|
|
@@ -2244,6 +2506,226 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2244
2506
|
});
|
|
2245
2507
|
});
|
|
2246
2508
|
|
|
2509
|
+
// closes #199 — DCR auto-approve for the operator's own browser. A valid
|
|
2510
|
+
// `parachute_hub_session` cookie indicates the operator is authenticated as
|
|
2511
|
+
// themselves; combined with a same-origin Origin/Referer (the CSRF gate)
|
|
2512
|
+
// that's enough to skip the manual `parachute auth approve-client` step.
|
|
2513
|
+
describe("DCR auto-approve via session cookie (#199)", () => {
|
|
2514
|
+
const SESSION_COOKIE_TTL_S = Math.floor(SESSION_TTL_MS / 1000);
|
|
2515
|
+
|
|
2516
|
+
function registerRequest(
|
|
2517
|
+
headers: Record<string, string>,
|
|
2518
|
+
bodyExtra: Record<string, unknown> = {},
|
|
2519
|
+
): Request {
|
|
2520
|
+
return new Request(`${ISSUER}/oauth/register`, {
|
|
2521
|
+
method: "POST",
|
|
2522
|
+
body: JSON.stringify({
|
|
2523
|
+
redirect_uris: ["https://app.example/cb"],
|
|
2524
|
+
...bodyExtra,
|
|
2525
|
+
}),
|
|
2526
|
+
headers: { "content-type": "application/json", ...headers },
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
test("valid session cookie + matching Origin → status approved (response + DB)", async () => {
|
|
2531
|
+
const { db, cleanup } = await makeDb();
|
|
2532
|
+
try {
|
|
2533
|
+
const user = await createUser(db, "owner", "pw");
|
|
2534
|
+
const session = createSession(db, { userId: user.id });
|
|
2535
|
+
const req = registerRequest({
|
|
2536
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2537
|
+
origin: ISSUER,
|
|
2538
|
+
});
|
|
2539
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2540
|
+
expect(res.status).toBe(201);
|
|
2541
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2542
|
+
expect(body.status).toBe("approved");
|
|
2543
|
+
// Persisted, not just response-shaped.
|
|
2544
|
+
const row = getClient(db, body.client_id as string);
|
|
2545
|
+
expect(row?.status).toBe("approved");
|
|
2546
|
+
} finally {
|
|
2547
|
+
cleanup();
|
|
2548
|
+
}
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
test("valid session cookie + cross-origin Origin → status pending (CSRF defense)", async () => {
|
|
2552
|
+
const { db, cleanup } = await makeDb();
|
|
2553
|
+
try {
|
|
2554
|
+
const user = await createUser(db, "owner", "pw");
|
|
2555
|
+
const session = createSession(db, { userId: user.id });
|
|
2556
|
+
const req = registerRequest({
|
|
2557
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2558
|
+
origin: "https://attacker.example",
|
|
2559
|
+
});
|
|
2560
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2561
|
+
expect(res.status).toBe(201);
|
|
2562
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2563
|
+
expect(body.status).toBe("pending");
|
|
2564
|
+
} finally {
|
|
2565
|
+
cleanup();
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
test("valid session cookie + Origin: 'null' (opaque/sandbox iframe) → pending", async () => {
|
|
2570
|
+
// Sandbox iframes (`<iframe sandbox>` without `allow-same-origin`),
|
|
2571
|
+
// `data:`/`file:` documents, and some privacy contexts send the literal
|
|
2572
|
+
// string `Origin: null` rather than omitting the header. `new URL("null")`
|
|
2573
|
+
// throws → isSameOriginRequest's try/catch returns false → DCR stays
|
|
2574
|
+
// pending. This test pins that invariant: an opaque-origin caller does
|
|
2575
|
+
// NOT ride the cookie path even with a valid session, because we can't
|
|
2576
|
+
// prove the request came from the issuer's own origin.
|
|
2577
|
+
const { db, cleanup } = await makeDb();
|
|
2578
|
+
try {
|
|
2579
|
+
const user = await createUser(db, "owner", "pw");
|
|
2580
|
+
const session = createSession(db, { userId: user.id });
|
|
2581
|
+
const req = registerRequest({
|
|
2582
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2583
|
+
origin: "null",
|
|
2584
|
+
});
|
|
2585
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2586
|
+
expect(res.status).toBe(201);
|
|
2587
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2588
|
+
expect(body.status).toBe("pending");
|
|
2589
|
+
// Persisted, not just response-shaped.
|
|
2590
|
+
const row = getClient(db, body.client_id as string);
|
|
2591
|
+
expect(row?.status).toBe("pending");
|
|
2592
|
+
} finally {
|
|
2593
|
+
cleanup();
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
test("valid session cookie + Origin matching exact origin (port included) → approved", async () => {
|
|
2598
|
+
// URL.origin includes scheme + host + port, so a port-mismatched Origin
|
|
2599
|
+
// must NOT match. https://hub.example:8443 ≠ https://hub.example.
|
|
2600
|
+
const { db, cleanup } = await makeDb();
|
|
2601
|
+
try {
|
|
2602
|
+
const issuer = "https://hub.example:8443";
|
|
2603
|
+
const user = await createUser(db, "owner", "pw");
|
|
2604
|
+
const session = createSession(db, { userId: user.id });
|
|
2605
|
+
|
|
2606
|
+
// Exact match (scheme + host + port) → approved.
|
|
2607
|
+
const okReq = registerRequest({
|
|
2608
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2609
|
+
origin: "https://hub.example:8443",
|
|
2610
|
+
});
|
|
2611
|
+
const okRes = await handleRegister(db, okReq, { issuer });
|
|
2612
|
+
expect(((await okRes.json()) as Record<string, unknown>).status).toBe("approved");
|
|
2613
|
+
|
|
2614
|
+
// Port-mismatched Origin (default 443 vs 8443) → pending.
|
|
2615
|
+
const badReq = registerRequest({
|
|
2616
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2617
|
+
origin: "https://hub.example",
|
|
2618
|
+
});
|
|
2619
|
+
const badRes = await handleRegister(db, badReq, { issuer });
|
|
2620
|
+
expect(((await badRes.json()) as Record<string, unknown>).status).toBe("pending");
|
|
2621
|
+
} finally {
|
|
2622
|
+
cleanup();
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
test("valid session cookie + matching Referer (no Origin) → approved (Referer fallback)", async () => {
|
|
2627
|
+
const { db, cleanup } = await makeDb();
|
|
2628
|
+
try {
|
|
2629
|
+
const user = await createUser(db, "owner", "pw");
|
|
2630
|
+
const session = createSession(db, { userId: user.id });
|
|
2631
|
+
const req = registerRequest({
|
|
2632
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2633
|
+
referer: `${ISSUER}/notes/`,
|
|
2634
|
+
});
|
|
2635
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2636
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2637
|
+
expect(body.status).toBe("approved");
|
|
2638
|
+
} finally {
|
|
2639
|
+
cleanup();
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
test("valid session cookie + no Origin AND no Referer → pending (deny without proof of origin)", async () => {
|
|
2644
|
+
const { db, cleanup } = await makeDb();
|
|
2645
|
+
try {
|
|
2646
|
+
const user = await createUser(db, "owner", "pw");
|
|
2647
|
+
const session = createSession(db, { userId: user.id });
|
|
2648
|
+
const req = registerRequest({
|
|
2649
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2650
|
+
});
|
|
2651
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2652
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2653
|
+
expect(body.status).toBe("pending");
|
|
2654
|
+
} finally {
|
|
2655
|
+
cleanup();
|
|
2656
|
+
}
|
|
2657
|
+
});
|
|
2658
|
+
|
|
2659
|
+
test("expired session cookie + matching Origin → pending (expiry check)", async () => {
|
|
2660
|
+
const { db, cleanup } = await makeDb();
|
|
2661
|
+
try {
|
|
2662
|
+
const user = await createUser(db, "owner", "pw");
|
|
2663
|
+
// Session created in the "now()" frame, but handleRegister sees a much
|
|
2664
|
+
// later clock — findSession (via findActiveSession) treats it as expired.
|
|
2665
|
+
const session = createSession(db, { userId: user.id });
|
|
2666
|
+
const future = new Date(Date.now() + SESSION_TTL_MS + 60_000);
|
|
2667
|
+
const req = registerRequest({
|
|
2668
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2669
|
+
origin: ISSUER,
|
|
2670
|
+
});
|
|
2671
|
+
const res = await handleRegister(db, req, { issuer: ISSUER, now: () => future });
|
|
2672
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2673
|
+
expect(body.status).toBe("pending");
|
|
2674
|
+
} finally {
|
|
2675
|
+
cleanup();
|
|
2676
|
+
}
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
test("invalid session cookie (id not in DB) + matching Origin → pending", async () => {
|
|
2680
|
+
const { db, cleanup } = await makeDb();
|
|
2681
|
+
try {
|
|
2682
|
+
const req = registerRequest({
|
|
2683
|
+
cookie: buildSessionCookie("not-a-real-session-id", SESSION_COOKIE_TTL_S),
|
|
2684
|
+
origin: ISSUER,
|
|
2685
|
+
});
|
|
2686
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2687
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2688
|
+
expect(body.status).toBe("pending");
|
|
2689
|
+
} finally {
|
|
2690
|
+
cleanup();
|
|
2691
|
+
}
|
|
2692
|
+
});
|
|
2693
|
+
|
|
2694
|
+
test("no cookie at all → pending (current public-DCR behavior)", async () => {
|
|
2695
|
+
const { db, cleanup } = await makeDb();
|
|
2696
|
+
try {
|
|
2697
|
+
const req = registerRequest({ origin: ISSUER });
|
|
2698
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2699
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2700
|
+
expect(body.status).toBe("pending");
|
|
2701
|
+
} finally {
|
|
2702
|
+
cleanup();
|
|
2703
|
+
}
|
|
2704
|
+
});
|
|
2705
|
+
|
|
2706
|
+
test("operator-bearer header (existing path) still → approved (regression)", async () => {
|
|
2707
|
+
// The new cookie-based path must not regress the bearer-based path that
|
|
2708
|
+
// first-party install (#74) depends on. Same setup as the #74 test, no
|
|
2709
|
+
// cookie supplied — bearer alone must continue to land approved.
|
|
2710
|
+
const { db, cleanup } = await makeDb();
|
|
2711
|
+
try {
|
|
2712
|
+
const { rotateSigningKey } = await import("../signing-keys.ts");
|
|
2713
|
+
const { mintOperatorToken } = await import("../operator-token.ts");
|
|
2714
|
+
rotateSigningKey(db);
|
|
2715
|
+
const user = await createUser(db, "owner", "pw");
|
|
2716
|
+
const operator = await mintOperatorToken(db, user.id, { issuer: ISSUER });
|
|
2717
|
+
|
|
2718
|
+
const req = registerRequest({ authorization: `Bearer ${operator.token}` });
|
|
2719
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2720
|
+
expect(res.status).toBe(201);
|
|
2721
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2722
|
+
expect(body.status).toBe("approved");
|
|
2723
|
+
} finally {
|
|
2724
|
+
cleanup();
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2247
2729
|
// closes #73 — RFC 6749 §6 refresh-token rotation, RFC 6819 §5.2.2.3 replay
|
|
2248
2730
|
// detection (family-wide revocation), RFC 7009 token revocation.
|
|
2249
2731
|
describe("refresh-token rotation + /oauth/revoke (#73)", () => {
|
|
@@ -3110,3 +3592,518 @@ describe("handleAuthorizeGet — skip consent when scope already granted (#75)",
|
|
|
3110
3592
|
}
|
|
3111
3593
|
});
|
|
3112
3594
|
});
|
|
3595
|
+
|
|
3596
|
+
// closes #208 — inline "Approve this app" form on the pending-client page
|
|
3597
|
+
// (cross-origin SPA recovery). Same security model as #199/#200 DCR
|
|
3598
|
+
// auto-approve: valid session + matching Origin = trusted operator. The
|
|
3599
|
+
// CSRF token is the third belt — a cross-origin POST with a leaked session
|
|
3600
|
+
// cookie still fails because the rendered token won't match.
|
|
3601
|
+
describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
3602
|
+
const SESSION_COOKIE_TTL_S = Math.floor(SESSION_TTL_MS / 1000);
|
|
3603
|
+
|
|
3604
|
+
function pendingAuthorizeUrl(clientId: string): string {
|
|
3605
|
+
const { challenge } = makePkce();
|
|
3606
|
+
return authorizeUrl({
|
|
3607
|
+
client_id: clientId,
|
|
3608
|
+
redirect_uri: "https://app.example/cb",
|
|
3609
|
+
response_type: "code",
|
|
3610
|
+
code_challenge: challenge,
|
|
3611
|
+
code_challenge_method: "S256",
|
|
3612
|
+
scope: "vault:read",
|
|
3613
|
+
state: "rt-208",
|
|
3614
|
+
});
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
test("session absent → page renders WITHOUT approve form (CLI-only fallback)", async () => {
|
|
3618
|
+
// Regression: pre-#208 behavior preserved when no session cookie is
|
|
3619
|
+
// present. The CLI-fallback message must still be visible so an operator
|
|
3620
|
+
// who arrived from a fresh browser knows what to do.
|
|
3621
|
+
const { db, cleanup } = await makeDb();
|
|
3622
|
+
try {
|
|
3623
|
+
const reg = registerClient(db, {
|
|
3624
|
+
redirectUris: ["https://app.example/cb"],
|
|
3625
|
+
clientName: "MyApp",
|
|
3626
|
+
status: "pending",
|
|
3627
|
+
});
|
|
3628
|
+
const req = new Request(pendingAuthorizeUrl(reg.client.clientId));
|
|
3629
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
3630
|
+
expect(res.status).toBe(403);
|
|
3631
|
+
const html = await res.text();
|
|
3632
|
+
expect(html).toContain("App not yet approved");
|
|
3633
|
+
// CLI-fallback message present — the only way to recover without a session.
|
|
3634
|
+
expect(html).toContain("approve-client");
|
|
3635
|
+
// No form element pointing at the approve endpoint.
|
|
3636
|
+
expect(html).not.toContain('action="/oauth/authorize/approve"');
|
|
3637
|
+
} finally {
|
|
3638
|
+
cleanup();
|
|
3639
|
+
}
|
|
3640
|
+
});
|
|
3641
|
+
|
|
3642
|
+
test("session valid + matching Origin → page renders WITH approve form + CSRF token", async () => {
|
|
3643
|
+
const { db, cleanup } = await makeDb();
|
|
3644
|
+
try {
|
|
3645
|
+
const user = await createUser(db, "owner", "pw");
|
|
3646
|
+
const session = createSession(db, { userId: user.id });
|
|
3647
|
+
const reg = registerClient(db, {
|
|
3648
|
+
redirectUris: ["https://app.example/cb"],
|
|
3649
|
+
clientName: "MyApp",
|
|
3650
|
+
status: "pending",
|
|
3651
|
+
});
|
|
3652
|
+
const req = new Request(pendingAuthorizeUrl(reg.client.clientId), {
|
|
3653
|
+
headers: {
|
|
3654
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3655
|
+
origin: ISSUER,
|
|
3656
|
+
},
|
|
3657
|
+
});
|
|
3658
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
3659
|
+
expect(res.status).toBe(403);
|
|
3660
|
+
const html = await res.text();
|
|
3661
|
+
expect(html).toContain("App not yet approved");
|
|
3662
|
+
// The form posts to the approve endpoint
|
|
3663
|
+
expect(html).toContain('action="/oauth/authorize/approve"');
|
|
3664
|
+
expect(html).toContain('name="client_id"');
|
|
3665
|
+
expect(html).toContain(`value="${reg.client.clientId}"`);
|
|
3666
|
+
// CSRF token present in the form
|
|
3667
|
+
expect(html).toContain(`value="${TEST_CSRF}"`);
|
|
3668
|
+
// return_to carries the original authorize URL so the post-approve
|
|
3669
|
+
// redirect lands the operator back on the same flow.
|
|
3670
|
+
expect(html).toContain('name="return_to"');
|
|
3671
|
+
expect(html).toContain("/oauth/authorize?");
|
|
3672
|
+
expect(html).toContain("rt-208"); // state echoed via return_to URL
|
|
3673
|
+
// Display fields present so operator can verify what they're approving.
|
|
3674
|
+
expect(html).toContain("MyApp");
|
|
3675
|
+
expect(html).toContain(reg.client.clientId);
|
|
3676
|
+
expect(html).toContain("https://app.example/cb");
|
|
3677
|
+
// CLI fallback still visible.
|
|
3678
|
+
expect(html).toContain("approve-client");
|
|
3679
|
+
} finally {
|
|
3680
|
+
cleanup();
|
|
3681
|
+
}
|
|
3682
|
+
});
|
|
3683
|
+
|
|
3684
|
+
test("approve POST happy path: CSRF + session + matching Origin → DB flips approved + 302 to authorize URL", async () => {
|
|
3685
|
+
const { db, cleanup } = await makeDb();
|
|
3686
|
+
try {
|
|
3687
|
+
const user = await createUser(db, "owner", "pw");
|
|
3688
|
+
const session = createSession(db, { userId: user.id });
|
|
3689
|
+
const reg = registerClient(db, {
|
|
3690
|
+
redirectUris: ["https://app.example/cb"],
|
|
3691
|
+
status: "pending",
|
|
3692
|
+
});
|
|
3693
|
+
const returnTo = `/oauth/authorize?client_id=${reg.client.clientId}&state=rt-208`;
|
|
3694
|
+
const form = new URLSearchParams({
|
|
3695
|
+
__csrf: TEST_CSRF,
|
|
3696
|
+
client_id: reg.client.clientId,
|
|
3697
|
+
return_to: returnTo,
|
|
3698
|
+
});
|
|
3699
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3700
|
+
method: "POST",
|
|
3701
|
+
body: form,
|
|
3702
|
+
headers: {
|
|
3703
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3704
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3705
|
+
origin: ISSUER,
|
|
3706
|
+
},
|
|
3707
|
+
});
|
|
3708
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3709
|
+
expect(res.status).toBe(302);
|
|
3710
|
+
expect(res.headers.get("location")).toBe(returnTo);
|
|
3711
|
+
// DB row flipped, not just response-shaped.
|
|
3712
|
+
const row = getClient(db, reg.client.clientId);
|
|
3713
|
+
expect(row?.status).toBe("approved");
|
|
3714
|
+
} finally {
|
|
3715
|
+
cleanup();
|
|
3716
|
+
}
|
|
3717
|
+
});
|
|
3718
|
+
|
|
3719
|
+
test("approve POST: invalid CSRF → 403", async () => {
|
|
3720
|
+
const { db, cleanup } = await makeDb();
|
|
3721
|
+
try {
|
|
3722
|
+
const user = await createUser(db, "owner", "pw");
|
|
3723
|
+
const session = createSession(db, { userId: user.id });
|
|
3724
|
+
const reg = registerClient(db, {
|
|
3725
|
+
redirectUris: ["https://app.example/cb"],
|
|
3726
|
+
status: "pending",
|
|
3727
|
+
});
|
|
3728
|
+
const form = new URLSearchParams({
|
|
3729
|
+
__csrf: "wrong-token",
|
|
3730
|
+
client_id: reg.client.clientId,
|
|
3731
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3732
|
+
});
|
|
3733
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3734
|
+
method: "POST",
|
|
3735
|
+
body: form,
|
|
3736
|
+
headers: {
|
|
3737
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3738
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3739
|
+
origin: ISSUER,
|
|
3740
|
+
},
|
|
3741
|
+
});
|
|
3742
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3743
|
+
expect(res.status).toBe(403);
|
|
3744
|
+
// Row stays pending.
|
|
3745
|
+
const row = getClient(db, reg.client.clientId);
|
|
3746
|
+
expect(row?.status).toBe("pending");
|
|
3747
|
+
} finally {
|
|
3748
|
+
cleanup();
|
|
3749
|
+
}
|
|
3750
|
+
});
|
|
3751
|
+
|
|
3752
|
+
test("approve POST: no session cookie → 401", async () => {
|
|
3753
|
+
const { db, cleanup } = await makeDb();
|
|
3754
|
+
try {
|
|
3755
|
+
const reg = registerClient(db, {
|
|
3756
|
+
redirectUris: ["https://app.example/cb"],
|
|
3757
|
+
status: "pending",
|
|
3758
|
+
});
|
|
3759
|
+
const form = new URLSearchParams({
|
|
3760
|
+
__csrf: TEST_CSRF,
|
|
3761
|
+
client_id: reg.client.clientId,
|
|
3762
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3763
|
+
});
|
|
3764
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3765
|
+
method: "POST",
|
|
3766
|
+
body: form,
|
|
3767
|
+
headers: {
|
|
3768
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3769
|
+
cookie: CSRF_COOKIE,
|
|
3770
|
+
origin: ISSUER,
|
|
3771
|
+
},
|
|
3772
|
+
});
|
|
3773
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3774
|
+
expect(res.status).toBe(401);
|
|
3775
|
+
// Row stays pending.
|
|
3776
|
+
const row = getClient(db, reg.client.clientId);
|
|
3777
|
+
expect(row?.status).toBe("pending");
|
|
3778
|
+
} finally {
|
|
3779
|
+
cleanup();
|
|
3780
|
+
}
|
|
3781
|
+
});
|
|
3782
|
+
|
|
3783
|
+
test("approve POST: cross-origin Origin → 403 (CSRF defense)", async () => {
|
|
3784
|
+
const { db, cleanup } = await makeDb();
|
|
3785
|
+
try {
|
|
3786
|
+
const user = await createUser(db, "owner", "pw");
|
|
3787
|
+
const session = createSession(db, { userId: user.id });
|
|
3788
|
+
const reg = registerClient(db, {
|
|
3789
|
+
redirectUris: ["https://app.example/cb"],
|
|
3790
|
+
status: "pending",
|
|
3791
|
+
});
|
|
3792
|
+
const form = new URLSearchParams({
|
|
3793
|
+
__csrf: TEST_CSRF,
|
|
3794
|
+
client_id: reg.client.clientId,
|
|
3795
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3796
|
+
});
|
|
3797
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3798
|
+
method: "POST",
|
|
3799
|
+
body: form,
|
|
3800
|
+
headers: {
|
|
3801
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3802
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3803
|
+
origin: "https://attacker.example",
|
|
3804
|
+
},
|
|
3805
|
+
});
|
|
3806
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3807
|
+
expect(res.status).toBe(403);
|
|
3808
|
+
// Row stays pending.
|
|
3809
|
+
const row = getClient(db, reg.client.clientId);
|
|
3810
|
+
expect(row?.status).toBe("pending");
|
|
3811
|
+
} finally {
|
|
3812
|
+
cleanup();
|
|
3813
|
+
}
|
|
3814
|
+
});
|
|
3815
|
+
|
|
3816
|
+
test("approve POST: Origin: 'null' (sandbox iframe / opaque origin) → 403", async () => {
|
|
3817
|
+
// Opaque-origin contexts (sandboxed iframes, some `data:` and `file:`
|
|
3818
|
+
// pages) send the literal string "null" as the Origin header. The DCR
|
|
3819
|
+
// /register path covers this; the inline-approve endpoint must reject it
|
|
3820
|
+
// too. isSameOriginRequest() handles this correctly because new URL("null")
|
|
3821
|
+
// throws → returns false; this test pins that contract.
|
|
3822
|
+
const { db, cleanup } = await makeDb();
|
|
3823
|
+
try {
|
|
3824
|
+
const user = await createUser(db, "owner", "pw");
|
|
3825
|
+
const session = createSession(db, { userId: user.id });
|
|
3826
|
+
const reg = registerClient(db, {
|
|
3827
|
+
redirectUris: ["https://app.example/cb"],
|
|
3828
|
+
status: "pending",
|
|
3829
|
+
});
|
|
3830
|
+
const form = new URLSearchParams({
|
|
3831
|
+
__csrf: TEST_CSRF,
|
|
3832
|
+
client_id: reg.client.clientId,
|
|
3833
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3834
|
+
});
|
|
3835
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3836
|
+
method: "POST",
|
|
3837
|
+
body: form,
|
|
3838
|
+
headers: {
|
|
3839
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3840
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3841
|
+
origin: "null",
|
|
3842
|
+
},
|
|
3843
|
+
});
|
|
3844
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3845
|
+
expect(res.status).toBe(403);
|
|
3846
|
+
// Row stays pending.
|
|
3847
|
+
const row = getClient(db, reg.client.clientId);
|
|
3848
|
+
expect(row?.status).toBe("pending");
|
|
3849
|
+
} finally {
|
|
3850
|
+
cleanup();
|
|
3851
|
+
}
|
|
3852
|
+
});
|
|
3853
|
+
|
|
3854
|
+
test("approve POST: idempotent on already-approved client (double-click / refresh)", async () => {
|
|
3855
|
+
// approveClient() short-circuits if the row is already approved
|
|
3856
|
+
// (clients.ts:153). A double-click or page refresh should not error —
|
|
3857
|
+
// the second POST also succeeds with a 302 to return_to and the row
|
|
3858
|
+
// stays approved. This pins idempotency end-to-end.
|
|
3859
|
+
const { db, cleanup } = await makeDb();
|
|
3860
|
+
try {
|
|
3861
|
+
const user = await createUser(db, "owner", "pw");
|
|
3862
|
+
const session = createSession(db, { userId: user.id });
|
|
3863
|
+
const reg = registerClient(db, {
|
|
3864
|
+
redirectUris: ["https://app.example/cb"],
|
|
3865
|
+
status: "pending",
|
|
3866
|
+
});
|
|
3867
|
+
const returnTo = `/oauth/authorize?client_id=${reg.client.clientId}&state=rt-208`;
|
|
3868
|
+
const buildReq = () => {
|
|
3869
|
+
const form = new URLSearchParams({
|
|
3870
|
+
__csrf: TEST_CSRF,
|
|
3871
|
+
client_id: reg.client.clientId,
|
|
3872
|
+
return_to: returnTo,
|
|
3873
|
+
});
|
|
3874
|
+
return new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3875
|
+
method: "POST",
|
|
3876
|
+
body: form,
|
|
3877
|
+
headers: {
|
|
3878
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3879
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3880
|
+
origin: ISSUER,
|
|
3881
|
+
},
|
|
3882
|
+
});
|
|
3883
|
+
};
|
|
3884
|
+
|
|
3885
|
+
// First POST: pending → approved.
|
|
3886
|
+
const first = await handleApproveClientPost(db, buildReq(), { issuer: ISSUER });
|
|
3887
|
+
expect(first.status).toBe(302);
|
|
3888
|
+
expect(first.headers.get("location")).toBe(returnTo);
|
|
3889
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
|
|
3890
|
+
|
|
3891
|
+
// Second POST (same client_id, same form): also succeeds, no error.
|
|
3892
|
+
const second = await handleApproveClientPost(db, buildReq(), { issuer: ISSUER });
|
|
3893
|
+
expect(second.status).toBe(302);
|
|
3894
|
+
expect(second.headers.get("location")).toBe(returnTo);
|
|
3895
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
|
|
3896
|
+
} finally {
|
|
3897
|
+
cleanup();
|
|
3898
|
+
}
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
test("approve POST: unknown client_id → 404", async () => {
|
|
3902
|
+
const { db, cleanup } = await makeDb();
|
|
3903
|
+
try {
|
|
3904
|
+
const user = await createUser(db, "owner", "pw");
|
|
3905
|
+
const session = createSession(db, { userId: user.id });
|
|
3906
|
+
const form = new URLSearchParams({
|
|
3907
|
+
__csrf: TEST_CSRF,
|
|
3908
|
+
client_id: "no-such-client-id",
|
|
3909
|
+
return_to: "/oauth/authorize?client_id=no-such-client-id",
|
|
3910
|
+
});
|
|
3911
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3912
|
+
method: "POST",
|
|
3913
|
+
body: form,
|
|
3914
|
+
headers: {
|
|
3915
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3916
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3917
|
+
origin: ISSUER,
|
|
3918
|
+
},
|
|
3919
|
+
});
|
|
3920
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3921
|
+
expect(res.status).toBe(404);
|
|
3922
|
+
} finally {
|
|
3923
|
+
cleanup();
|
|
3924
|
+
}
|
|
3925
|
+
});
|
|
3926
|
+
|
|
3927
|
+
test("approve POST: malicious return_to (absolute URL) → 400 (open-redirect defense)", async () => {
|
|
3928
|
+
// The form must always supply a hub-relative /oauth/authorize?... URL.
|
|
3929
|
+
// Anything else is either an open-redirect attempt or a misuse — refuse
|
|
3930
|
+
// to follow it. return_to is validated BEFORE the DB mutation, so a bad
|
|
3931
|
+
// value also leaves the client row at status=pending.
|
|
3932
|
+
const { db, cleanup } = await makeDb();
|
|
3933
|
+
try {
|
|
3934
|
+
const user = await createUser(db, "owner", "pw");
|
|
3935
|
+
const session = createSession(db, { userId: user.id });
|
|
3936
|
+
const reg = registerClient(db, {
|
|
3937
|
+
redirectUris: ["https://app.example/cb"],
|
|
3938
|
+
status: "pending",
|
|
3939
|
+
});
|
|
3940
|
+
const form = new URLSearchParams({
|
|
3941
|
+
__csrf: TEST_CSRF,
|
|
3942
|
+
client_id: reg.client.clientId,
|
|
3943
|
+
return_to: "https://evil.example/steal",
|
|
3944
|
+
});
|
|
3945
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3946
|
+
method: "POST",
|
|
3947
|
+
body: form,
|
|
3948
|
+
headers: {
|
|
3949
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3950
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3951
|
+
origin: ISSUER,
|
|
3952
|
+
},
|
|
3953
|
+
});
|
|
3954
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3955
|
+
expect(res.status).toBe(400);
|
|
3956
|
+
// No redirect to evil.example.
|
|
3957
|
+
expect(res.headers.get("location")).toBeNull();
|
|
3958
|
+
// DB row remains pending — validate-before-mutate ordering.
|
|
3959
|
+
const row = getClient(db, reg.client.clientId);
|
|
3960
|
+
expect(row?.status).toBe("pending");
|
|
3961
|
+
} finally {
|
|
3962
|
+
cleanup();
|
|
3963
|
+
}
|
|
3964
|
+
});
|
|
3965
|
+
|
|
3966
|
+
test("approve POST: scheme-relative return_to (//evil.example) → 400", async () => {
|
|
3967
|
+
// `//evil.example/foo` is a scheme-relative URL — browsers resolve it
|
|
3968
|
+
// against the current scheme to land at https://evil.example/foo.
|
|
3969
|
+
// Reject anything that doesn't start with a single `/`.
|
|
3970
|
+
const { db, cleanup } = await makeDb();
|
|
3971
|
+
try {
|
|
3972
|
+
const user = await createUser(db, "owner", "pw");
|
|
3973
|
+
const session = createSession(db, { userId: user.id });
|
|
3974
|
+
const reg = registerClient(db, {
|
|
3975
|
+
redirectUris: ["https://app.example/cb"],
|
|
3976
|
+
status: "pending",
|
|
3977
|
+
});
|
|
3978
|
+
const form = new URLSearchParams({
|
|
3979
|
+
__csrf: TEST_CSRF,
|
|
3980
|
+
client_id: reg.client.clientId,
|
|
3981
|
+
return_to: "//evil.example/foo",
|
|
3982
|
+
});
|
|
3983
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3984
|
+
method: "POST",
|
|
3985
|
+
body: form,
|
|
3986
|
+
headers: {
|
|
3987
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3988
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3989
|
+
origin: ISSUER,
|
|
3990
|
+
},
|
|
3991
|
+
});
|
|
3992
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3993
|
+
expect(res.status).toBe(400);
|
|
3994
|
+
// DB row remains pending — validate-before-mutate ordering.
|
|
3995
|
+
const row = getClient(db, reg.client.clientId);
|
|
3996
|
+
expect(row?.status).toBe("pending");
|
|
3997
|
+
} finally {
|
|
3998
|
+
cleanup();
|
|
3999
|
+
}
|
|
4000
|
+
});
|
|
4001
|
+
|
|
4002
|
+
test("approve POST: return_to off /oauth/authorize path (e.g. /admin/config) → 400", async () => {
|
|
4003
|
+
// Even hub-relative paths must target the authorize endpoint. A
|
|
4004
|
+
// hand-crafted form trying to redirect to /admin/config or any other
|
|
4005
|
+
// hub surface is misuse — this endpoint exists to re-enter the OAuth
|
|
4006
|
+
// flow, nothing else.
|
|
4007
|
+
const { db, cleanup } = await makeDb();
|
|
4008
|
+
try {
|
|
4009
|
+
const user = await createUser(db, "owner", "pw");
|
|
4010
|
+
const session = createSession(db, { userId: user.id });
|
|
4011
|
+
const reg = registerClient(db, {
|
|
4012
|
+
redirectUris: ["https://app.example/cb"],
|
|
4013
|
+
status: "pending",
|
|
4014
|
+
});
|
|
4015
|
+
const form = new URLSearchParams({
|
|
4016
|
+
__csrf: TEST_CSRF,
|
|
4017
|
+
client_id: reg.client.clientId,
|
|
4018
|
+
return_to: "/admin/config",
|
|
4019
|
+
});
|
|
4020
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
4021
|
+
method: "POST",
|
|
4022
|
+
body: form,
|
|
4023
|
+
headers: {
|
|
4024
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4025
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
4026
|
+
origin: ISSUER,
|
|
4027
|
+
},
|
|
4028
|
+
});
|
|
4029
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
4030
|
+
expect(res.status).toBe(400);
|
|
4031
|
+
// DB row remains pending — validate-before-mutate ordering.
|
|
4032
|
+
const row = getClient(db, reg.client.clientId);
|
|
4033
|
+
expect(row?.status).toBe("pending");
|
|
4034
|
+
} finally {
|
|
4035
|
+
cleanup();
|
|
4036
|
+
}
|
|
4037
|
+
});
|
|
4038
|
+
|
|
4039
|
+
test("end-to-end: GET (pending) → POST approve → GET (now approved) renders consent", async () => {
|
|
4040
|
+
// The full redirect chain. Sessions and CSRF carry across all three
|
|
4041
|
+
// requests in the same cookie. The final GET sees status=approved and
|
|
4042
|
+
// renders the consent screen.
|
|
4043
|
+
const { db, cleanup } = await makeDb();
|
|
4044
|
+
try {
|
|
4045
|
+
const user = await createUser(db, "owner", "pw");
|
|
4046
|
+
const session = createSession(db, { userId: user.id });
|
|
4047
|
+
const reg = registerClient(db, {
|
|
4048
|
+
redirectUris: ["https://app.example/cb"],
|
|
4049
|
+
clientName: "RoundTrip",
|
|
4050
|
+
status: "pending",
|
|
4051
|
+
});
|
|
4052
|
+
const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`;
|
|
4053
|
+
const authorizeHref = pendingAuthorizeUrl(reg.client.clientId);
|
|
4054
|
+
|
|
4055
|
+
// Step 1: GET /oauth/authorize on a pending client renders the approve form.
|
|
4056
|
+
const getRes = handleAuthorizeGet(
|
|
4057
|
+
db,
|
|
4058
|
+
new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
|
|
4059
|
+
{ issuer: ISSUER },
|
|
4060
|
+
);
|
|
4061
|
+
expect(getRes.status).toBe(403);
|
|
4062
|
+
const getHtml = await getRes.text();
|
|
4063
|
+
expect(getHtml).toContain('action="/oauth/authorize/approve"');
|
|
4064
|
+
|
|
4065
|
+
// Pull the return_to value the form would submit. It's the path+search
|
|
4066
|
+
// of the authorize URL.
|
|
4067
|
+
const authorizeUrlParsed = new URL(authorizeHref);
|
|
4068
|
+
const returnTo = `${authorizeUrlParsed.pathname}${authorizeUrlParsed.search}`;
|
|
4069
|
+
|
|
4070
|
+
// Step 2: POST the approve form.
|
|
4071
|
+
const postForm = new URLSearchParams({
|
|
4072
|
+
__csrf: TEST_CSRF,
|
|
4073
|
+
client_id: reg.client.clientId,
|
|
4074
|
+
return_to: returnTo,
|
|
4075
|
+
});
|
|
4076
|
+
const postRes = await handleApproveClientPost(
|
|
4077
|
+
db,
|
|
4078
|
+
new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
4079
|
+
method: "POST",
|
|
4080
|
+
body: postForm,
|
|
4081
|
+
headers: {
|
|
4082
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4083
|
+
cookie,
|
|
4084
|
+
origin: ISSUER,
|
|
4085
|
+
},
|
|
4086
|
+
}),
|
|
4087
|
+
{ issuer: ISSUER },
|
|
4088
|
+
);
|
|
4089
|
+
expect(postRes.status).toBe(302);
|
|
4090
|
+
expect(postRes.headers.get("location")).toBe(returnTo);
|
|
4091
|
+
|
|
4092
|
+
// Step 3: GET /oauth/authorize again — now the client is approved, so
|
|
4093
|
+
// the operator lands on the consent screen.
|
|
4094
|
+
const reentryRes = handleAuthorizeGet(
|
|
4095
|
+
db,
|
|
4096
|
+
new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
|
|
4097
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
4098
|
+
);
|
|
4099
|
+
expect(reentryRes.status).toBe(200);
|
|
4100
|
+
const consentHtml = await reentryRes.text();
|
|
4101
|
+
// Consent screen markers (renderConsent uses these).
|
|
4102
|
+
expect(consentHtml).toContain('name="__action" value="consent"');
|
|
4103
|
+
expect(consentHtml).toContain("Authorize");
|
|
4104
|
+
expect(consentHtml).toContain("RoundTrip");
|
|
4105
|
+
} finally {
|
|
4106
|
+
cleanup();
|
|
4107
|
+
}
|
|
4108
|
+
});
|
|
4109
|
+
});
|