@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21
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 +4 -11
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-vaults.ts +77 -27
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +52 -4
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +173 -25
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +110 -7
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -2,18 +2,24 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { issueAuthCode } from "../auth-codes.ts";
|
|
5
6
|
import {
|
|
6
7
|
InvalidRedirectUriError,
|
|
7
8
|
approveClient,
|
|
9
|
+
deleteClient,
|
|
8
10
|
expandRedirectUrisForHubOrigins,
|
|
11
|
+
findReapableClients,
|
|
9
12
|
getClient,
|
|
10
13
|
isValidRedirectUri,
|
|
11
14
|
listClientsByStatus,
|
|
15
|
+
reapClient,
|
|
12
16
|
registerClient,
|
|
13
17
|
requireRegisteredRedirectUri,
|
|
14
18
|
verifyClientSecret,
|
|
15
19
|
} from "../clients.ts";
|
|
20
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
16
21
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
22
|
+
import { createUser } from "../users.ts";
|
|
17
23
|
|
|
18
24
|
function makeDb() {
|
|
19
25
|
const configDir = mkdtempSync(join(tmpdir(), "phub-clients-"));
|
|
@@ -151,7 +157,10 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
151
157
|
const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
|
|
152
158
|
|
|
153
159
|
test("expands a loopback-rooted URI onto every other hub origin", () => {
|
|
154
|
-
const out = expandRedirectUrisForHubOrigins(
|
|
160
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
161
|
+
[`${LOOPBACK}/surface/notes/oauth/callback`],
|
|
162
|
+
hubOrigins,
|
|
163
|
+
);
|
|
155
164
|
// Original is preserved + the public + localhost variants are added.
|
|
156
165
|
expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
157
166
|
expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
@@ -188,10 +197,7 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
188
197
|
});
|
|
189
198
|
|
|
190
199
|
test("single known hub origin → no expansion (submitted set returned as-is)", () => {
|
|
191
|
-
const out = expandRedirectUrisForHubOrigins(
|
|
192
|
-
[`${LOOPBACK}/surface/notes/`],
|
|
193
|
-
[LOOPBACK],
|
|
194
|
-
);
|
|
200
|
+
const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/`], [LOOPBACK]);
|
|
195
201
|
expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
|
|
196
202
|
});
|
|
197
203
|
|
|
@@ -222,9 +228,9 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
222
228
|
const r = registerClient(db, { redirectUris: expanded });
|
|
223
229
|
// The public-origin variant now matches exactly at authorize time — the
|
|
224
230
|
// off-localhost sign-in that surface#118 broke.
|
|
225
|
-
expect(
|
|
226
|
-
|
|
227
|
-
)
|
|
231
|
+
expect(requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`)).toBe(
|
|
232
|
+
`${PUBLIC}/surface/notes/oauth/callback`,
|
|
233
|
+
);
|
|
228
234
|
// A truly-unregistered URI is still rejected — strict match unchanged.
|
|
229
235
|
expect(() =>
|
|
230
236
|
requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
|
|
@@ -341,6 +347,298 @@ describe("approval gate (#74)", () => {
|
|
|
341
347
|
});
|
|
342
348
|
});
|
|
343
349
|
|
|
350
|
+
describe("deleteClient cascade (hub#640 RFC 7592 deregistration)", () => {
|
|
351
|
+
test("returns false for an unknown client_id (nothing to delete)", () => {
|
|
352
|
+
const { db, cleanup } = makeDb();
|
|
353
|
+
try {
|
|
354
|
+
expect(deleteClient(db, "no-such-client")).toBe(false);
|
|
355
|
+
} finally {
|
|
356
|
+
cleanup();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("deletes the client row and reports true", () => {
|
|
361
|
+
const { db, cleanup } = makeDb();
|
|
362
|
+
try {
|
|
363
|
+
const r = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
364
|
+
expect(getClient(db, r.client.clientId)).not.toBeNull();
|
|
365
|
+
expect(deleteClient(db, r.client.clientId)).toBe(true);
|
|
366
|
+
expect(getClient(db, r.client.clientId)).toBeNull();
|
|
367
|
+
} finally {
|
|
368
|
+
cleanup();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("cascades dependent grants + auth_codes (FK ON, no ON DELETE CASCADE)", async () => {
|
|
373
|
+
const { db, cleanup } = makeDb();
|
|
374
|
+
try {
|
|
375
|
+
const user = await createUser(db, "owner", "pw");
|
|
376
|
+
const r = registerClient(db, {
|
|
377
|
+
redirectUris: ["https://app.example/cb"],
|
|
378
|
+
scopes: ["vault:work:read"],
|
|
379
|
+
});
|
|
380
|
+
const clientId = r.client.clientId;
|
|
381
|
+
|
|
382
|
+
// Plant a live grant + auth_code that reference the client. Without the
|
|
383
|
+
// cascade, the bare DELETE FROM clients would throw a FK violation while
|
|
384
|
+
// these rows exist (PRAGMA foreign_keys = ON).
|
|
385
|
+
recordGrant(db, user.id, clientId, ["vault:work:read"]);
|
|
386
|
+
const ac = issueAuthCode(db, {
|
|
387
|
+
clientId,
|
|
388
|
+
userId: user.id,
|
|
389
|
+
redirectUri: "https://app.example/cb",
|
|
390
|
+
scopes: ["vault:work:read"],
|
|
391
|
+
codeChallenge: "x".repeat(43),
|
|
392
|
+
codeChallengeMethod: "S256",
|
|
393
|
+
});
|
|
394
|
+
// Sanity: the dependents are present before the delete.
|
|
395
|
+
expect(findGrant(db, user.id, clientId)).not.toBeNull();
|
|
396
|
+
expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).not.toBeNull();
|
|
397
|
+
|
|
398
|
+
// Delete cascades — no FK throw, true returned.
|
|
399
|
+
expect(deleteClient(db, clientId)).toBe(true);
|
|
400
|
+
|
|
401
|
+
// Client + both dependents are gone.
|
|
402
|
+
expect(getClient(db, clientId)).toBeNull();
|
|
403
|
+
expect(findGrant(db, user.id, clientId)).toBeNull();
|
|
404
|
+
expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).toBeNull();
|
|
405
|
+
// The user row is untouched — only client-keyed rows cascade.
|
|
406
|
+
expect(db.query("SELECT id FROM users WHERE id = ?").get(user.id)).not.toBeNull();
|
|
407
|
+
} finally {
|
|
408
|
+
cleanup();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("transactional: deleting one client leaves a sibling client's grants intact", async () => {
|
|
413
|
+
const { db, cleanup } = makeDb();
|
|
414
|
+
try {
|
|
415
|
+
const user = await createUser(db, "owner", "pw");
|
|
416
|
+
const keep = registerClient(db, { redirectUris: ["https://keep.example/cb"] });
|
|
417
|
+
const drop = registerClient(db, { redirectUris: ["https://drop.example/cb"] });
|
|
418
|
+
recordGrant(db, user.id, keep.client.clientId, ["vault:work:read"]);
|
|
419
|
+
recordGrant(db, user.id, drop.client.clientId, ["vault:work:read"]);
|
|
420
|
+
|
|
421
|
+
expect(deleteClient(db, drop.client.clientId)).toBe(true);
|
|
422
|
+
|
|
423
|
+
// The kept client + its grant survive; only the dropped client's
|
|
424
|
+
// grant cascaded.
|
|
425
|
+
expect(getClient(db, keep.client.clientId)).not.toBeNull();
|
|
426
|
+
expect(findGrant(db, user.id, keep.client.clientId)).not.toBeNull();
|
|
427
|
+
expect(getClient(db, drop.client.clientId)).toBeNull();
|
|
428
|
+
expect(findGrant(db, user.id, drop.client.clientId)).toBeNull();
|
|
429
|
+
} finally {
|
|
430
|
+
cleanup();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("findReapableClients / reapClient — the GC safety gate (hub#640)", () => {
|
|
436
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
437
|
+
// A fixed "now" so age math is deterministic. All planted clients register
|
|
438
|
+
// 60 days before this unless a test overrides.
|
|
439
|
+
const NOW = new Date("2026-06-27T00:00:00Z");
|
|
440
|
+
const now = () => NOW;
|
|
441
|
+
const OLD = new Date(NOW.getTime() - 60 * DAY_MS); // 60d old → past 30d floor
|
|
442
|
+
const oldNow = () => OLD;
|
|
443
|
+
|
|
444
|
+
/** Plant a `tokens` row with precise expiry/revocation for a client. */
|
|
445
|
+
function plantToken(
|
|
446
|
+
db: ReturnType<typeof openHubDb>,
|
|
447
|
+
opts: {
|
|
448
|
+
clientId: string;
|
|
449
|
+
userId: string;
|
|
450
|
+
jti: string;
|
|
451
|
+
expiresAt: string;
|
|
452
|
+
revokedAt?: string | null;
|
|
453
|
+
},
|
|
454
|
+
): void {
|
|
455
|
+
db.prepare(
|
|
456
|
+
`INSERT INTO tokens
|
|
457
|
+
(jti, user_id, client_id, scopes, refresh_token_hash, family_id,
|
|
458
|
+
expires_at, revoked_at, created_at, created_via, subject)
|
|
459
|
+
VALUES (?, ?, ?, '', NULL, NULL, ?, ?, ?, 'oauth_refresh', NULL)`,
|
|
460
|
+
).run(
|
|
461
|
+
opts.jti,
|
|
462
|
+
opts.userId,
|
|
463
|
+
opts.clientId,
|
|
464
|
+
opts.expiresAt,
|
|
465
|
+
opts.revokedAt ?? null,
|
|
466
|
+
OLD.toISOString(),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
test("a genuinely-dead old client IS reaped (no grants, only expired/revoked tokens, no live codes)", async () => {
|
|
471
|
+
const { db, cleanup } = makeDb();
|
|
472
|
+
try {
|
|
473
|
+
const user = await createUser(db, "owner", "pw");
|
|
474
|
+
const dead = registerClient(db, { redirectUris: ["https://dead.example/cb"], now: oldNow });
|
|
475
|
+
const id = dead.client.clientId;
|
|
476
|
+
// An expired token + a revoked token — both dead.
|
|
477
|
+
plantToken(db, {
|
|
478
|
+
clientId: id,
|
|
479
|
+
userId: user.id,
|
|
480
|
+
jti: "jti-expired",
|
|
481
|
+
expiresAt: new Date(NOW.getTime() - DAY_MS).toISOString(),
|
|
482
|
+
});
|
|
483
|
+
plantToken(db, {
|
|
484
|
+
clientId: id,
|
|
485
|
+
userId: user.id,
|
|
486
|
+
jti: "jti-revoked",
|
|
487
|
+
expiresAt: new Date(NOW.getTime() + DAY_MS).toISOString(), // unexpired...
|
|
488
|
+
revokedAt: new Date(NOW.getTime() - DAY_MS).toISOString(), // ...but revoked → dead
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const reapable = findReapableClients(db, { now });
|
|
492
|
+
expect(reapable.map((c) => c.clientId)).toEqual([id]);
|
|
493
|
+
expect(reapable[0]?.ageDays).toBe(60);
|
|
494
|
+
|
|
495
|
+
// reapClient removes the client AND its dead token rows.
|
|
496
|
+
expect(reapClient(db, id)).toBe(true);
|
|
497
|
+
expect(getClient(db, id)).toBeNull();
|
|
498
|
+
expect(db.query("SELECT jti FROM tokens WHERE client_id = ?").all(id)).toEqual([]);
|
|
499
|
+
} finally {
|
|
500
|
+
cleanup();
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("a client WITH a live grant is NEVER reaped, even when old", async () => {
|
|
505
|
+
const { db, cleanup } = makeDb();
|
|
506
|
+
try {
|
|
507
|
+
const user = await createUser(db, "owner", "pw");
|
|
508
|
+
const c = registerClient(db, { redirectUris: ["https://granted.example/cb"], now: oldNow });
|
|
509
|
+
recordGrant(db, user.id, c.client.clientId, ["vault:work:read"]);
|
|
510
|
+
const reapable = findReapableClients(db, { now });
|
|
511
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
|
|
512
|
+
} finally {
|
|
513
|
+
cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("a client with a live (unexpired, unrevoked) token is NEVER reaped, even when old", async () => {
|
|
518
|
+
const { db, cleanup } = makeDb();
|
|
519
|
+
try {
|
|
520
|
+
const user = await createUser(db, "owner", "pw");
|
|
521
|
+
const c = registerClient(db, { redirectUris: ["https://live.example/cb"], now: oldNow });
|
|
522
|
+
plantToken(db, {
|
|
523
|
+
clientId: c.client.clientId,
|
|
524
|
+
userId: user.id,
|
|
525
|
+
jti: "jti-live",
|
|
526
|
+
expiresAt: new Date(NOW.getTime() + 7 * DAY_MS).toISOString(),
|
|
527
|
+
revokedAt: null,
|
|
528
|
+
});
|
|
529
|
+
const reapable = findReapableClients(db, { now });
|
|
530
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
|
|
531
|
+
} finally {
|
|
532
|
+
cleanup();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("a client with an unexpired auth_code is NEVER reaped (in-flight OAuth)", async () => {
|
|
537
|
+
const { db, cleanup } = makeDb();
|
|
538
|
+
try {
|
|
539
|
+
const user = await createUser(db, "owner", "pw");
|
|
540
|
+
const c = registerClient(db, { redirectUris: ["https://inflight.example/cb"], now: oldNow });
|
|
541
|
+
// issueAuthCode mints a code expiring AUTH_CODE_TTL after `now` → unexpired.
|
|
542
|
+
issueAuthCode(db, {
|
|
543
|
+
clientId: c.client.clientId,
|
|
544
|
+
userId: user.id,
|
|
545
|
+
redirectUri: "https://inflight.example/cb",
|
|
546
|
+
scopes: ["vault:work:read"],
|
|
547
|
+
codeChallenge: "x".repeat(43),
|
|
548
|
+
codeChallengeMethod: "S256",
|
|
549
|
+
now,
|
|
550
|
+
});
|
|
551
|
+
const reapable = findReapableClients(db, { now });
|
|
552
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
|
|
553
|
+
} finally {
|
|
554
|
+
cleanup();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("a freshly-registered client is NEVER reaped, even with zero grants/tokens/codes", () => {
|
|
559
|
+
const { db, cleanup } = makeDb();
|
|
560
|
+
try {
|
|
561
|
+
// Registered 5 days ago — inside the default 30d floor.
|
|
562
|
+
const fresh = registerClient(db, {
|
|
563
|
+
redirectUris: ["https://fresh.example/cb"],
|
|
564
|
+
now: () => new Date(NOW.getTime() - 5 * DAY_MS),
|
|
565
|
+
});
|
|
566
|
+
const reapable = findReapableClients(db, { now });
|
|
567
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(fresh.client.clientId);
|
|
568
|
+
} finally {
|
|
569
|
+
cleanup();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("an expired auth_code does NOT protect a client (only LIVE codes do)", async () => {
|
|
574
|
+
const { db, cleanup } = makeDb();
|
|
575
|
+
try {
|
|
576
|
+
const user = await createUser(db, "owner", "pw");
|
|
577
|
+
const c = registerClient(db, { redirectUris: ["https://stale.example/cb"], now: oldNow });
|
|
578
|
+
// Code issued 60d ago → long expired.
|
|
579
|
+
issueAuthCode(db, {
|
|
580
|
+
clientId: c.client.clientId,
|
|
581
|
+
userId: user.id,
|
|
582
|
+
redirectUri: "https://stale.example/cb",
|
|
583
|
+
scopes: ["vault:work:read"],
|
|
584
|
+
codeChallenge: "x".repeat(43),
|
|
585
|
+
codeChallengeMethod: "S256",
|
|
586
|
+
now: oldNow,
|
|
587
|
+
});
|
|
588
|
+
const reapable = findReapableClients(db, { now });
|
|
589
|
+
expect(reapable.map((x) => x.clientId)).toContain(c.client.clientId);
|
|
590
|
+
} finally {
|
|
591
|
+
cleanup();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("--older-than threshold is honored (a 20d-old client falls under a 10d floor but not 30d)", () => {
|
|
596
|
+
const { db, cleanup } = makeDb();
|
|
597
|
+
try {
|
|
598
|
+
const c = registerClient(db, {
|
|
599
|
+
redirectUris: ["https://midage.example/cb"],
|
|
600
|
+
now: () => new Date(NOW.getTime() - 20 * DAY_MS),
|
|
601
|
+
});
|
|
602
|
+
// Default 30d floor → not yet reapable.
|
|
603
|
+
expect(findReapableClients(db, { now }).map((x) => x.clientId)).not.toContain(
|
|
604
|
+
c.client.clientId,
|
|
605
|
+
);
|
|
606
|
+
// 10d floor → now reapable.
|
|
607
|
+
expect(
|
|
608
|
+
findReapableClients(db, { now, olderThanMs: 10 * DAY_MS }).map((x) => x.clientId),
|
|
609
|
+
).toContain(c.client.clientId);
|
|
610
|
+
} finally {
|
|
611
|
+
cleanup();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("reapClient on a still-protected client deletes nothing the gate excluded (callers pass only gated ids)", async () => {
|
|
616
|
+
// Belt-and-suspenders: reapClient itself doesn't re-check the gate (the
|
|
617
|
+
// caller is contractually responsible). This asserts the realistic flow —
|
|
618
|
+
// a live client is simply NOT in the findReapableClients output, so the
|
|
619
|
+
// apply loop never calls reapClient on it.
|
|
620
|
+
const { db, cleanup } = makeDb();
|
|
621
|
+
try {
|
|
622
|
+
const user = await createUser(db, "owner", "pw");
|
|
623
|
+
const live = registerClient(db, { redirectUris: ["https://keep.example/cb"], now: oldNow });
|
|
624
|
+
recordGrant(db, user.id, live.client.clientId, ["vault:work:read"]);
|
|
625
|
+
const dead = registerClient(db, { redirectUris: ["https://gone.example/cb"], now: oldNow });
|
|
626
|
+
|
|
627
|
+
const reapable = findReapableClients(db, { now });
|
|
628
|
+
const ids = reapable.map((x) => x.clientId);
|
|
629
|
+
expect(ids).toEqual([dead.client.clientId]);
|
|
630
|
+
for (const c of reapable) reapClient(db, c.clientId);
|
|
631
|
+
|
|
632
|
+
// Live client + its grant survive; only the dead one is gone.
|
|
633
|
+
expect(getClient(db, live.client.clientId)).not.toBeNull();
|
|
634
|
+
expect(findGrant(db, user.id, live.client.clientId)).not.toBeNull();
|
|
635
|
+
expect(getClient(db, dead.client.clientId)).toBeNull();
|
|
636
|
+
} finally {
|
|
637
|
+
cleanup();
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
344
642
|
describe("isValidRedirectUri", () => {
|
|
345
643
|
test("accepts http and https", () => {
|
|
346
644
|
expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
|
|
@@ -352,4 +650,24 @@ describe("isValidRedirectUri", () => {
|
|
|
352
650
|
expect(isValidRedirectUri("/relative")).toBe(false);
|
|
353
651
|
expect(isValidRedirectUri("not a url")).toBe(false);
|
|
354
652
|
});
|
|
653
|
+
// hub#663: spec-forbidden shapes that the protocol allowlist alone passed.
|
|
654
|
+
test("rejects userinfo-bearing redirect URIs (hub#663)", () => {
|
|
655
|
+
expect(isValidRedirectUri("https://x@evil.com/cb")).toBe(false);
|
|
656
|
+
expect(isValidRedirectUri("https://user:pass@evil.com/cb")).toBe(false);
|
|
657
|
+
expect(isValidRedirectUri("http://attacker@127.0.0.1:3000/cb")).toBe(false);
|
|
658
|
+
});
|
|
659
|
+
test("rejects control chars in the raw input (hub#663)", () => {
|
|
660
|
+
// Control chars must be caught on the RAW string — URL parsing would
|
|
661
|
+
// otherwise strip a trailing \r\n and the smuggled value would pass.
|
|
662
|
+
expect(isValidRedirectUri("https://example.com/cb\r\nSet-Cookie: x")).toBe(false);
|
|
663
|
+
expect(isValidRedirectUri("https://example.com/\x00cb")).toBe(false);
|
|
664
|
+
expect(isValidRedirectUri("https://example.com/cb\x7f")).toBe(false);
|
|
665
|
+
});
|
|
666
|
+
test("still accepts clean http(s) with ports, paths, and queries (regression guard)", () => {
|
|
667
|
+
// Legitimate clients (hub modules, self-built surfaces, Notes, Claude DCR)
|
|
668
|
+
// all register clean URIs — these must keep passing.
|
|
669
|
+
expect(isValidRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true);
|
|
670
|
+
expect(isValidRedirectUri("http://localhost:1939/admin/oauth/callback")).toBe(true);
|
|
671
|
+
expect(isValidRedirectUri("https://my-surface.github.io/cb?x=1")).toBe(true);
|
|
672
|
+
});
|
|
355
673
|
});
|
|
@@ -258,8 +258,10 @@ describe("installConnectorService — Linux systemd", () => {
|
|
|
258
258
|
platform: "linux",
|
|
259
259
|
getuid: () => 1000,
|
|
260
260
|
userName: () => "op",
|
|
261
|
-
//
|
|
261
|
+
// #528 probe: show-user → Linger=no (off, so we proceed to enable);
|
|
262
|
+
// then enable-linger FAIL, daemon-reload OK, enable --now OK.
|
|
262
263
|
runResults: [
|
|
264
|
+
{ code: 0, stdout: "Linger=no\n", stderr: "" },
|
|
263
265
|
{ code: 1, stdout: "", stderr: "Failed to enable linger" },
|
|
264
266
|
{ code: 0, stdout: "", stderr: "" },
|
|
265
267
|
{ code: 0, stdout: "", stderr: "" },
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { getClient, registerClient } from "../clients.ts";
|
|
5
6
|
import {
|
|
6
7
|
CORS_PREFLIGHT_HEADERS,
|
|
7
8
|
CORS_RESPONSE_HEADERS,
|
|
@@ -11,7 +12,9 @@ import {
|
|
|
11
12
|
} from "../cors.ts";
|
|
12
13
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
14
|
import { hubFetch } from "../hub-server.ts";
|
|
15
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
14
16
|
import { writeManifest } from "../services-manifest.ts";
|
|
17
|
+
import { createUser } from "../users.ts";
|
|
15
18
|
|
|
16
19
|
const GITCOIN_BRAIN_ORIGIN = "https://unforced-dev.github.io";
|
|
17
20
|
const EXAMPLE_ORIGIN = "https://example.com";
|
|
@@ -51,10 +54,12 @@ describe("cors helper module", () => {
|
|
|
51
54
|
expect(CORS_RESPONSE_HEADERS["access-control-expose-headers"]).toContain("WWW-Authenticate");
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
test("CORS_PREFLIGHT_HEADERS announces GET + POST + OPTIONS and standard request headers", () => {
|
|
57
|
+
test("CORS_PREFLIGHT_HEADERS announces GET + POST + DELETE + OPTIONS and standard request headers", () => {
|
|
55
58
|
const methods = CORS_PREFLIGHT_HEADERS["access-control-allow-methods"] ?? "";
|
|
56
59
|
expect(methods).toContain("GET");
|
|
57
60
|
expect(methods).toContain("POST");
|
|
61
|
+
// DELETE for RFC 7592 client deregistration (hub#640).
|
|
62
|
+
expect(methods).toContain("DELETE");
|
|
58
63
|
expect(methods).toContain("OPTIONS");
|
|
59
64
|
const headers = CORS_PREFLIGHT_HEADERS["access-control-allow-headers"] ?? "";
|
|
60
65
|
expect(headers).toContain("Authorization");
|
|
@@ -585,3 +590,135 @@ describe("hubFetch CORS scope discipline — out-of-scope routes stay same-origi
|
|
|
585
590
|
}
|
|
586
591
|
});
|
|
587
592
|
});
|
|
593
|
+
|
|
594
|
+
// hub#640 — confirm the TOP-LEVEL DELETE /oauth/clients/<id> route is wired
|
|
595
|
+
// into the real dispatch (not just the handler in isolation). This is the
|
|
596
|
+
// load-bearing "right prefix" check: the surface remove-flow fires DELETE at
|
|
597
|
+
// exactly this path, and before this route the hub 404'd it. Goes through
|
|
598
|
+
// hubFetch so the dispatch order + the path-prefix branch are exercised.
|
|
599
|
+
describe("hub#640 DELETE /oauth/clients/<id> dispatch (RFC 7592)", () => {
|
|
600
|
+
async function operatorBearer(db: ReturnType<typeof openHubDb>): Promise<string> {
|
|
601
|
+
const user = await createUser(db, "owner", "pw");
|
|
602
|
+
const minted = await signAccessToken(db, {
|
|
603
|
+
sub: user.id,
|
|
604
|
+
scopes: ["parachute:host:admin"],
|
|
605
|
+
audience: "hub",
|
|
606
|
+
clientId: "parachute-hub-spa",
|
|
607
|
+
issuer: ISSUER,
|
|
608
|
+
ttlSeconds: 600,
|
|
609
|
+
});
|
|
610
|
+
return minted.token;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
test("204 + row gone with a valid operator Bearer (the surface remove-flow path)", async () => {
|
|
614
|
+
const h = makeHarness();
|
|
615
|
+
try {
|
|
616
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
617
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
618
|
+
try {
|
|
619
|
+
const bearer = await operatorBearer(db);
|
|
620
|
+
const id = registerClient(db, {
|
|
621
|
+
redirectUris: ["https://app.example/cb"],
|
|
622
|
+
clientName: "Notes",
|
|
623
|
+
}).client.clientId;
|
|
624
|
+
|
|
625
|
+
const res = await hubFetch(h.dir, {
|
|
626
|
+
getDb: () => db,
|
|
627
|
+
issuer: ISSUER,
|
|
628
|
+
manifestPath: h.manifestPath,
|
|
629
|
+
})(
|
|
630
|
+
new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(id)}`, {
|
|
631
|
+
method: "DELETE",
|
|
632
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
expect(res.status).toBe(204);
|
|
636
|
+
expect(getClient(db, id)).toBeNull();
|
|
637
|
+
} finally {
|
|
638
|
+
db.close();
|
|
639
|
+
}
|
|
640
|
+
} finally {
|
|
641
|
+
h.cleanup();
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("401 without a Bearer — the route is auth-gated, not open", async () => {
|
|
646
|
+
const h = makeHarness();
|
|
647
|
+
try {
|
|
648
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
649
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
650
|
+
try {
|
|
651
|
+
const id = registerClient(db, {
|
|
652
|
+
redirectUris: ["https://app.example/cb"],
|
|
653
|
+
}).client.clientId;
|
|
654
|
+
const res = await hubFetch(h.dir, {
|
|
655
|
+
getDb: () => db,
|
|
656
|
+
issuer: ISSUER,
|
|
657
|
+
manifestPath: h.manifestPath,
|
|
658
|
+
})(
|
|
659
|
+
new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(id)}`, {
|
|
660
|
+
method: "DELETE",
|
|
661
|
+
}),
|
|
662
|
+
);
|
|
663
|
+
expect(res.status).toBe(401);
|
|
664
|
+
// Row survives an unauthenticated DELETE.
|
|
665
|
+
expect(getClient(db, id)).not.toBeNull();
|
|
666
|
+
} finally {
|
|
667
|
+
db.close();
|
|
668
|
+
}
|
|
669
|
+
} finally {
|
|
670
|
+
h.cleanup();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("404 for an absent client_id through dispatch (matches surface 'not_found')", async () => {
|
|
675
|
+
const h = makeHarness();
|
|
676
|
+
try {
|
|
677
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
678
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
679
|
+
try {
|
|
680
|
+
const bearer = await operatorBearer(db);
|
|
681
|
+
const res = await hubFetch(h.dir, {
|
|
682
|
+
getDb: () => db,
|
|
683
|
+
issuer: ISSUER,
|
|
684
|
+
manifestPath: h.manifestPath,
|
|
685
|
+
})(
|
|
686
|
+
new Request(`${ISSUER}/oauth/clients/no-such-client`, {
|
|
687
|
+
method: "DELETE",
|
|
688
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
expect(res.status).toBe(404);
|
|
692
|
+
// The surface keys off a JSON 404 → hubDeleteStatus "not_found".
|
|
693
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
694
|
+
expect(body.error).toBe("not_found");
|
|
695
|
+
} finally {
|
|
696
|
+
db.close();
|
|
697
|
+
}
|
|
698
|
+
} finally {
|
|
699
|
+
h.cleanup();
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("OPTIONS preflight on the DELETE path advertises DELETE in Allow-Methods", async () => {
|
|
704
|
+
const h = makeHarness();
|
|
705
|
+
try {
|
|
706
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
707
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
708
|
+
try {
|
|
709
|
+
const res = await hubFetch(h.dir, {
|
|
710
|
+
getDb: () => db,
|
|
711
|
+
issuer: ISSUER,
|
|
712
|
+
manifestPath: h.manifestPath,
|
|
713
|
+
})(preflight("/oauth/clients/some-id", EXAMPLE_ORIGIN));
|
|
714
|
+
expect(res.status).toBe(204);
|
|
715
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("DELETE");
|
|
716
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
717
|
+
} finally {
|
|
718
|
+
db.close();
|
|
719
|
+
}
|
|
720
|
+
} finally {
|
|
721
|
+
h.cleanup();
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
});
|