@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +92 -0
  3. package/src/__tests__/expose-2fa-warning.test.ts +125 -0
  4. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  5. package/src/__tests__/expose.test.ts +199 -340
  6. package/src/__tests__/hub-server.test.ts +1227 -1
  7. package/src/__tests__/install.test.ts +50 -31
  8. package/src/__tests__/lifecycle.test.ts +97 -2
  9. package/src/__tests__/module-manifest.test.ts +13 -0
  10. package/src/__tests__/notes-serve.test.ts +154 -2
  11. package/src/__tests__/oauth-handlers.test.ts +737 -1
  12. package/src/__tests__/port-assign.test.ts +41 -52
  13. package/src/__tests__/rate-limit.test.ts +190 -0
  14. package/src/__tests__/services-manifest.test.ts +367 -0
  15. package/src/__tests__/setup.test.ts +12 -9
  16. package/src/__tests__/status.test.ts +173 -0
  17. package/src/admin-handlers.ts +38 -13
  18. package/src/commands/expose-2fa-warning.ts +82 -0
  19. package/src/commands/expose-cloudflare.ts +27 -0
  20. package/src/commands/expose-public-auto.ts +3 -7
  21. package/src/commands/expose.ts +88 -173
  22. package/src/commands/install.ts +11 -13
  23. package/src/commands/lifecycle.ts +53 -4
  24. package/src/commands/status.ts +28 -1
  25. package/src/help.ts +3 -3
  26. package/src/hub-server.ts +266 -32
  27. package/src/module-manifest.ts +19 -0
  28. package/src/notes-serve.ts +70 -9
  29. package/src/oauth-handlers.ts +249 -12
  30. package/src/oauth-ui.ts +167 -0
  31. package/src/port-assign.ts +28 -35
  32. package/src/rate-limit.ts +163 -0
  33. package/src/service-spec.ts +66 -13
  34. package/src/services-manifest.ts +83 -3
  35. 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
+ });