@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.
- package/dist/esm/src/admin/admin-api.d.ts +5 -1
- package/dist/esm/src/admin/admin-api.d.ts.map +1 -1
- package/dist/esm/src/admin/admin-api.js +327 -7
- package/dist/esm/src/admin/admin-api.js.map +1 -1
- package/dist/esm/src/admin/admin-auth.d.ts +21 -3
- package/dist/esm/src/admin/admin-auth.d.ts.map +1 -1
- package/dist/esm/src/admin/admin-auth.js +17 -9
- package/dist/esm/src/admin/admin-auth.js.map +1 -1
- package/dist/esm/src/admin/admin-passkey-store.d.ts +68 -0
- package/dist/esm/src/admin/admin-passkey-store.d.ts.map +1 -0
- package/dist/esm/src/admin/admin-passkey-store.js +132 -0
- package/dist/esm/src/admin/admin-passkey-store.js.map +1 -0
- package/dist/esm/src/admin/admin-session.d.ts +35 -0
- package/dist/esm/src/admin/admin-session.d.ts.map +1 -0
- package/dist/esm/src/admin/admin-session.js +91 -0
- package/dist/esm/src/admin/admin-session.js.map +1 -0
- package/dist/esm/src/admin/admin-store.d.ts +4 -0
- package/dist/esm/src/admin/admin-store.d.ts.map +1 -1
- package/dist/esm/src/admin/admin-store.js +6 -2
- package/dist/esm/src/admin/admin-store.js.map +1 -1
- package/dist/esm/src/admin/audit-log.d.ts.map +1 -1
- package/dist/esm/src/admin/audit-log.js +5 -43
- package/dist/esm/src/admin/audit-log.js.map +1 -1
- package/dist/esm/src/admin/index.d.ts +5 -1
- package/dist/esm/src/admin/index.d.ts.map +1 -1
- package/dist/esm/src/admin/index.js +2 -0
- package/dist/esm/src/admin/index.js.map +1 -1
- package/dist/esm/src/admin/types.d.ts +22 -0
- package/dist/esm/src/admin/types.d.ts.map +1 -1
- package/dist/esm/src/admin/webhook-manager.d.ts.map +1 -1
- package/dist/esm/src/admin/webhook-manager.js +11 -10
- package/dist/esm/src/admin/webhook-manager.js.map +1 -1
- package/dist/esm/src/config.d.ts +18 -0
- package/dist/esm/src/config.d.ts.map +1 -1
- package/dist/esm/src/config.js +18 -0
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/connect/connect-server.d.ts +75 -0
- package/dist/esm/src/connect/connect-server.d.ts.map +1 -0
- package/dist/esm/src/{web5-connect/web5-connect-server.js → connect/connect-server.js} +32 -24
- package/dist/esm/src/connect/connect-server.js.map +1 -0
- package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.d.ts +11 -1
- package/dist/esm/src/connect/sql-ttl-cache.d.ts.map +1 -0
- package/dist/esm/src/{web5-connect → connect}/sql-ttl-cache.js +19 -20
- package/dist/esm/src/connect/sql-ttl-cache.js.map +1 -0
- package/dist/esm/src/dwn-server.d.ts.map +1 -1
- package/dist/esm/src/dwn-server.js +46 -11
- package/dist/esm/src/dwn-server.js.map +1 -1
- package/dist/esm/src/http-api.d.ts +6 -2
- package/dist/esm/src/http-api.d.ts.map +1 -1
- package/dist/esm/src/http-api.js +31 -17
- package/dist/esm/src/http-api.js.map +1 -1
- package/dist/esm/src/migrations/001-initial-server-schema.d.ts +21 -0
- package/dist/esm/src/migrations/001-initial-server-schema.d.ts.map +1 -0
- package/dist/esm/src/migrations/001-initial-server-schema.js +97 -0
- package/dist/esm/src/migrations/001-initial-server-schema.js.map +1 -0
- package/dist/esm/src/migrations/index.d.ts +13 -0
- package/dist/esm/src/migrations/index.d.ts.map +1 -0
- package/dist/esm/src/migrations/index.js +5 -0
- package/dist/esm/src/migrations/index.js.map +1 -0
- package/dist/esm/src/registration/registration-store.d.ts +4 -0
- package/dist/esm/src/registration/registration-store.d.ts.map +1 -1
- package/dist/esm/src/registration/registration-store.js +11 -34
- package/dist/esm/src/registration/registration-store.js.map +1 -1
- package/dist/esm/src/server-migration-runner.d.ts +23 -0
- package/dist/esm/src/server-migration-runner.d.ts.map +1 -0
- package/dist/esm/src/server-migration-runner.js +57 -0
- package/dist/esm/src/server-migration-runner.js.map +1 -0
- package/dist/esm/src/storage.d.ts +15 -0
- package/dist/esm/src/storage.d.ts.map +1 -1
- package/dist/esm/src/storage.js +135 -17
- package/dist/esm/src/storage.js.map +1 -1
- package/package.json +8 -27
- package/src/admin/admin-api.ts +403 -10
- package/src/admin/admin-auth.ts +38 -9
- package/src/admin/admin-passkey-store.ts +190 -0
- package/src/admin/admin-session.ts +116 -0
- package/src/admin/admin-store.ts +6 -2
- package/src/admin/audit-log.ts +7 -44
- package/src/admin/index.ts +5 -0
- package/src/admin/types.ts +28 -0
- package/src/admin/webhook-manager.ts +12 -10
- package/src/config.ts +21 -0
- package/src/connect/connect-server.ts +150 -0
- package/src/{web5-connect → connect}/sql-ttl-cache.ts +21 -22
- package/src/dwn-server.ts +49 -11
- package/src/http-api.ts +37 -18
- package/src/migrations/001-initial-server-schema.ts +114 -0
- package/src/migrations/index.ts +18 -0
- package/src/registration/registration-store.ts +13 -36
- package/src/server-migration-runner.ts +74 -0
- package/src/storage.ts +145 -17
- package/dist/esm/src/web5-connect/sql-ttl-cache.d.ts.map +0 -1
- package/dist/esm/src/web5-connect/sql-ttl-cache.js.map +0 -1
- package/dist/esm/src/web5-connect/web5-connect-server.d.ts +0 -58
- package/dist/esm/src/web5-connect/web5-connect-server.d.ts.map +0 -1
- package/dist/esm/src/web5-connect/web5-connect-server.js.map +0 -1
- package/src/web5-connect/web5-connect-server.ts +0 -123
package/src/admin/admin-api.ts
CHANGED
|
@@ -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
|
-
//
|
|
125
|
-
const
|
|
126
|
-
|
|
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 (
|
|
165
|
+
if (authResult.error.status === 401) {
|
|
131
166
|
this.#auditFailedAuth(req, path);
|
|
132
167
|
}
|
|
133
|
-
return
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/admin/admin-auth.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
65
|
+
return { error: new Response('Unauthorized', { status: 401 }) };
|
|
37
66
|
}
|
|
38
67
|
|
|
39
68
|
/**
|