@enbox/dwn-server 0.0.7 → 0.0.9

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 (97) hide show
  1. package/dist/esm/src/admin/admin-api.d.ts +5 -1
  2. package/dist/esm/src/admin/admin-api.d.ts.map +1 -1
  3. package/dist/esm/src/admin/admin-api.js +327 -7
  4. package/dist/esm/src/admin/admin-api.js.map +1 -1
  5. package/dist/esm/src/admin/admin-auth.d.ts +21 -3
  6. package/dist/esm/src/admin/admin-auth.d.ts.map +1 -1
  7. package/dist/esm/src/admin/admin-auth.js +17 -9
  8. package/dist/esm/src/admin/admin-auth.js.map +1 -1
  9. package/dist/esm/src/admin/admin-passkey-store.d.ts +68 -0
  10. package/dist/esm/src/admin/admin-passkey-store.d.ts.map +1 -0
  11. package/dist/esm/src/admin/admin-passkey-store.js +132 -0
  12. package/dist/esm/src/admin/admin-passkey-store.js.map +1 -0
  13. package/dist/esm/src/admin/admin-session.d.ts +35 -0
  14. package/dist/esm/src/admin/admin-session.d.ts.map +1 -0
  15. package/dist/esm/src/admin/admin-session.js +91 -0
  16. package/dist/esm/src/admin/admin-session.js.map +1 -0
  17. package/dist/esm/src/admin/admin-store.d.ts +4 -0
  18. package/dist/esm/src/admin/admin-store.d.ts.map +1 -1
  19. package/dist/esm/src/admin/admin-store.js +6 -2
  20. package/dist/esm/src/admin/admin-store.js.map +1 -1
  21. package/dist/esm/src/admin/audit-log.d.ts.map +1 -1
  22. package/dist/esm/src/admin/audit-log.js +5 -43
  23. package/dist/esm/src/admin/audit-log.js.map +1 -1
  24. package/dist/esm/src/admin/index.d.ts +5 -1
  25. package/dist/esm/src/admin/index.d.ts.map +1 -1
  26. package/dist/esm/src/admin/index.js +2 -0
  27. package/dist/esm/src/admin/index.js.map +1 -1
  28. package/dist/esm/src/admin/types.d.ts +22 -0
  29. package/dist/esm/src/admin/types.d.ts.map +1 -1
  30. package/dist/esm/src/admin/webhook-manager.d.ts.map +1 -1
  31. package/dist/esm/src/admin/webhook-manager.js +11 -10
  32. package/dist/esm/src/admin/webhook-manager.js.map +1 -1
  33. package/dist/esm/src/config.d.ts +18 -0
  34. package/dist/esm/src/config.d.ts.map +1 -1
  35. package/dist/esm/src/config.js +18 -0
  36. package/dist/esm/src/config.js.map +1 -1
  37. package/dist/esm/src/connect/connect-server.d.ts +75 -0
  38. package/dist/esm/src/connect/connect-server.d.ts.map +1 -0
  39. package/dist/esm/src/{web5-connect/web5-connect-server.js → connect/connect-server.js} +32 -24
  40. package/dist/esm/src/connect/connect-server.js.map +1 -0
  41. package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.d.ts +11 -1
  42. package/dist/esm/src/connect/sql-ttl-cache.d.ts.map +1 -0
  43. package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.js +19 -20
  44. package/dist/esm/src/connect/sql-ttl-cache.js.map +1 -0
  45. package/dist/esm/src/dwn-server.d.ts.map +1 -1
  46. package/dist/esm/src/dwn-server.js +46 -11
  47. package/dist/esm/src/dwn-server.js.map +1 -1
  48. package/dist/esm/src/http-api.d.ts +6 -2
  49. package/dist/esm/src/http-api.d.ts.map +1 -1
  50. package/dist/esm/src/http-api.js +31 -17
  51. package/dist/esm/src/http-api.js.map +1 -1
  52. package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
  53. package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
  54. package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
  55. package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
  56. package/dist/esm/src/migrations/index.d.ts +13 -0
  57. package/dist/esm/src/migrations/index.d.ts.map +1 -0
  58. package/dist/esm/src/migrations/index.js +5 -0
  59. package/dist/esm/src/migrations/index.js.map +1 -0
  60. package/dist/esm/src/registration/registration-store.d.ts +4 -0
  61. package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
  62. package/dist/esm/src/registration/registration-store.js +11 -34
  63. package/dist/esm/src/registration/registration-store.js.map +1 -1
  64. package/dist/esm/src/server-migration-runner.d.ts +23 -0
  65. package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
  66. package/dist/esm/src/server-migration-runner.js +57 -0
  67. package/dist/esm/src/server-migration-runner.js.map +1 -0
  68. package/dist/esm/src/storage.d.ts +15 -0
  69. package/dist/esm/src/storage.d.ts.map +1 -1
  70. package/dist/esm/src/storage.js +135 -17
  71. package/dist/esm/src/storage.js.map +1 -1
  72. package/package.json +8 -27
  73. package/src/admin/admin-api.ts +403 -10
  74. package/src/admin/admin-auth.ts +38 -9
  75. package/src/admin/admin-passkey-store.ts +190 -0
  76. package/src/admin/admin-session.ts +116 -0
  77. package/src/admin/admin-store.ts +6 -2
  78. package/src/admin/audit-log.ts +7 -44
  79. package/src/admin/index.ts +5 -0
  80. package/src/admin/types.ts +28 -0
  81. package/src/admin/webhook-manager.ts +12 -10
  82. package/src/config.ts +21 -0
  83. package/src/connect/connect-server.ts +150 -0
  84. package/src/{web5-connect → connect}/sql-ttl-cache.ts +21 -22
  85. package/src/dwn-server.ts +49 -11
  86. package/src/http-api.ts +37 -18
  87. package/src/migrations/001-initial-server-schema.ts +114 -0
  88. package/src/migrations/index.ts +18 -0
  89. package/src/registration/registration-store.ts +13 -36
  90. package/src/server-migration-runner.ts +74 -0
  91. package/src/storage.ts +145 -17
  92. package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +0 -1
  93. package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +0 -1
  94. package/dist/esm/src/web5-connect/web5-connect-server.d.ts +0 -58
  95. package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +0 -1
  96. package/dist/esm/src/web5-connect/web5-connect-server.js.map +0 -1
  97. package/src/web5-connect/web5-connect-server.ts +0 -123
