@openparachute/hub 0.5.1 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -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 +1227 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- 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 +367 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- 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 +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +266 -32
- package/src/module-manifest.ts +19 -0
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +66 -13
- package/src/services-manifest.ts +83 -3
- package/src/sessions.ts +19 -0
|
@@ -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
9
|
import { 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,
|
|
@@ -2244,6 +2245,226 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2244
2245
|
});
|
|
2245
2246
|
});
|
|
2246
2247
|
|
|
2248
|
+
// closes #199 — DCR auto-approve for the operator's own browser. A valid
|
|
2249
|
+
// `parachute_hub_session` cookie indicates the operator is authenticated as
|
|
2250
|
+
// themselves; combined with a same-origin Origin/Referer (the CSRF gate)
|
|
2251
|
+
// that's enough to skip the manual `parachute auth approve-client` step.
|
|
2252
|
+
describe("DCR auto-approve via session cookie (#199)", () => {
|
|
2253
|
+
const SESSION_COOKIE_TTL_S = Math.floor(SESSION_TTL_MS / 1000);
|
|
2254
|
+
|
|
2255
|
+
function registerRequest(
|
|
2256
|
+
headers: Record<string, string>,
|
|
2257
|
+
bodyExtra: Record<string, unknown> = {},
|
|
2258
|
+
): Request {
|
|
2259
|
+
return new Request(`${ISSUER}/oauth/register`, {
|
|
2260
|
+
method: "POST",
|
|
2261
|
+
body: JSON.stringify({
|
|
2262
|
+
redirect_uris: ["https://app.example/cb"],
|
|
2263
|
+
...bodyExtra,
|
|
2264
|
+
}),
|
|
2265
|
+
headers: { "content-type": "application/json", ...headers },
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
test("valid session cookie + matching Origin → status approved (response + DB)", async () => {
|
|
2270
|
+
const { db, cleanup } = await makeDb();
|
|
2271
|
+
try {
|
|
2272
|
+
const user = await createUser(db, "owner", "pw");
|
|
2273
|
+
const session = createSession(db, { userId: user.id });
|
|
2274
|
+
const req = registerRequest({
|
|
2275
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2276
|
+
origin: ISSUER,
|
|
2277
|
+
});
|
|
2278
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2279
|
+
expect(res.status).toBe(201);
|
|
2280
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2281
|
+
expect(body.status).toBe("approved");
|
|
2282
|
+
// Persisted, not just response-shaped.
|
|
2283
|
+
const row = getClient(db, body.client_id as string);
|
|
2284
|
+
expect(row?.status).toBe("approved");
|
|
2285
|
+
} finally {
|
|
2286
|
+
cleanup();
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
test("valid session cookie + cross-origin Origin → status pending (CSRF defense)", async () => {
|
|
2291
|
+
const { db, cleanup } = await makeDb();
|
|
2292
|
+
try {
|
|
2293
|
+
const user = await createUser(db, "owner", "pw");
|
|
2294
|
+
const session = createSession(db, { userId: user.id });
|
|
2295
|
+
const req = registerRequest({
|
|
2296
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2297
|
+
origin: "https://attacker.example",
|
|
2298
|
+
});
|
|
2299
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2300
|
+
expect(res.status).toBe(201);
|
|
2301
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2302
|
+
expect(body.status).toBe("pending");
|
|
2303
|
+
} finally {
|
|
2304
|
+
cleanup();
|
|
2305
|
+
}
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
test("valid session cookie + Origin: 'null' (opaque/sandbox iframe) → pending", async () => {
|
|
2309
|
+
// Sandbox iframes (`<iframe sandbox>` without `allow-same-origin`),
|
|
2310
|
+
// `data:`/`file:` documents, and some privacy contexts send the literal
|
|
2311
|
+
// string `Origin: null` rather than omitting the header. `new URL("null")`
|
|
2312
|
+
// throws → originMatchesIssuer's try/catch returns false → DCR stays
|
|
2313
|
+
// pending. This test pins that invariant: an opaque-origin caller does
|
|
2314
|
+
// NOT ride the cookie path even with a valid session, because we can't
|
|
2315
|
+
// prove the request came from the issuer's own origin.
|
|
2316
|
+
const { db, cleanup } = await makeDb();
|
|
2317
|
+
try {
|
|
2318
|
+
const user = await createUser(db, "owner", "pw");
|
|
2319
|
+
const session = createSession(db, { userId: user.id });
|
|
2320
|
+
const req = registerRequest({
|
|
2321
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2322
|
+
origin: "null",
|
|
2323
|
+
});
|
|
2324
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2325
|
+
expect(res.status).toBe(201);
|
|
2326
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2327
|
+
expect(body.status).toBe("pending");
|
|
2328
|
+
// Persisted, not just response-shaped.
|
|
2329
|
+
const row = getClient(db, body.client_id as string);
|
|
2330
|
+
expect(row?.status).toBe("pending");
|
|
2331
|
+
} finally {
|
|
2332
|
+
cleanup();
|
|
2333
|
+
}
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
test("valid session cookie + Origin matching exact origin (port included) → approved", async () => {
|
|
2337
|
+
// URL.origin includes scheme + host + port, so a port-mismatched Origin
|
|
2338
|
+
// must NOT match. https://hub.example:8443 ≠ https://hub.example.
|
|
2339
|
+
const { db, cleanup } = await makeDb();
|
|
2340
|
+
try {
|
|
2341
|
+
const issuer = "https://hub.example:8443";
|
|
2342
|
+
const user = await createUser(db, "owner", "pw");
|
|
2343
|
+
const session = createSession(db, { userId: user.id });
|
|
2344
|
+
|
|
2345
|
+
// Exact match (scheme + host + port) → approved.
|
|
2346
|
+
const okReq = registerRequest({
|
|
2347
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2348
|
+
origin: "https://hub.example:8443",
|
|
2349
|
+
});
|
|
2350
|
+
const okRes = await handleRegister(db, okReq, { issuer });
|
|
2351
|
+
expect(((await okRes.json()) as Record<string, unknown>).status).toBe("approved");
|
|
2352
|
+
|
|
2353
|
+
// Port-mismatched Origin (default 443 vs 8443) → pending.
|
|
2354
|
+
const badReq = registerRequest({
|
|
2355
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2356
|
+
origin: "https://hub.example",
|
|
2357
|
+
});
|
|
2358
|
+
const badRes = await handleRegister(db, badReq, { issuer });
|
|
2359
|
+
expect(((await badRes.json()) as Record<string, unknown>).status).toBe("pending");
|
|
2360
|
+
} finally {
|
|
2361
|
+
cleanup();
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
test("valid session cookie + matching Referer (no Origin) → approved (Referer fallback)", async () => {
|
|
2366
|
+
const { db, cleanup } = await makeDb();
|
|
2367
|
+
try {
|
|
2368
|
+
const user = await createUser(db, "owner", "pw");
|
|
2369
|
+
const session = createSession(db, { userId: user.id });
|
|
2370
|
+
const req = registerRequest({
|
|
2371
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2372
|
+
referer: `${ISSUER}/notes/`,
|
|
2373
|
+
});
|
|
2374
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2375
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2376
|
+
expect(body.status).toBe("approved");
|
|
2377
|
+
} finally {
|
|
2378
|
+
cleanup();
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
|
|
2382
|
+
test("valid session cookie + no Origin AND no Referer → pending (deny without proof of origin)", async () => {
|
|
2383
|
+
const { db, cleanup } = await makeDb();
|
|
2384
|
+
try {
|
|
2385
|
+
const user = await createUser(db, "owner", "pw");
|
|
2386
|
+
const session = createSession(db, { userId: user.id });
|
|
2387
|
+
const req = registerRequest({
|
|
2388
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2389
|
+
});
|
|
2390
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2391
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2392
|
+
expect(body.status).toBe("pending");
|
|
2393
|
+
} finally {
|
|
2394
|
+
cleanup();
|
|
2395
|
+
}
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
test("expired session cookie + matching Origin → pending (expiry check)", async () => {
|
|
2399
|
+
const { db, cleanup } = await makeDb();
|
|
2400
|
+
try {
|
|
2401
|
+
const user = await createUser(db, "owner", "pw");
|
|
2402
|
+
// Session created in the "now()" frame, but handleRegister sees a much
|
|
2403
|
+
// later clock — findSession (via findActiveSession) treats it as expired.
|
|
2404
|
+
const session = createSession(db, { userId: user.id });
|
|
2405
|
+
const future = new Date(Date.now() + SESSION_TTL_MS + 60_000);
|
|
2406
|
+
const req = registerRequest({
|
|
2407
|
+
cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
|
|
2408
|
+
origin: ISSUER,
|
|
2409
|
+
});
|
|
2410
|
+
const res = await handleRegister(db, req, { issuer: ISSUER, now: () => future });
|
|
2411
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2412
|
+
expect(body.status).toBe("pending");
|
|
2413
|
+
} finally {
|
|
2414
|
+
cleanup();
|
|
2415
|
+
}
|
|
2416
|
+
});
|
|
2417
|
+
|
|
2418
|
+
test("invalid session cookie (id not in DB) + matching Origin → pending", async () => {
|
|
2419
|
+
const { db, cleanup } = await makeDb();
|
|
2420
|
+
try {
|
|
2421
|
+
const req = registerRequest({
|
|
2422
|
+
cookie: buildSessionCookie("not-a-real-session-id", SESSION_COOKIE_TTL_S),
|
|
2423
|
+
origin: ISSUER,
|
|
2424
|
+
});
|
|
2425
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2426
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2427
|
+
expect(body.status).toBe("pending");
|
|
2428
|
+
} finally {
|
|
2429
|
+
cleanup();
|
|
2430
|
+
}
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
test("no cookie at all → pending (current public-DCR behavior)", async () => {
|
|
2434
|
+
const { db, cleanup } = await makeDb();
|
|
2435
|
+
try {
|
|
2436
|
+
const req = registerRequest({ origin: ISSUER });
|
|
2437
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2438
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2439
|
+
expect(body.status).toBe("pending");
|
|
2440
|
+
} finally {
|
|
2441
|
+
cleanup();
|
|
2442
|
+
}
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
test("operator-bearer header (existing path) still → approved (regression)", async () => {
|
|
2446
|
+
// The new cookie-based path must not regress the bearer-based path that
|
|
2447
|
+
// first-party install (#74) depends on. Same setup as the #74 test, no
|
|
2448
|
+
// cookie supplied — bearer alone must continue to land approved.
|
|
2449
|
+
const { db, cleanup } = await makeDb();
|
|
2450
|
+
try {
|
|
2451
|
+
const { rotateSigningKey } = await import("../signing-keys.ts");
|
|
2452
|
+
const { mintOperatorToken } = await import("../operator-token.ts");
|
|
2453
|
+
rotateSigningKey(db);
|
|
2454
|
+
const user = await createUser(db, "owner", "pw");
|
|
2455
|
+
const operator = await mintOperatorToken(db, user.id, { issuer: ISSUER });
|
|
2456
|
+
|
|
2457
|
+
const req = registerRequest({ authorization: `Bearer ${operator.token}` });
|
|
2458
|
+
const res = await handleRegister(db, req, { issuer: ISSUER });
|
|
2459
|
+
expect(res.status).toBe(201);
|
|
2460
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
2461
|
+
expect(body.status).toBe("approved");
|
|
2462
|
+
} finally {
|
|
2463
|
+
cleanup();
|
|
2464
|
+
}
|
|
2465
|
+
});
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2247
2468
|
// closes #73 — RFC 6749 §6 refresh-token rotation, RFC 6819 §5.2.2.3 replay
|
|
2248
2469
|
// detection (family-wide revocation), RFC 7009 token revocation.
|
|
2249
2470
|
describe("refresh-token rotation + /oauth/revoke (#73)", () => {
|
|
@@ -3110,3 +3331,518 @@ describe("handleAuthorizeGet — skip consent when scope already granted (#75)",
|
|
|
3110
3331
|
}
|
|
3111
3332
|
});
|
|
3112
3333
|
});
|
|
3334
|
+
|
|
3335
|
+
// closes #208 — inline "Approve this app" form on the pending-client page
|
|
3336
|
+
// (cross-origin SPA recovery). Same security model as #199/#200 DCR
|
|
3337
|
+
// auto-approve: valid session + matching Origin = trusted operator. The
|
|
3338
|
+
// CSRF token is the third belt — a cross-origin POST with a leaked session
|
|
3339
|
+
// cookie still fails because the rendered token won't match.
|
|
3340
|
+
describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
3341
|
+
const SESSION_COOKIE_TTL_S = Math.floor(SESSION_TTL_MS / 1000);
|
|
3342
|
+
|
|
3343
|
+
function pendingAuthorizeUrl(clientId: string): string {
|
|
3344
|
+
const { challenge } = makePkce();
|
|
3345
|
+
return authorizeUrl({
|
|
3346
|
+
client_id: clientId,
|
|
3347
|
+
redirect_uri: "https://app.example/cb",
|
|
3348
|
+
response_type: "code",
|
|
3349
|
+
code_challenge: challenge,
|
|
3350
|
+
code_challenge_method: "S256",
|
|
3351
|
+
scope: "vault:read",
|
|
3352
|
+
state: "rt-208",
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
test("session absent → page renders WITHOUT approve form (CLI-only fallback)", async () => {
|
|
3357
|
+
// Regression: pre-#208 behavior preserved when no session cookie is
|
|
3358
|
+
// present. The CLI-fallback message must still be visible so an operator
|
|
3359
|
+
// who arrived from a fresh browser knows what to do.
|
|
3360
|
+
const { db, cleanup } = await makeDb();
|
|
3361
|
+
try {
|
|
3362
|
+
const reg = registerClient(db, {
|
|
3363
|
+
redirectUris: ["https://app.example/cb"],
|
|
3364
|
+
clientName: "MyApp",
|
|
3365
|
+
status: "pending",
|
|
3366
|
+
});
|
|
3367
|
+
const req = new Request(pendingAuthorizeUrl(reg.client.clientId));
|
|
3368
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
3369
|
+
expect(res.status).toBe(403);
|
|
3370
|
+
const html = await res.text();
|
|
3371
|
+
expect(html).toContain("App not yet approved");
|
|
3372
|
+
// CLI-fallback message present — the only way to recover without a session.
|
|
3373
|
+
expect(html).toContain("approve-client");
|
|
3374
|
+
// No form element pointing at the approve endpoint.
|
|
3375
|
+
expect(html).not.toContain('action="/oauth/authorize/approve"');
|
|
3376
|
+
} finally {
|
|
3377
|
+
cleanup();
|
|
3378
|
+
}
|
|
3379
|
+
});
|
|
3380
|
+
|
|
3381
|
+
test("session valid + matching Origin → page renders WITH approve form + CSRF token", async () => {
|
|
3382
|
+
const { db, cleanup } = await makeDb();
|
|
3383
|
+
try {
|
|
3384
|
+
const user = await createUser(db, "owner", "pw");
|
|
3385
|
+
const session = createSession(db, { userId: user.id });
|
|
3386
|
+
const reg = registerClient(db, {
|
|
3387
|
+
redirectUris: ["https://app.example/cb"],
|
|
3388
|
+
clientName: "MyApp",
|
|
3389
|
+
status: "pending",
|
|
3390
|
+
});
|
|
3391
|
+
const req = new Request(pendingAuthorizeUrl(reg.client.clientId), {
|
|
3392
|
+
headers: {
|
|
3393
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3394
|
+
origin: ISSUER,
|
|
3395
|
+
},
|
|
3396
|
+
});
|
|
3397
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
3398
|
+
expect(res.status).toBe(403);
|
|
3399
|
+
const html = await res.text();
|
|
3400
|
+
expect(html).toContain("App not yet approved");
|
|
3401
|
+
// The form posts to the approve endpoint
|
|
3402
|
+
expect(html).toContain('action="/oauth/authorize/approve"');
|
|
3403
|
+
expect(html).toContain('name="client_id"');
|
|
3404
|
+
expect(html).toContain(`value="${reg.client.clientId}"`);
|
|
3405
|
+
// CSRF token present in the form
|
|
3406
|
+
expect(html).toContain(`value="${TEST_CSRF}"`);
|
|
3407
|
+
// return_to carries the original authorize URL so the post-approve
|
|
3408
|
+
// redirect lands the operator back on the same flow.
|
|
3409
|
+
expect(html).toContain('name="return_to"');
|
|
3410
|
+
expect(html).toContain("/oauth/authorize?");
|
|
3411
|
+
expect(html).toContain("rt-208"); // state echoed via return_to URL
|
|
3412
|
+
// Display fields present so operator can verify what they're approving.
|
|
3413
|
+
expect(html).toContain("MyApp");
|
|
3414
|
+
expect(html).toContain(reg.client.clientId);
|
|
3415
|
+
expect(html).toContain("https://app.example/cb");
|
|
3416
|
+
// CLI fallback still visible.
|
|
3417
|
+
expect(html).toContain("approve-client");
|
|
3418
|
+
} finally {
|
|
3419
|
+
cleanup();
|
|
3420
|
+
}
|
|
3421
|
+
});
|
|
3422
|
+
|
|
3423
|
+
test("approve POST happy path: CSRF + session + matching Origin → DB flips approved + 302 to authorize URL", async () => {
|
|
3424
|
+
const { db, cleanup } = await makeDb();
|
|
3425
|
+
try {
|
|
3426
|
+
const user = await createUser(db, "owner", "pw");
|
|
3427
|
+
const session = createSession(db, { userId: user.id });
|
|
3428
|
+
const reg = registerClient(db, {
|
|
3429
|
+
redirectUris: ["https://app.example/cb"],
|
|
3430
|
+
status: "pending",
|
|
3431
|
+
});
|
|
3432
|
+
const returnTo = `/oauth/authorize?client_id=${reg.client.clientId}&state=rt-208`;
|
|
3433
|
+
const form = new URLSearchParams({
|
|
3434
|
+
__csrf: TEST_CSRF,
|
|
3435
|
+
client_id: reg.client.clientId,
|
|
3436
|
+
return_to: returnTo,
|
|
3437
|
+
});
|
|
3438
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3439
|
+
method: "POST",
|
|
3440
|
+
body: form,
|
|
3441
|
+
headers: {
|
|
3442
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3443
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3444
|
+
origin: ISSUER,
|
|
3445
|
+
},
|
|
3446
|
+
});
|
|
3447
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3448
|
+
expect(res.status).toBe(302);
|
|
3449
|
+
expect(res.headers.get("location")).toBe(returnTo);
|
|
3450
|
+
// DB row flipped, not just response-shaped.
|
|
3451
|
+
const row = getClient(db, reg.client.clientId);
|
|
3452
|
+
expect(row?.status).toBe("approved");
|
|
3453
|
+
} finally {
|
|
3454
|
+
cleanup();
|
|
3455
|
+
}
|
|
3456
|
+
});
|
|
3457
|
+
|
|
3458
|
+
test("approve POST: invalid CSRF → 403", async () => {
|
|
3459
|
+
const { db, cleanup } = await makeDb();
|
|
3460
|
+
try {
|
|
3461
|
+
const user = await createUser(db, "owner", "pw");
|
|
3462
|
+
const session = createSession(db, { userId: user.id });
|
|
3463
|
+
const reg = registerClient(db, {
|
|
3464
|
+
redirectUris: ["https://app.example/cb"],
|
|
3465
|
+
status: "pending",
|
|
3466
|
+
});
|
|
3467
|
+
const form = new URLSearchParams({
|
|
3468
|
+
__csrf: "wrong-token",
|
|
3469
|
+
client_id: reg.client.clientId,
|
|
3470
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3471
|
+
});
|
|
3472
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3473
|
+
method: "POST",
|
|
3474
|
+
body: form,
|
|
3475
|
+
headers: {
|
|
3476
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3477
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3478
|
+
origin: ISSUER,
|
|
3479
|
+
},
|
|
3480
|
+
});
|
|
3481
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3482
|
+
expect(res.status).toBe(403);
|
|
3483
|
+
// Row stays pending.
|
|
3484
|
+
const row = getClient(db, reg.client.clientId);
|
|
3485
|
+
expect(row?.status).toBe("pending");
|
|
3486
|
+
} finally {
|
|
3487
|
+
cleanup();
|
|
3488
|
+
}
|
|
3489
|
+
});
|
|
3490
|
+
|
|
3491
|
+
test("approve POST: no session cookie → 401", async () => {
|
|
3492
|
+
const { db, cleanup } = await makeDb();
|
|
3493
|
+
try {
|
|
3494
|
+
const reg = registerClient(db, {
|
|
3495
|
+
redirectUris: ["https://app.example/cb"],
|
|
3496
|
+
status: "pending",
|
|
3497
|
+
});
|
|
3498
|
+
const form = new URLSearchParams({
|
|
3499
|
+
__csrf: TEST_CSRF,
|
|
3500
|
+
client_id: reg.client.clientId,
|
|
3501
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3502
|
+
});
|
|
3503
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3504
|
+
method: "POST",
|
|
3505
|
+
body: form,
|
|
3506
|
+
headers: {
|
|
3507
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3508
|
+
cookie: CSRF_COOKIE,
|
|
3509
|
+
origin: ISSUER,
|
|
3510
|
+
},
|
|
3511
|
+
});
|
|
3512
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3513
|
+
expect(res.status).toBe(401);
|
|
3514
|
+
// Row stays pending.
|
|
3515
|
+
const row = getClient(db, reg.client.clientId);
|
|
3516
|
+
expect(row?.status).toBe("pending");
|
|
3517
|
+
} finally {
|
|
3518
|
+
cleanup();
|
|
3519
|
+
}
|
|
3520
|
+
});
|
|
3521
|
+
|
|
3522
|
+
test("approve POST: cross-origin Origin → 403 (CSRF defense)", async () => {
|
|
3523
|
+
const { db, cleanup } = await makeDb();
|
|
3524
|
+
try {
|
|
3525
|
+
const user = await createUser(db, "owner", "pw");
|
|
3526
|
+
const session = createSession(db, { userId: user.id });
|
|
3527
|
+
const reg = registerClient(db, {
|
|
3528
|
+
redirectUris: ["https://app.example/cb"],
|
|
3529
|
+
status: "pending",
|
|
3530
|
+
});
|
|
3531
|
+
const form = new URLSearchParams({
|
|
3532
|
+
__csrf: TEST_CSRF,
|
|
3533
|
+
client_id: reg.client.clientId,
|
|
3534
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3535
|
+
});
|
|
3536
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3537
|
+
method: "POST",
|
|
3538
|
+
body: form,
|
|
3539
|
+
headers: {
|
|
3540
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3541
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3542
|
+
origin: "https://attacker.example",
|
|
3543
|
+
},
|
|
3544
|
+
});
|
|
3545
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3546
|
+
expect(res.status).toBe(403);
|
|
3547
|
+
// Row stays pending.
|
|
3548
|
+
const row = getClient(db, reg.client.clientId);
|
|
3549
|
+
expect(row?.status).toBe("pending");
|
|
3550
|
+
} finally {
|
|
3551
|
+
cleanup();
|
|
3552
|
+
}
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
test("approve POST: Origin: 'null' (sandbox iframe / opaque origin) → 403", async () => {
|
|
3556
|
+
// Opaque-origin contexts (sandboxed iframes, some `data:` and `file:`
|
|
3557
|
+
// pages) send the literal string "null" as the Origin header. The DCR
|
|
3558
|
+
// /register path covers this; the inline-approve endpoint must reject it
|
|
3559
|
+
// too. originMatchesIssuer() handles this correctly because new URL("null")
|
|
3560
|
+
// throws → returns false; this test pins that contract.
|
|
3561
|
+
const { db, cleanup } = await makeDb();
|
|
3562
|
+
try {
|
|
3563
|
+
const user = await createUser(db, "owner", "pw");
|
|
3564
|
+
const session = createSession(db, { userId: user.id });
|
|
3565
|
+
const reg = registerClient(db, {
|
|
3566
|
+
redirectUris: ["https://app.example/cb"],
|
|
3567
|
+
status: "pending",
|
|
3568
|
+
});
|
|
3569
|
+
const form = new URLSearchParams({
|
|
3570
|
+
__csrf: TEST_CSRF,
|
|
3571
|
+
client_id: reg.client.clientId,
|
|
3572
|
+
return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
|
|
3573
|
+
});
|
|
3574
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3575
|
+
method: "POST",
|
|
3576
|
+
body: form,
|
|
3577
|
+
headers: {
|
|
3578
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3579
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3580
|
+
origin: "null",
|
|
3581
|
+
},
|
|
3582
|
+
});
|
|
3583
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3584
|
+
expect(res.status).toBe(403);
|
|
3585
|
+
// Row stays pending.
|
|
3586
|
+
const row = getClient(db, reg.client.clientId);
|
|
3587
|
+
expect(row?.status).toBe("pending");
|
|
3588
|
+
} finally {
|
|
3589
|
+
cleanup();
|
|
3590
|
+
}
|
|
3591
|
+
});
|
|
3592
|
+
|
|
3593
|
+
test("approve POST: idempotent on already-approved client (double-click / refresh)", async () => {
|
|
3594
|
+
// approveClient() short-circuits if the row is already approved
|
|
3595
|
+
// (clients.ts:153). A double-click or page refresh should not error —
|
|
3596
|
+
// the second POST also succeeds with a 302 to return_to and the row
|
|
3597
|
+
// stays approved. This pins idempotency end-to-end.
|
|
3598
|
+
const { db, cleanup } = await makeDb();
|
|
3599
|
+
try {
|
|
3600
|
+
const user = await createUser(db, "owner", "pw");
|
|
3601
|
+
const session = createSession(db, { userId: user.id });
|
|
3602
|
+
const reg = registerClient(db, {
|
|
3603
|
+
redirectUris: ["https://app.example/cb"],
|
|
3604
|
+
status: "pending",
|
|
3605
|
+
});
|
|
3606
|
+
const returnTo = `/oauth/authorize?client_id=${reg.client.clientId}&state=rt-208`;
|
|
3607
|
+
const buildReq = () => {
|
|
3608
|
+
const form = new URLSearchParams({
|
|
3609
|
+
__csrf: TEST_CSRF,
|
|
3610
|
+
client_id: reg.client.clientId,
|
|
3611
|
+
return_to: returnTo,
|
|
3612
|
+
});
|
|
3613
|
+
return new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3614
|
+
method: "POST",
|
|
3615
|
+
body: form,
|
|
3616
|
+
headers: {
|
|
3617
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3618
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3619
|
+
origin: ISSUER,
|
|
3620
|
+
},
|
|
3621
|
+
});
|
|
3622
|
+
};
|
|
3623
|
+
|
|
3624
|
+
// First POST: pending → approved.
|
|
3625
|
+
const first = await handleApproveClientPost(db, buildReq(), { issuer: ISSUER });
|
|
3626
|
+
expect(first.status).toBe(302);
|
|
3627
|
+
expect(first.headers.get("location")).toBe(returnTo);
|
|
3628
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
|
|
3629
|
+
|
|
3630
|
+
// Second POST (same client_id, same form): also succeeds, no error.
|
|
3631
|
+
const second = await handleApproveClientPost(db, buildReq(), { issuer: ISSUER });
|
|
3632
|
+
expect(second.status).toBe(302);
|
|
3633
|
+
expect(second.headers.get("location")).toBe(returnTo);
|
|
3634
|
+
expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
|
|
3635
|
+
} finally {
|
|
3636
|
+
cleanup();
|
|
3637
|
+
}
|
|
3638
|
+
});
|
|
3639
|
+
|
|
3640
|
+
test("approve POST: unknown client_id → 404", async () => {
|
|
3641
|
+
const { db, cleanup } = await makeDb();
|
|
3642
|
+
try {
|
|
3643
|
+
const user = await createUser(db, "owner", "pw");
|
|
3644
|
+
const session = createSession(db, { userId: user.id });
|
|
3645
|
+
const form = new URLSearchParams({
|
|
3646
|
+
__csrf: TEST_CSRF,
|
|
3647
|
+
client_id: "no-such-client-id",
|
|
3648
|
+
return_to: "/oauth/authorize?client_id=no-such-client-id",
|
|
3649
|
+
});
|
|
3650
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3651
|
+
method: "POST",
|
|
3652
|
+
body: form,
|
|
3653
|
+
headers: {
|
|
3654
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3655
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3656
|
+
origin: ISSUER,
|
|
3657
|
+
},
|
|
3658
|
+
});
|
|
3659
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3660
|
+
expect(res.status).toBe(404);
|
|
3661
|
+
} finally {
|
|
3662
|
+
cleanup();
|
|
3663
|
+
}
|
|
3664
|
+
});
|
|
3665
|
+
|
|
3666
|
+
test("approve POST: malicious return_to (absolute URL) → 400 (open-redirect defense)", async () => {
|
|
3667
|
+
// The form must always supply a hub-relative /oauth/authorize?... URL.
|
|
3668
|
+
// Anything else is either an open-redirect attempt or a misuse — refuse
|
|
3669
|
+
// to follow it. return_to is validated BEFORE the DB mutation, so a bad
|
|
3670
|
+
// value also leaves the client row at status=pending.
|
|
3671
|
+
const { db, cleanup } = await makeDb();
|
|
3672
|
+
try {
|
|
3673
|
+
const user = await createUser(db, "owner", "pw");
|
|
3674
|
+
const session = createSession(db, { userId: user.id });
|
|
3675
|
+
const reg = registerClient(db, {
|
|
3676
|
+
redirectUris: ["https://app.example/cb"],
|
|
3677
|
+
status: "pending",
|
|
3678
|
+
});
|
|
3679
|
+
const form = new URLSearchParams({
|
|
3680
|
+
__csrf: TEST_CSRF,
|
|
3681
|
+
client_id: reg.client.clientId,
|
|
3682
|
+
return_to: "https://evil.example/steal",
|
|
3683
|
+
});
|
|
3684
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3685
|
+
method: "POST",
|
|
3686
|
+
body: form,
|
|
3687
|
+
headers: {
|
|
3688
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3689
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3690
|
+
origin: ISSUER,
|
|
3691
|
+
},
|
|
3692
|
+
});
|
|
3693
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3694
|
+
expect(res.status).toBe(400);
|
|
3695
|
+
// No redirect to evil.example.
|
|
3696
|
+
expect(res.headers.get("location")).toBeNull();
|
|
3697
|
+
// DB row remains pending — validate-before-mutate ordering.
|
|
3698
|
+
const row = getClient(db, reg.client.clientId);
|
|
3699
|
+
expect(row?.status).toBe("pending");
|
|
3700
|
+
} finally {
|
|
3701
|
+
cleanup();
|
|
3702
|
+
}
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
test("approve POST: scheme-relative return_to (//evil.example) → 400", async () => {
|
|
3706
|
+
// `//evil.example/foo` is a scheme-relative URL — browsers resolve it
|
|
3707
|
+
// against the current scheme to land at https://evil.example/foo.
|
|
3708
|
+
// Reject anything that doesn't start with a single `/`.
|
|
3709
|
+
const { db, cleanup } = await makeDb();
|
|
3710
|
+
try {
|
|
3711
|
+
const user = await createUser(db, "owner", "pw");
|
|
3712
|
+
const session = createSession(db, { userId: user.id });
|
|
3713
|
+
const reg = registerClient(db, {
|
|
3714
|
+
redirectUris: ["https://app.example/cb"],
|
|
3715
|
+
status: "pending",
|
|
3716
|
+
});
|
|
3717
|
+
const form = new URLSearchParams({
|
|
3718
|
+
__csrf: TEST_CSRF,
|
|
3719
|
+
client_id: reg.client.clientId,
|
|
3720
|
+
return_to: "//evil.example/foo",
|
|
3721
|
+
});
|
|
3722
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3723
|
+
method: "POST",
|
|
3724
|
+
body: form,
|
|
3725
|
+
headers: {
|
|
3726
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3727
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3728
|
+
origin: ISSUER,
|
|
3729
|
+
},
|
|
3730
|
+
});
|
|
3731
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3732
|
+
expect(res.status).toBe(400);
|
|
3733
|
+
// DB row remains pending — validate-before-mutate ordering.
|
|
3734
|
+
const row = getClient(db, reg.client.clientId);
|
|
3735
|
+
expect(row?.status).toBe("pending");
|
|
3736
|
+
} finally {
|
|
3737
|
+
cleanup();
|
|
3738
|
+
}
|
|
3739
|
+
});
|
|
3740
|
+
|
|
3741
|
+
test("approve POST: return_to off /oauth/authorize path (e.g. /admin/config) → 400", async () => {
|
|
3742
|
+
// Even hub-relative paths must target the authorize endpoint. A
|
|
3743
|
+
// hand-crafted form trying to redirect to /admin/config or any other
|
|
3744
|
+
// hub surface is misuse — this endpoint exists to re-enter the OAuth
|
|
3745
|
+
// flow, nothing else.
|
|
3746
|
+
const { db, cleanup } = await makeDb();
|
|
3747
|
+
try {
|
|
3748
|
+
const user = await createUser(db, "owner", "pw");
|
|
3749
|
+
const session = createSession(db, { userId: user.id });
|
|
3750
|
+
const reg = registerClient(db, {
|
|
3751
|
+
redirectUris: ["https://app.example/cb"],
|
|
3752
|
+
status: "pending",
|
|
3753
|
+
});
|
|
3754
|
+
const form = new URLSearchParams({
|
|
3755
|
+
__csrf: TEST_CSRF,
|
|
3756
|
+
client_id: reg.client.clientId,
|
|
3757
|
+
return_to: "/admin/config",
|
|
3758
|
+
});
|
|
3759
|
+
const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3760
|
+
method: "POST",
|
|
3761
|
+
body: form,
|
|
3762
|
+
headers: {
|
|
3763
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3764
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
|
|
3765
|
+
origin: ISSUER,
|
|
3766
|
+
},
|
|
3767
|
+
});
|
|
3768
|
+
const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
|
|
3769
|
+
expect(res.status).toBe(400);
|
|
3770
|
+
// DB row remains pending — validate-before-mutate ordering.
|
|
3771
|
+
const row = getClient(db, reg.client.clientId);
|
|
3772
|
+
expect(row?.status).toBe("pending");
|
|
3773
|
+
} finally {
|
|
3774
|
+
cleanup();
|
|
3775
|
+
}
|
|
3776
|
+
});
|
|
3777
|
+
|
|
3778
|
+
test("end-to-end: GET (pending) → POST approve → GET (now approved) renders consent", async () => {
|
|
3779
|
+
// The full redirect chain. Sessions and CSRF carry across all three
|
|
3780
|
+
// requests in the same cookie. The final GET sees status=approved and
|
|
3781
|
+
// renders the consent screen.
|
|
3782
|
+
const { db, cleanup } = await makeDb();
|
|
3783
|
+
try {
|
|
3784
|
+
const user = await createUser(db, "owner", "pw");
|
|
3785
|
+
const session = createSession(db, { userId: user.id });
|
|
3786
|
+
const reg = registerClient(db, {
|
|
3787
|
+
redirectUris: ["https://app.example/cb"],
|
|
3788
|
+
clientName: "RoundTrip",
|
|
3789
|
+
status: "pending",
|
|
3790
|
+
});
|
|
3791
|
+
const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`;
|
|
3792
|
+
const authorizeHref = pendingAuthorizeUrl(reg.client.clientId);
|
|
3793
|
+
|
|
3794
|
+
// Step 1: GET /oauth/authorize on a pending client renders the approve form.
|
|
3795
|
+
const getRes = handleAuthorizeGet(
|
|
3796
|
+
db,
|
|
3797
|
+
new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
|
|
3798
|
+
{ issuer: ISSUER },
|
|
3799
|
+
);
|
|
3800
|
+
expect(getRes.status).toBe(403);
|
|
3801
|
+
const getHtml = await getRes.text();
|
|
3802
|
+
expect(getHtml).toContain('action="/oauth/authorize/approve"');
|
|
3803
|
+
|
|
3804
|
+
// Pull the return_to value the form would submit. It's the path+search
|
|
3805
|
+
// of the authorize URL.
|
|
3806
|
+
const authorizeUrlParsed = new URL(authorizeHref);
|
|
3807
|
+
const returnTo = `${authorizeUrlParsed.pathname}${authorizeUrlParsed.search}`;
|
|
3808
|
+
|
|
3809
|
+
// Step 2: POST the approve form.
|
|
3810
|
+
const postForm = new URLSearchParams({
|
|
3811
|
+
__csrf: TEST_CSRF,
|
|
3812
|
+
client_id: reg.client.clientId,
|
|
3813
|
+
return_to: returnTo,
|
|
3814
|
+
});
|
|
3815
|
+
const postRes = await handleApproveClientPost(
|
|
3816
|
+
db,
|
|
3817
|
+
new Request(`${ISSUER}/oauth/authorize/approve`, {
|
|
3818
|
+
method: "POST",
|
|
3819
|
+
body: postForm,
|
|
3820
|
+
headers: {
|
|
3821
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
3822
|
+
cookie,
|
|
3823
|
+
origin: ISSUER,
|
|
3824
|
+
},
|
|
3825
|
+
}),
|
|
3826
|
+
{ issuer: ISSUER },
|
|
3827
|
+
);
|
|
3828
|
+
expect(postRes.status).toBe(302);
|
|
3829
|
+
expect(postRes.headers.get("location")).toBe(returnTo);
|
|
3830
|
+
|
|
3831
|
+
// Step 3: GET /oauth/authorize again — now the client is approved, so
|
|
3832
|
+
// the operator lands on the consent screen.
|
|
3833
|
+
const reentryRes = handleAuthorizeGet(
|
|
3834
|
+
db,
|
|
3835
|
+
new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
|
|
3836
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
3837
|
+
);
|
|
3838
|
+
expect(reentryRes.status).toBe(200);
|
|
3839
|
+
const consentHtml = await reentryRes.text();
|
|
3840
|
+
// Consent screen markers (renderConsent uses these).
|
|
3841
|
+
expect(consentHtml).toContain('name="__action" value="consent"');
|
|
3842
|
+
expect(consentHtml).toContain("Authorize");
|
|
3843
|
+
expect(consentHtml).toContain("RoundTrip");
|
|
3844
|
+
} finally {
|
|
3845
|
+
cleanup();
|
|
3846
|
+
}
|
|
3847
|
+
});
|
|
3848
|
+
});
|