@@ -1,9 +1,10 @@
1
- import type { Dwn } from '@enbox/dwn-sdk-js';
2
-
3
1
  import type { ActivityLog } from './activity-log.js';
2
+ import type { AdminPasskeyStore } from './admin-passkey-store.js';
3
+ import type { AdminSessionManager } from './admin-session.js';
4
4
  import type { AdminStore } from './admin-store.js';
5
5
  import type { AuditLog } from './audit-log.js';
6
6
  import type { ConnectionManager } from '../connection/connection-manager.js';
7
+ import type { Dwn } from '@enbox/dwn-sdk-js';
7
8
  import type { DwnServerConfig } from '../config.js';
8
9
  import type { RateLimiter } from '../rate-limiter.js';
9
10
  import type { RegistrationManager } from '../registration/registration-manager.js';
@@ -11,6 +12,7 @@ import type { RegistrationStore } from '../registration/registration-store.js';
11
12
  import type { WebhookManager } from './webhook-manager.js';
12
13
  import type {
13
14
  AdminConnectionSnapshot,
15
+ AdminPasskeySummary,
14
16
  AdminServerStats,
15
17
  AdminTenantDetail,
16
18
  AdminTenantSummary,
@@ -23,8 +25,15 @@ import type {
23
25
  TenantQuotaInput,
24
26
  TenantQuotaStatus,
25
27
  } from './types.js';
28
+ import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server';
26
29
 
27
30
  import log from 'loglevel';
31
+ import {
32
+ generateAuthenticationOptions,
33
+ generateRegistrationOptions,
34
+ verifyAuthenticationResponse,
35
+ verifyRegistrationResponse,
36
+ } from '@simplewebauthn/server';
28
37
 
29
38
  import { validateAdminAuth } from './admin-auth.js';
30
39
  import {
@@ -60,6 +69,12 @@ export class AdminApi {
60
69
  #ipRateLimiter: RateLimiter | undefined;
61
70
  #tenantRateLimiter: RateLimiter | undefined;
62
71
  #webhookManager: WebhookManager | undefined;
72
+ #passkeyStore: AdminPasskeyStore | undefined;
73
+ #sessionManager: AdminSessionManager | undefined;
74
+ /** In-memory challenge store: maps challenge → expiry timestamp. */
75
+ #challenges: Map<string, number> = new Map();
76
+ /** Challenge TTL: 60 seconds. */
77
+ static readonly #CHALLENGE_TTL_MS = 60_000;
63
78
  #startTime: number;
64
79
  #packageInfo: { version?: string };
65
80
  #metricsInterval: ReturnType<typeof setInterval> | undefined;
@@ -87,6 +102,8 @@ export class AdminApi {
87
102
  ipRateLimiter? : RateLimiter;
88
103
  tenantRateLimiter? : RateLimiter;
89
104
  webhookManager? : WebhookManager;
105
+ passkeyStore? : AdminPasskeyStore;
106
+ sessionManager? : AdminSessionManager;
90
107
  packageInfo? : { version?: string };
91
108
  }): AdminApi {
92
109
  const api = new AdminApi();
@@ -101,6 +118,8 @@ export class AdminApi {
101
118
  api.#ipRateLimiter = options.ipRateLimiter;
102
119
  api.#tenantRateLimiter = options.tenantRateLimiter;
103
120
  api.#webhookManager = options.webhookManager;
121
+ api.#passkeyStore = options.passkeyStore;
122
+ api.#sessionManager = options.sessionManager;
104
123
  api.#packageInfo = options.packageInfo ?? {};
105
124
  return api;
106
125
  }
@@ -121,22 +140,62 @@ export class AdminApi {
121
140
  * @returns A `Response` to send to the client.
122
141
  */
123
142
  public async route(req: Request, url: URL, path: string, method: string): Promise<Response> {
124
- // Authenticate every request.
125
- const authError = validateAdminAuth(req, this.#config);
126
- if (authError) {
143
+ // Strip the `/admin/api` prefix for cleaner matching.
144
+ const subPath = path.slice('/admin/api'.length);
145
+
146
+ // Passkey login routes are unauthenticated (challenge-gated instead).
147
+ // They must be checked before the auth gate.
148
+ if (subPath === '/passkeys/login/options' && method === 'POST') {
149
+ return this.#handlePasskeyLoginOptions(req);
150
+ }
151
+ if (subPath === '/passkeys/login/verify' && method === 'POST') {
152
+ return this.#handlePasskeyLoginVerify(req);
153
+ }
154
+ // Allow checking if passkeys exist (for the login screen to decide what to show).
155
+ if (subPath === '/passkeys/status' && method === 'GET') {
156
+ return this.#handlePasskeyStatus();
157
+ }
158
+
159
+ // Authenticate every request (except the passkey login routes above).
160
+ const authResult = validateAdminAuth(req, this.#config, this.#sessionManager);
161
+ if (authResult.error) {
127
162
  // Log failed authentication attempts (401 only, not 404 for disabled admin).
128
163
  // Rate-limited to one audit entry per IP per 60 seconds.
129
164
  // @see https://github.com/enboxorg/enbox/issues/392
130
- if (authError.status === 401) {
165
+ if (authResult.error.status === 401) {
131
166
  this.#auditFailedAuth(req, path);
132
167
  }
133
- return authError;
168
+ return authResult.error;
134
169
  }
135
170
 
136
- // Strip the `/admin/api` prefix for cleaner matching.
137
- const subPath = path.slice('/admin/api'.length);
138
-
139
171
  try {
172
+ // --- Passkey management (authenticated) ---
173
+ // Registration requires static token auth (not session auth).
174
+ if (subPath === '/passkeys/register/options' && method === 'POST') {
175
+ if (authResult.authMethod !== 'token') {
176
+ return Response.json({ error: 'Passkey registration requires static token authentication.' }, { status: 403 });
177
+ }
178
+ return this.#handlePasskeyRegisterOptions(req);
179
+ }
180
+ if (subPath === '/passkeys/register/verify' && method === 'POST') {
181
+ if (authResult.authMethod !== 'token') {
182
+ return Response.json({ error: 'Passkey registration requires static token authentication.' }, { status: 403 });
183
+ }
184
+ return this.#handlePasskeyRegisterVerify(req);
185
+ }
186
+ if (subPath === '/passkeys' && method === 'GET') {
187
+ return this.#handlePasskeyList();
188
+ }
189
+ {
190
+ const match = subPath.match(/^\/passkeys\/([^/]+)$/);
191
+ if (match && method === 'DELETE') {
192
+ if (authResult.authMethod !== 'token') {
193
+ return Response.json({ error: 'Passkey deletion requires static token authentication.' }, { status: 403 });
194
+ }
195
+ return this.#handlePasskeyDelete(match[1]);
196
+ }
197
+ }
198
+
140
199
  // --- Health ---
141
200
  if (method === 'GET' && subPath === '/health') {
142
201
  return this.#handleHealth();
@@ -1237,6 +1296,340 @@ export class AdminApi {
1237
1296
  }
1238
1297
  }
1239
1298
 
1299
+ // ---------------------------------------------------------------------------
1300
+ // Passkey (WebAuthn) handlers
1301
+ // ---------------------------------------------------------------------------
1302
+
1303
+ /**
1304
+ * Returns whether any passkeys are registered. This is an unauthenticated
1305
+ * endpoint so the login screen can decide whether to show the passkey option.
1306
+ */
1307
+ async #handlePasskeyStatus(): Promise<Response> {
1308
+ if (!this.#config.adminToken) {
1309
+ return new Response('Not Found', { status: 404 });
1310
+ }
1311
+
1312
+ const count = this.#passkeyStore ? await this.#passkeyStore.count() : 0;
1313
+ return Response.json({ hasPasskeys: count > 0 });
1314
+ }
1315
+
1316
+ /**
1317
+ * Generates WebAuthn registration options. Requires static token auth.
1318
+ */
1319
+ async #handlePasskeyRegisterOptions(req: Request): Promise<Response> {
1320
+ if (!this.#passkeyStore) {
1321
+ return Response.json(
1322
+ { error: 'Passkey storage is not enabled. Requires a SQL storage backend.' },
1323
+ { status: 501 },
1324
+ );
1325
+ }
1326
+
1327
+ let body: { name?: string };
1328
+ try {
1329
+ body = await req.json() as { name?: string };
1330
+ } catch {
1331
+ body = {};
1332
+ }
1333
+
1334
+ const rpId = this.#getWebAuthnRpId(req);
1335
+ const rpName = this.#config.adminWebAuthnRpName ?? 'DWN Admin';
1336
+
1337
+ // Get existing credentials to exclude during registration.
1338
+ const existing = await this.#passkeyStore.list();
1339
+ const excludeCredentials = existing.map((cred): { id: string; transports?: AuthenticatorTransport[] } => ({
1340
+ id : cred.id,
1341
+ transports : JSON.parse(cred.transports) as AuthenticatorTransport[],
1342
+ }));
1343
+
1344
+ const options = await generateRegistrationOptions({
1345
+ rpName,
1346
+ rpID : rpId,
1347
+ userName : 'admin',
1348
+ userDisplayName : body.name ?? 'DWN Admin',
1349
+ attestationType : 'none',
1350
+ excludeCredentials,
1351
+ authenticatorSelection : {
1352
+ residentKey : 'preferred',
1353
+ userVerification : 'preferred',
1354
+ },
1355
+ });
1356
+
1357
+ // Store the challenge for verification.
1358
+ this.#storeChallenge(options.challenge);
1359
+
1360
+ return Response.json(options);
1361
+ }
1362
+
1363
+ /**
1364
+ * Verifies a WebAuthn registration response and stores the credential.
1365
+ * Requires static token auth.
1366
+ */
1367
+ async #handlePasskeyRegisterVerify(req: Request): Promise<Response> {
1368
+ if (!this.#passkeyStore) {
1369
+ return Response.json(
1370
+ { error: 'Passkey storage is not enabled. Requires a SQL storage backend.' },
1371
+ { status: 501 },
1372
+ );
1373
+ }
1374
+
1375
+ let body: { credential: RegistrationResponseJSON; name?: string };
1376
+ try {
1377
+ body = await req.json() as typeof body;
1378
+ } catch {
1379
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
1380
+ }
1381
+
1382
+ if (!body.credential) {
1383
+ return Response.json({ error: 'credential is required' }, { status: 400 });
1384
+ }
1385
+
1386
+ const rpId = this.#getWebAuthnRpId(req);
1387
+ const expectedOrigin = this.#getWebAuthnOrigin(req);
1388
+
1389
+ let verification;
1390
+ try {
1391
+ verification = await verifyRegistrationResponse({
1392
+ response : body.credential,
1393
+ expectedChallenge : (challenge: string): boolean => this.#consumeChallenge(challenge),
1394
+ expectedOrigin,
1395
+ expectedRPID : rpId,
1396
+ });
1397
+ } catch (err) {
1398
+ log.warn('Passkey registration verification failed:', err);
1399
+ return Response.json({ error: 'Registration verification failed.' }, { status: 400 });
1400
+ }
1401
+
1402
+ if (!verification.verified || !verification.registrationInfo) {
1403
+ return Response.json({ error: 'Registration verification failed.' }, { status: 400 });
1404
+ }
1405
+
1406
+ const { credential } = verification.registrationInfo;
1407
+
1408
+ await this.#passkeyStore.save({
1409
+ id : credential.id,
1410
+ name : body.name || 'Passkey',
1411
+ publicKey : Buffer.from(credential.publicKey).toString('base64url'),
1412
+ counter : credential.counter,
1413
+ transports : JSON.stringify(credential.transports ?? []),
1414
+ createdAt : new Date().toISOString(),
1415
+ lastUsedAt : null,
1416
+ });
1417
+
1418
+ await this.#audit('passkey.register', credential.id, JSON.stringify({ name: body.name || 'Passkey' }));
1419
+
1420
+ return Response.json({ verified: true, id: credential.id }, { status: 201 });
1421
+ }
1422
+
1423
+ /**
1424
+ * Generates WebAuthn authentication options. Unauthenticated.
1425
+ */
1426
+ async #handlePasskeyLoginOptions(_req: Request): Promise<Response> {
1427
+ if (!this.#config.adminToken) {
1428
+ return new Response('Not Found', { status: 404 });
1429
+ }
1430
+
1431
+ if (!this.#passkeyStore) {
1432
+ return Response.json({ error: 'Passkeys are not enabled.' }, { status: 501 });
1433
+ }
1434
+
1435
+ const existing = await this.#passkeyStore.list();
1436
+ if (existing.length === 0) {
1437
+ return Response.json({ error: 'No passkeys registered.' }, { status: 404 });
1438
+ }
1439
+
1440
+ const rpId = this.#getWebAuthnRpId(_req);
1441
+
1442
+ const allowCredentials = existing.map((cred): { id: string; transports?: AuthenticatorTransport[] } => ({
1443
+ id : cred.id,
1444
+ transports : JSON.parse(cred.transports) as AuthenticatorTransport[],
1445
+ }));
1446
+
1447
+ const options = await generateAuthenticationOptions({
1448
+ rpID : rpId,
1449
+ allowCredentials,
1450
+ userVerification : 'preferred',
1451
+ });
1452
+
1453
+ this.#storeChallenge(options.challenge);
1454
+
1455
+ return Response.json(options);
1456
+ }
1457
+
1458
+ /**
1459
+ * Verifies a WebAuthn authentication response and issues a session token.
1460
+ * Unauthenticated.
1461
+ */
1462
+ async #handlePasskeyLoginVerify(req: Request): Promise<Response> {
1463
+ if (!this.#config.adminToken) {
1464
+ return new Response('Not Found', { status: 404 });
1465
+ }
1466
+
1467
+ if (!this.#passkeyStore || !this.#sessionManager) {
1468
+ return Response.json({ error: 'Passkeys are not enabled.' }, { status: 501 });
1469
+ }
1470
+
1471
+ let body: { credential: AuthenticationResponseJSON };
1472
+ try {
1473
+ body = await req.json() as typeof body;
1474
+ } catch {
1475
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
1476
+ }
1477
+
1478
+ if (!body.credential) {
1479
+ return Response.json({ error: 'credential is required' }, { status: 400 });
1480
+ }
1481
+
1482
+ const credentialId = body.credential.id;
1483
+ const storedCredential = await this.#passkeyStore.getById(credentialId);
1484
+ if (!storedCredential) {
1485
+ return Response.json({ error: 'Unknown credential.' }, { status: 400 });
1486
+ }
1487
+
1488
+ const rpId = this.#getWebAuthnRpId(req);
1489
+ const expectedOrigin = this.#getWebAuthnOrigin(req);
1490
+
1491
+ let verification;
1492
+ try {
1493
+ verification = await verifyAuthenticationResponse({
1494
+ response : body.credential,
1495
+ expectedChallenge : (challenge: string): boolean => this.#consumeChallenge(challenge),
1496
+ expectedOrigin,
1497
+ expectedRPID : rpId,
1498
+ credential : {
1499
+ id : storedCredential.id,
1500
+ publicKey : new Uint8Array(Buffer.from(storedCredential.publicKey, 'base64url')),
1501
+ counter : storedCredential.counter,
1502
+ transports : JSON.parse(storedCredential.transports) as AuthenticatorTransport[],
1503
+ },
1504
+ });
1505
+ } catch (err) {
1506
+ log.warn('Passkey authentication verification failed:', err);
1507
+ this.#auditFailedAuth(req, '/passkeys/login/verify');
1508
+ return Response.json({ error: 'Authentication verification failed.' }, { status: 401 });
1509
+ }
1510
+
1511
+ if (!verification.verified) {
1512
+ this.#auditFailedAuth(req, '/passkeys/login/verify');
1513
+ return Response.json({ error: 'Authentication verification failed.' }, { status: 401 });
1514
+ }
1515
+
1516
+ // Update counter for replay protection.
1517
+ await this.#passkeyStore.updateCounter(credentialId, verification.authenticationInfo.newCounter);
1518
+
1519
+ // Issue a session token.
1520
+ const sessionToken = this.#sessionManager.create();
1521
+
1522
+ await this.#audit('passkey.login', credentialId);
1523
+
1524
+ return Response.json({ verified: true, token: sessionToken });
1525
+ }
1526
+
1527
+ /**
1528
+ * Lists all registered passkeys.
1529
+ */
1530
+ async #handlePasskeyList(): Promise<Response> {
1531
+ if (!this.#passkeyStore) {
1532
+ return Response.json(
1533
+ { error: 'Passkey storage is not enabled. Requires a SQL storage backend.' },
1534
+ { status: 501 },
1535
+ );
1536
+ }
1537
+
1538
+ const records = await this.#passkeyStore.list();
1539
+ const passkeys: AdminPasskeySummary[] = records.map((r): AdminPasskeySummary => ({
1540
+ id : r.id,
1541
+ name : r.name,
1542
+ createdAt : r.createdAt,
1543
+ lastUsedAt : r.lastUsedAt,
1544
+ }));
1545
+
1546
+ return Response.json({ passkeys });
1547
+ }
1548
+
1549
+ /**
1550
+ * Deletes a passkey by ID. Requires static token auth.
1551
+ */
1552
+ async #handlePasskeyDelete(id: string): Promise<Response> {
1553
+ if (!this.#passkeyStore) {
1554
+ return Response.json(
1555
+ { error: 'Passkey storage is not enabled. Requires a SQL storage backend.' },
1556
+ { status: 501 },
1557
+ );
1558
+ }
1559
+
1560
+ const deleted = await this.#passkeyStore.delete(id);
1561
+ if (!deleted) {
1562
+ return Response.json({ error: 'Passkey not found' }, { status: 404 });
1563
+ }
1564
+
1565
+ await this.#audit('passkey.delete', id);
1566
+ return Response.json({ success: true, id });
1567
+ }
1568
+
1569
+ /**
1570
+ * Resolves the WebAuthn Relying Party ID from config or the request Host header.
1571
+ */
1572
+ #getWebAuthnRpId(req: Request): string {
1573
+ if (this.#config.adminWebAuthnRpId) {
1574
+ return this.#config.adminWebAuthnRpId;
1575
+ }
1576
+
1577
+ // Extract hostname from request Host header or fall back to baseUrl.
1578
+ const host = req.headers.get('host');
1579
+ if (host) {
1580
+ // Strip port if present.
1581
+ return host.split(':')[0];
1582
+ }
1583
+
1584
+ try {
1585
+ return new URL(this.#config.baseUrl).hostname;
1586
+ } catch {
1587
+ return 'localhost';
1588
+ }
1589
+ }
1590
+
1591
+ /**
1592
+ * Resolves the expected WebAuthn origin from the request.
1593
+ */
1594
+ #getWebAuthnOrigin(req: Request): string {
1595
+ const origin = req.headers.get('origin');
1596
+ if (origin) {
1597
+ return origin;
1598
+ }
1599
+
1600
+ // Fall back to baseUrl.
1601
+ return this.#config.baseUrl;
1602
+ }
1603
+
1604
+ /**
1605
+ * Stores a WebAuthn challenge for later verification.
1606
+ */
1607
+ #storeChallenge(challenge: string): void {
1608
+ // Prune expired challenges first.
1609
+ const now = Date.now();
1610
+ for (const [c, expiry] of this.#challenges) {
1611
+ if (now >= expiry) {
1612
+ this.#challenges.delete(c);
1613
+ }
1614
+ }
1615
+
1616
+ this.#challenges.set(challenge, now + AdminApi.#CHALLENGE_TTL_MS);
1617
+ }
1618
+
1619
+ /**
1620
+ * Consumes a WebAuthn challenge (one-time use). Returns `true` if the
1621
+ * challenge was found and not expired.
1622
+ */
1623
+ #consumeChallenge(challenge: string): boolean {
1624
+ const expiry = this.#challenges.get(challenge);
1625
+ if (expiry === undefined) {
1626
+ return false;
1627
+ }
1628
+
1629
+ this.#challenges.delete(challenge);
1630
+ return Date.now() < expiry;
1631
+ }
1632
+
1240
1633
  // ---------------------------------------------------------------------------
1241
1634
  // Helpers
1242
1635
  // ---------------------------------------------------------------------------
@@ -1,39 +1,68 @@
1
+ import type { AdminSessionManager } from './admin-session.js';
1
2
  import type { DwnServerConfig } from '../config.js';
2
3
 
3
4
  import { timingSafeEqual } from 'crypto';
4
5
 
6
+ /**
7
+ * The result of admin authentication.
8
+ *
9
+ * When authentication succeeds, `authMethod` indicates how the caller
10
+ * authenticated:
11
+ * - `'token'` — static bearer token (from `DWN_ADMIN_TOKEN`)
12
+ * - `'session'` — passkey-derived session token
13
+ */
14
+ export type AdminAuthResult = {
15
+ /** `null` when auth succeeded, or a `Response` to send back on failure. */
16
+ error : Response | null;
17
+ /** How the caller authenticated. Only set when `error` is `null`. */
18
+ authMethod? : 'token' | 'session';
19
+ };
20
+
5
21
  /**
6
22
  * Validates the admin bearer token from the `Authorization` header.
7
23
  *
8
- * @returns `null` if authentication succeeds, or a `Response` with the appropriate
9
- * error status (404 if admin is disabled, 401 if credentials are missing/invalid).
24
+ * Supports two authentication methods:
25
+ * 1. **Static bearer token** the original `DWN_ADMIN_TOKEN`
26
+ * 2. **Session token** — issued after a successful WebAuthn passkey login
27
+ *
28
+ * @returns An {@link AdminAuthResult}. When `error` is `null`, auth passed.
10
29
  */
11
- export function validateAdminAuth(req: Request, config: DwnServerConfig): Response | null {
30
+ export function validateAdminAuth(
31
+ req: Request,
32
+ config: DwnServerConfig,
33
+ sessionManager?: AdminSessionManager,
34
+ ): AdminAuthResult {
12
35
  const expectedToken = config.adminToken;
13
36
 
14
37
  // If no admin token is configured, the admin API is disabled.
15
38
  // Return 404 to avoid revealing the endpoint exists.
16
39
  if (!expectedToken) {
17
- return new Response('Not Found', { status: 404 });
40
+ return { error: new Response('Not Found', { status: 404 }) };
18
41
  }
19
42
 
20
43
  const authHeader = req.headers.get('authorization');
21
44
  if (!authHeader) {
22
- return new Response('Unauthorized', { status: 401 });
45
+ return { error: new Response('Unauthorized', { status: 401 }) };
23
46
  }
24
47
 
25
48
  // Expect "Bearer <token>" format.
26
49
  if (!authHeader.startsWith('Bearer ')) {
27
- return new Response('Unauthorized', { status: 401 });
50
+ return { error: new Response('Unauthorized', { status: 401 }) };
28
51
  }
29
52
 
30
53
  const suppliedToken = authHeader.slice('Bearer '.length);
31
54
 
32
- if (!constantTimeEquals(expectedToken, suppliedToken)) {
33
- return new Response('Unauthorized', { status: 401 });
55
+ // Try static token first.
56
+ if (constantTimeEquals(expectedToken, suppliedToken)) {
57
+ return { error: null, authMethod: 'token' };
58
+ }
59
+
60
+ // Try session token if a session manager is available.
61
+ if (sessionManager && sessionManager.validate(suppliedToken)) {
62
+ return { error: null, authMethod: 'session' };
34
63
  }
35
64
 
36
- return null; // auth passed
65
+ return { error: new Response('Unauthorized', { status: 401 }) };
37
66
  }
38
67
 
39
68
  /**