passkey-magic 0.1.0

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.
@@ -0,0 +1,823 @@
1
+ import { n as generateToken, r as isValidEmail, t as generateId } from "./crypto-KHRNe6EL.mjs";
2
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
3
+ //#region src/events.ts
4
+ /** Typed event emitter for auth lifecycle events. */
5
+ var AuthEmitter = class {
6
+ listeners = /* @__PURE__ */ new Map();
7
+ /**
8
+ * Subscribe to an auth event. Returns an unsubscribe function.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const unsub = auth.on('session:created', ({ user, method }) => {
13
+ * console.log(`${user.id} logged in via ${method}`)
14
+ * })
15
+ * unsub() // stop listening
16
+ * ```
17
+ */
18
+ on(event, handler) {
19
+ if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
20
+ this.listeners.get(event).add(handler);
21
+ return () => {
22
+ this.listeners.get(event)?.delete(handler);
23
+ };
24
+ }
25
+ /** Emit an event to all registered handlers. */
26
+ emit(event, data) {
27
+ const handlers = this.listeners.get(event);
28
+ if (handlers) for (const handler of handlers) handler(data);
29
+ }
30
+ };
31
+ //#endregion
32
+ //#region src/server/session.ts
33
+ function createSessionManager(storage, opts) {
34
+ const id = opts.generateId ?? generateId;
35
+ return {
36
+ async create(userId, meta) {
37
+ const session = {
38
+ id: id(),
39
+ token: generateToken(),
40
+ userId,
41
+ expiresAt: new Date(Date.now() + opts.ttl),
42
+ createdAt: /* @__PURE__ */ new Date(),
43
+ userAgent: meta?.userAgent,
44
+ ipAddress: meta?.ipAddress
45
+ };
46
+ return storage.createSession(session);
47
+ },
48
+ async validate(token) {
49
+ const session = await storage.getSessionByToken(token);
50
+ if (!session) return null;
51
+ if (/* @__PURE__ */ new Date() > session.expiresAt) {
52
+ await storage.deleteSession(session.id);
53
+ return null;
54
+ }
55
+ return session;
56
+ },
57
+ async listByUser(userId) {
58
+ return storage.getSessionsByUserId(userId);
59
+ },
60
+ async revoke(id) {
61
+ await storage.deleteSession(id);
62
+ },
63
+ async revokeAll(userId) {
64
+ await storage.deleteSessionsByUserId(userId);
65
+ }
66
+ };
67
+ }
68
+ //#endregion
69
+ //#region src/server/passkey.ts
70
+ function createPasskeyManager(storage, config) {
71
+ const generateId$3 = config.generateId ?? generateId;
72
+ const challengeTTL = config.challengeTTL ?? 6e4;
73
+ return {
74
+ async generateRegistrationOptions(params) {
75
+ const userId = params.userId ?? generateId$3();
76
+ const userName = params.userName ?? params.email ?? userId;
77
+ const existingCreds = await storage.getCredentialsByUserId(userId);
78
+ const options = await generateRegistrationOptions({
79
+ rpName: config.rpName,
80
+ rpID: config.rpID,
81
+ userName,
82
+ excludeCredentials: existingCreds.map((c) => ({
83
+ id: c.id,
84
+ transports: c.transports
85
+ })),
86
+ authenticatorSelection: {
87
+ residentKey: "preferred",
88
+ userVerification: "preferred"
89
+ }
90
+ });
91
+ await storage.storeChallenge(`reg:${userId}`, options.challenge, challengeTTL);
92
+ return {
93
+ options,
94
+ userId
95
+ };
96
+ },
97
+ async verifyRegistration({ userId, response }) {
98
+ const expectedChallenge = await storage.getChallenge(`reg:${userId}`);
99
+ if (!expectedChallenge) throw new Error("Registration challenge expired or not found");
100
+ const verification = await verifyRegistrationResponse({
101
+ response,
102
+ expectedChallenge,
103
+ expectedOrigin: Array.isArray(config.origin) ? config.origin : [config.origin],
104
+ expectedRPID: config.rpID
105
+ });
106
+ if (!verification.verified || !verification.registrationInfo) throw new Error("Registration verification failed");
107
+ await storage.deleteChallenge(`reg:${userId}`);
108
+ const { credential: regCred, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
109
+ let user = await storage.getUserById(userId);
110
+ if (!user) user = await storage.createUser({
111
+ id: userId,
112
+ createdAt: /* @__PURE__ */ new Date()
113
+ });
114
+ const credential = {
115
+ id: regCred.id,
116
+ userId,
117
+ publicKey: regCred.publicKey,
118
+ counter: regCred.counter,
119
+ deviceType: credentialDeviceType,
120
+ backedUp: credentialBackedUp,
121
+ transports: regCred.transports,
122
+ createdAt: /* @__PURE__ */ new Date()
123
+ };
124
+ await storage.createCredential(credential);
125
+ return {
126
+ user,
127
+ credential
128
+ };
129
+ },
130
+ async generateAuthenticationOptions(params) {
131
+ let allowCredentials;
132
+ if (params?.userId) allowCredentials = (await storage.getCredentialsByUserId(params.userId)).map((c) => ({
133
+ id: c.id,
134
+ transports: c.transports
135
+ }));
136
+ const options = await generateAuthenticationOptions({
137
+ rpID: config.rpID,
138
+ allowCredentials,
139
+ userVerification: "preferred"
140
+ });
141
+ await storage.storeChallenge(`auth:${options.challenge}`, options.challenge, challengeTTL);
142
+ return { options };
143
+ },
144
+ async verifyAuthentication({ response }) {
145
+ const credential = await storage.getCredentialById(response.id);
146
+ if (!credential) throw new Error("Credential not found");
147
+ const clientData = JSON.parse(new TextDecoder().decode(base64urlToBuffer(response.response.clientDataJSON)));
148
+ const storedChallenge = await storage.getChallenge(`auth:${clientData.challenge}`);
149
+ if (!storedChallenge) throw new Error("Authentication challenge expired or not found");
150
+ const verification = await verifyAuthenticationResponse({
151
+ response,
152
+ expectedChallenge: storedChallenge,
153
+ expectedOrigin: Array.isArray(config.origin) ? config.origin : [config.origin],
154
+ expectedRPID: config.rpID,
155
+ credential: {
156
+ id: credential.id,
157
+ publicKey: new Uint8Array(credential.publicKey),
158
+ counter: credential.counter,
159
+ transports: credential.transports
160
+ }
161
+ });
162
+ if (!verification.verified) throw new Error("Authentication verification failed");
163
+ await storage.deleteChallenge(`auth:${clientData.challenge}`);
164
+ await storage.updateCredential(credential.id, { counter: verification.authenticationInfo.newCounter });
165
+ const user = await storage.getUserById(credential.userId);
166
+ if (!user) throw new Error("User not found for credential");
167
+ return { user };
168
+ }
169
+ };
170
+ }
171
+ function base64urlToBuffer(base64url) {
172
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
173
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
174
+ const binary = atob(padded);
175
+ const bytes = new Uint8Array(binary.length);
176
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
177
+ return bytes.buffer;
178
+ }
179
+ //#endregion
180
+ //#region src/server/magic-link.ts
181
+ function createMagicLinkManager(storage, emailAdapter, opts) {
182
+ const generateId$2 = opts.generateId ?? generateId;
183
+ return {
184
+ async send({ email }) {
185
+ if (!isValidEmail(email)) throw new Error("Invalid email address");
186
+ const token = generateToken(32);
187
+ await storage.storeMagicLink(token, email, opts.ttl);
188
+ const url = `${opts.magicLinkURL}?token=${encodeURIComponent(token)}`;
189
+ await emailAdapter.sendMagicLink(email, url, token);
190
+ return { sent: true };
191
+ },
192
+ async verify({ token }) {
193
+ if (!token || typeof token !== "string") throw new Error("Invalid magic link token");
194
+ const entry = await storage.getMagicLink(token);
195
+ if (!entry) throw new Error("Magic link expired or invalid");
196
+ await storage.deleteMagicLink(token);
197
+ let user = await storage.getUserByEmail(entry.email);
198
+ let isNewUser = false;
199
+ if (!user) {
200
+ user = await storage.createUser({
201
+ id: generateId$2(),
202
+ email: entry.email,
203
+ createdAt: /* @__PURE__ */ new Date()
204
+ });
205
+ isNewUser = true;
206
+ }
207
+ return {
208
+ user,
209
+ isNewUser
210
+ };
211
+ }
212
+ };
213
+ }
214
+ //#endregion
215
+ //#region src/server/qr-session.ts
216
+ function createQRSessionManager(storage, opts) {
217
+ const generateId$1 = opts.generateId ?? generateId;
218
+ return {
219
+ async create() {
220
+ const session = {
221
+ id: generateId$1(),
222
+ state: "pending",
223
+ expiresAt: new Date(Date.now() + opts.ttl),
224
+ createdAt: /* @__PURE__ */ new Date()
225
+ };
226
+ await storage.createQRSession(session);
227
+ return { sessionId: session.id };
228
+ },
229
+ async getStatus(sessionId) {
230
+ const session = await storage.getQRSession(sessionId);
231
+ if (!session) throw new Error("QR session not found");
232
+ if (/* @__PURE__ */ new Date() > session.expiresAt && session.state === "pending") {
233
+ await storage.updateQRSession(sessionId, { state: "expired" });
234
+ return { state: "expired" };
235
+ }
236
+ const status = { state: session.state };
237
+ if (session.state === "authenticated" && session.sessionToken) status.session = {
238
+ token: session.sessionToken,
239
+ expiresAt: session.expiresAt.toISOString()
240
+ };
241
+ return status;
242
+ },
243
+ async markScanned(sessionId) {
244
+ const session = await storage.getQRSession(sessionId);
245
+ if (!session) throw new Error("QR session not found");
246
+ if (session.state !== "pending") throw new Error(`QR session is ${session.state}, expected pending`);
247
+ await storage.updateQRSession(sessionId, { state: "scanned" });
248
+ },
249
+ async complete(sessionId, userId) {
250
+ const session = await storage.getQRSession(sessionId);
251
+ if (!session) throw new Error("QR session not found");
252
+ if (session.state !== "pending" && session.state !== "scanned") throw new Error(`QR session is ${session.state}, cannot complete`);
253
+ await storage.updateQRSession(sessionId, {
254
+ state: "authenticated",
255
+ userId
256
+ });
257
+ }
258
+ };
259
+ }
260
+ //#endregion
261
+ //#region src/server/handler.ts
262
+ /**
263
+ * Create a Web Standard `Request → Response` handler for all auth routes.
264
+ *
265
+ * Routes:
266
+ * ```
267
+ * POST /passkey/register/options — Start passkey registration
268
+ * POST /passkey/register/verify — Complete passkey registration
269
+ * POST /passkey/authenticate/options — Start passkey authentication
270
+ * POST /passkey/authenticate/verify — Complete passkey authentication
271
+ * POST /passkey/add/options — Start adding passkey (authed)
272
+ * POST /passkey/add/verify — Complete adding passkey (authed)
273
+ * POST /magic-link/send — Send magic link email
274
+ * POST /magic-link/verify — Verify magic link token
275
+ * POST /qr/create — Create QR login session
276
+ * GET /qr/:id/status — Poll QR session status
277
+ * POST /qr/:id/scanned — Mark QR session scanned
278
+ * POST /qr/:id/complete — Complete QR session
279
+ * GET /session — Validate current session (Bearer token)
280
+ * DELETE /session — Revoke current session (Bearer token)
281
+ * GET /account — Get current user (authed)
282
+ * GET /account/sessions — List user sessions (authed)
283
+ * DELETE /account/sessions/:id — Revoke specific session (authed)
284
+ * DELETE /account/sessions — Revoke all sessions (authed)
285
+ * GET /account/credentials — List user passkeys (authed)
286
+ * PATCH /account/credentials/:id — Update passkey label (authed)
287
+ * DELETE /account/credentials/:id — Remove passkey (authed)
288
+ * POST /account/link-email — Link email to account (authed)
289
+ * POST /account/unlink-email — Unlink email from account (authed)
290
+ * POST /account/email-available — Check email availability
291
+ * DELETE /account — Delete account (authed)
292
+ * ```
293
+ */
294
+ function createHandler(auth, opts = {}) {
295
+ const prefix = (opts.pathPrefix ?? "/auth").replace(/\/$/, "");
296
+ async function authenticate(request) {
297
+ const token = extractBearerToken(request);
298
+ if (!token) return null;
299
+ return auth.validateSession(token);
300
+ }
301
+ async function requireAuth(request) {
302
+ const result = await authenticate(request);
303
+ if (!result) throw new HttpError(401, "Authentication required");
304
+ return result;
305
+ }
306
+ return async (request) => {
307
+ const url = new URL(request.url, "http://localhost");
308
+ const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : null;
309
+ if (path === null) return json({ error: "Not found" }, 404);
310
+ try {
311
+ if (path === "/passkey/register/options" && request.method === "POST") {
312
+ const body = await readJSON(request);
313
+ return json(await auth.generateRegistrationOptions({
314
+ userId: expectOptionalString(body, "userId"),
315
+ email: expectOptionalString(body, "email"),
316
+ userName: expectOptionalString(body, "userName")
317
+ }));
318
+ }
319
+ if (path === "/passkey/register/verify" && request.method === "POST") {
320
+ const body = await readJSON(request);
321
+ return json(await auth.verifyRegistration({
322
+ userId: expectString(body, "userId"),
323
+ response: expectObject(body, "response")
324
+ }));
325
+ }
326
+ if (path === "/passkey/authenticate/options" && request.method === "POST") {
327
+ const body = await readJSON(request);
328
+ return json(await auth.generateAuthenticationOptions({ userId: expectOptionalString(body, "userId") }));
329
+ }
330
+ if (path === "/passkey/authenticate/verify" && request.method === "POST") {
331
+ const body = await readJSON(request);
332
+ return json(await auth.verifyAuthentication({ response: expectObject(body, "response") }));
333
+ }
334
+ if (path === "/passkey/add/options" && request.method === "POST") {
335
+ const { user } = await requireAuth(request);
336
+ const body = await readJSON(request);
337
+ return json(await auth.addPasskey({
338
+ userId: user.id,
339
+ userName: expectOptionalString(body, "userName")
340
+ }));
341
+ }
342
+ if (path === "/passkey/add/verify" && request.method === "POST") {
343
+ const { user } = await requireAuth(request);
344
+ const body = await readJSON(request);
345
+ return json(await auth.verifyAddPasskey({
346
+ userId: user.id,
347
+ response: expectObject(body, "response")
348
+ }));
349
+ }
350
+ if (path === "/magic-link/send" && request.method === "POST") {
351
+ if (!("sendMagicLink" in auth)) return json({ error: "Magic link not configured" }, 400);
352
+ const body = await readJSON(request);
353
+ return json(await auth.sendMagicLink({ email: expectString(body, "email") }));
354
+ }
355
+ if (path === "/magic-link/verify" && request.method === "POST") {
356
+ if (!("verifyMagicLink" in auth)) return json({ error: "Magic link not configured" }, 400);
357
+ const body = await readJSON(request);
358
+ return json(await auth.verifyMagicLink({ token: expectString(body, "token") }));
359
+ }
360
+ if (path === "/qr/create" && request.method === "POST") return json(await auth.createQRSession());
361
+ const qrStatusMatch = path.match(/^\/qr\/([^/]+)\/status$/);
362
+ if (qrStatusMatch && request.method === "GET") return json(await auth.getQRSessionStatus(qrStatusMatch[1]));
363
+ const qrScannedMatch = path.match(/^\/qr\/([^/]+)\/scanned$/);
364
+ if (qrScannedMatch && request.method === "POST") {
365
+ await auth.markQRSessionScanned(qrScannedMatch[1]);
366
+ return json({ ok: true });
367
+ }
368
+ const qrCompleteMatch = path.match(/^\/qr\/([^/]+)\/complete$/);
369
+ if (qrCompleteMatch && request.method === "POST") {
370
+ const body = await readJSON(request);
371
+ return json(await auth.completeQRSession({
372
+ sessionId: qrCompleteMatch[1],
373
+ response: expectObject(body, "response")
374
+ }));
375
+ }
376
+ if (path === "/session" && request.method === "GET") return json(await requireAuth(request));
377
+ if (path === "/session" && request.method === "DELETE") {
378
+ const token = extractBearerToken(request);
379
+ if (!token) return json({ error: "No session token" }, 401);
380
+ await auth.revokeSession(token);
381
+ return json({ ok: true });
382
+ }
383
+ if (path === "/account" && request.method === "GET") {
384
+ const { user } = await requireAuth(request);
385
+ return json({ user });
386
+ }
387
+ if (path === "/account" && request.method === "DELETE") {
388
+ const { user } = await requireAuth(request);
389
+ await auth.deleteAccount(user.id);
390
+ return json({ ok: true });
391
+ }
392
+ if (path === "/account/sessions" && request.method === "GET") {
393
+ const { user } = await requireAuth(request);
394
+ return json({ sessions: await auth.getUserSessions(user.id) });
395
+ }
396
+ if (path === "/account/sessions" && request.method === "DELETE") {
397
+ const { user } = await requireAuth(request);
398
+ await auth.revokeAllSessions(user.id);
399
+ return json({ ok: true });
400
+ }
401
+ const sessionDeleteMatch = path.match(/^\/account\/sessions\/([^/]+)$/);
402
+ if (sessionDeleteMatch && request.method === "DELETE") {
403
+ await requireAuth(request);
404
+ await auth.revokeSessionById(sessionDeleteMatch[1]);
405
+ return json({ ok: true });
406
+ }
407
+ if (path === "/account/credentials" && request.method === "GET") {
408
+ const { user } = await requireAuth(request);
409
+ return json({ credentials: await auth.getUserCredentials(user.id) });
410
+ }
411
+ const credPatchMatch = path.match(/^\/account\/credentials\/([^/]+)$/);
412
+ if (credPatchMatch && request.method === "PATCH") {
413
+ await requireAuth(request);
414
+ const body = await readJSON(request);
415
+ await auth.updateCredential({
416
+ credentialId: credPatchMatch[1],
417
+ label: expectString(body, "label")
418
+ });
419
+ return json({ ok: true });
420
+ }
421
+ const credDeleteMatch = path.match(/^\/account\/credentials\/([^/]+)$/);
422
+ if (credDeleteMatch && request.method === "DELETE") {
423
+ await requireAuth(request);
424
+ await auth.removeCredential(credDeleteMatch[1]);
425
+ return json({ ok: true });
426
+ }
427
+ if (path === "/account/link-email" && request.method === "POST") {
428
+ const { user } = await requireAuth(request);
429
+ const body = await readJSON(request);
430
+ return json(await auth.linkEmail({
431
+ userId: user.id,
432
+ email: expectString(body, "email")
433
+ }));
434
+ }
435
+ if (path === "/account/unlink-email" && request.method === "POST") {
436
+ const { user } = await requireAuth(request);
437
+ return json(await auth.unlinkEmail({ userId: user.id }));
438
+ }
439
+ if (path === "/account/email-available" && request.method === "POST") {
440
+ const body = await readJSON(request);
441
+ return json({ available: await auth.isEmailAvailable(expectString(body, "email")) });
442
+ }
443
+ return json({ error: "Not found" }, 404);
444
+ } catch (err) {
445
+ if (err instanceof HttpError) return json({ error: err.message }, err.status);
446
+ const message = err instanceof Error ? err.message : "Internal error";
447
+ const status = isClientError(message) ? 400 : 500;
448
+ return json({ error: message }, status);
449
+ }
450
+ };
451
+ }
452
+ var HttpError = class extends Error {
453
+ constructor(status, message) {
454
+ super(message);
455
+ this.status = status;
456
+ }
457
+ };
458
+ function json(data, status = 200) {
459
+ return new Response(JSON.stringify(data), {
460
+ status,
461
+ headers: { "Content-Type": "application/json" }
462
+ });
463
+ }
464
+ function extractBearerToken(request) {
465
+ const header = request.headers.get("Authorization");
466
+ if (header?.startsWith("Bearer ")) return header.slice(7);
467
+ return null;
468
+ }
469
+ async function readJSON(request) {
470
+ try {
471
+ const body = await request.json();
472
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new HttpError(400, "Request body must be a JSON object");
473
+ return body;
474
+ } catch (err) {
475
+ if (err instanceof HttpError) throw err;
476
+ throw new HttpError(400, "Invalid JSON in request body");
477
+ }
478
+ }
479
+ function expectString(body, key) {
480
+ const value = body[key];
481
+ if (typeof value !== "string" || value.length === 0) throw new HttpError(400, `Missing or invalid field: ${key}`);
482
+ return value;
483
+ }
484
+ function expectOptionalString(body, key) {
485
+ const value = body[key];
486
+ if (value === void 0 || value === null) return void 0;
487
+ if (typeof value !== "string") throw new HttpError(400, `Invalid field: ${key} (expected string)`);
488
+ return value;
489
+ }
490
+ function expectObject(body, key) {
491
+ const value = body[key];
492
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new HttpError(400, `Missing or invalid field: ${key}`);
493
+ return value;
494
+ }
495
+ /** Known client-side errors that should return 400 instead of 500. */
496
+ function isClientError(message) {
497
+ const patterns = [
498
+ "not found",
499
+ "expired",
500
+ "invalid",
501
+ "blocked",
502
+ "already",
503
+ "cannot remove",
504
+ "cannot unlink",
505
+ "no email",
506
+ "no passkey"
507
+ ];
508
+ const lower = message.toLowerCase();
509
+ return patterns.some((p) => lower.includes(p));
510
+ }
511
+ //#endregion
512
+ //#region src/server/index.ts
513
+ const SEVEN_DAYS = 10080 * 60 * 1e3;
514
+ const SIXTY_SECONDS = 60 * 1e3;
515
+ const FIFTEEN_MINUTES = 900 * 1e3;
516
+ const FIVE_MINUTES = 300 * 1e3;
517
+ /**
518
+ * Create a passkey-magic auth instance.
519
+ *
520
+ * @example
521
+ * ```ts
522
+ * const auth = createAuth({
523
+ * rpName: 'My App',
524
+ * rpID: 'example.com',
525
+ * origin: 'https://example.com',
526
+ * storage: memoryAdapter(),
527
+ * })
528
+ * ```
529
+ *
530
+ * @example With magic link fallback:
531
+ * ```ts
532
+ * const auth = createAuth({
533
+ * rpName: 'My App',
534
+ * rpID: 'example.com',
535
+ * origin: 'https://example.com',
536
+ * storage: memoryAdapter(),
537
+ * email: myEmailAdapter,
538
+ * magicLinkURL: 'https://example.com/auth/verify',
539
+ * })
540
+ * auth.sendMagicLink({ email: 'user@example.com' }) // ✓ type-safe
541
+ * ```
542
+ */
543
+ function createAuth(config) {
544
+ const emitter = new AuthEmitter();
545
+ const hooks = config.hooks ?? {};
546
+ const sessions = createSessionManager(config.storage, {
547
+ ttl: config.sessionTTL ?? SEVEN_DAYS,
548
+ generateId: config.generateId
549
+ });
550
+ const passkeys = createPasskeyManager(config.storage, {
551
+ rpName: config.rpName,
552
+ rpID: config.rpID,
553
+ origin: config.origin,
554
+ challengeTTL: config.challengeTTL ?? SIXTY_SECONDS,
555
+ generateId: config.generateId
556
+ });
557
+ const qrSessions = createQRSessionManager(config.storage, {
558
+ ttl: config.qrSessionTTL ?? FIVE_MINUTES,
559
+ generateId: config.generateId
560
+ });
561
+ const base = {
562
+ async generateRegistrationOptions(params) {
563
+ if (hooks.beforeRegister) {
564
+ if (await hooks.beforeRegister({ email: params.email }) === false) throw new Error("Registration blocked by hook");
565
+ }
566
+ return passkeys.generateRegistrationOptions(params);
567
+ },
568
+ async verifyRegistration({ userId, response }) {
569
+ const existingUser = await config.storage.getUserById(userId);
570
+ const { user, credential } = await passkeys.verifyRegistration({
571
+ userId,
572
+ response
573
+ });
574
+ const session = await sessions.create(user.id);
575
+ if (!existingUser) emitter.emit("user:created", { user });
576
+ emitter.emit("credential:created", {
577
+ credential,
578
+ user
579
+ });
580
+ emitter.emit("session:created", {
581
+ session,
582
+ user,
583
+ method: "passkey"
584
+ });
585
+ if (hooks.afterRegister) await hooks.afterRegister({
586
+ user,
587
+ credential
588
+ });
589
+ return {
590
+ method: "passkey",
591
+ user,
592
+ session,
593
+ credential
594
+ };
595
+ },
596
+ async generateAuthenticationOptions(params) {
597
+ if (hooks.beforeAuthenticate) {
598
+ if (await hooks.beforeAuthenticate({ credentialId: params?.userId }) === false) throw new Error("Authentication blocked by hook");
599
+ }
600
+ return passkeys.generateAuthenticationOptions(params);
601
+ },
602
+ async verifyAuthentication({ response }) {
603
+ const { user } = await passkeys.verifyAuthentication({ response });
604
+ const session = await sessions.create(user.id);
605
+ emitter.emit("session:created", {
606
+ session,
607
+ user,
608
+ method: "passkey"
609
+ });
610
+ if (hooks.afterAuthenticate) await hooks.afterAuthenticate({
611
+ user,
612
+ session
613
+ });
614
+ return {
615
+ method: "passkey",
616
+ user,
617
+ session
618
+ };
619
+ },
620
+ async addPasskey({ userId, userName }) {
621
+ const user = await config.storage.getUserById(userId);
622
+ if (!user) throw new Error("User not found");
623
+ const { options } = await passkeys.generateRegistrationOptions({
624
+ userId,
625
+ email: user.email,
626
+ userName: userName ?? user.email ?? userId
627
+ });
628
+ return { options };
629
+ },
630
+ async verifyAddPasskey({ userId, response }) {
631
+ const { user, credential } = await passkeys.verifyRegistration({
632
+ userId,
633
+ response
634
+ });
635
+ emitter.emit("credential:created", {
636
+ credential,
637
+ user
638
+ });
639
+ return { credential };
640
+ },
641
+ async updateCredential({ credentialId, label }) {
642
+ const cred = await config.storage.getCredentialById(credentialId);
643
+ if (!cred) throw new Error("Credential not found");
644
+ await config.storage.updateCredential(credentialId, { label });
645
+ emitter.emit("credential:updated", {
646
+ credentialId,
647
+ userId: cred.userId
648
+ });
649
+ },
650
+ async removeCredential(credentialId) {
651
+ const cred = await config.storage.getCredentialById(credentialId);
652
+ if (!cred) throw new Error("Credential not found");
653
+ if ((await config.storage.getCredentialsByUserId(cred.userId)).length <= 1) {
654
+ if (!(await config.storage.getUserById(cred.userId))?.email) throw new Error("Cannot remove the last passkey — user has no email for recovery");
655
+ }
656
+ await config.storage.deleteCredential(credentialId);
657
+ emitter.emit("credential:removed", {
658
+ credentialId,
659
+ userId: cred.userId
660
+ });
661
+ },
662
+ async getUserCredentials(userId) {
663
+ return config.storage.getCredentialsByUserId(userId);
664
+ },
665
+ async createQRSession() {
666
+ return qrSessions.create();
667
+ },
668
+ async getQRSessionStatus(sessionId) {
669
+ return qrSessions.getStatus(sessionId);
670
+ },
671
+ async markQRSessionScanned(sessionId) {
672
+ await qrSessions.markScanned(sessionId);
673
+ emitter.emit("qr:scanned", { sessionId });
674
+ },
675
+ async completeQRSession({ sessionId, response }) {
676
+ const { user } = await passkeys.verifyAuthentication({ response });
677
+ if (hooks.beforeQRComplete) {
678
+ if (await hooks.beforeQRComplete({
679
+ sessionId,
680
+ userId: user.id
681
+ }) === false) throw new Error("QR completion blocked by hook");
682
+ }
683
+ const session = await sessions.create(user.id);
684
+ await config.storage.updateQRSession(sessionId, {
685
+ state: "authenticated",
686
+ userId: user.id,
687
+ sessionToken: session.token
688
+ });
689
+ emitter.emit("qr:completed", {
690
+ sessionId,
691
+ user
692
+ });
693
+ emitter.emit("session:created", {
694
+ session,
695
+ user,
696
+ method: "qr"
697
+ });
698
+ if (hooks.afterQRComplete) await hooks.afterQRComplete({
699
+ user,
700
+ session
701
+ });
702
+ return {
703
+ method: "qr",
704
+ user,
705
+ session
706
+ };
707
+ },
708
+ async validateSession(token) {
709
+ const session = await sessions.validate(token);
710
+ if (!session) return null;
711
+ const user = await config.storage.getUserById(session.userId);
712
+ if (!user) return null;
713
+ return {
714
+ user,
715
+ session
716
+ };
717
+ },
718
+ async getUserSessions(userId) {
719
+ return sessions.listByUser(userId);
720
+ },
721
+ async revokeSession(token) {
722
+ const session = await sessions.validate(token);
723
+ if (session) {
724
+ await sessions.revoke(session.id);
725
+ emitter.emit("session:revoked", {
726
+ sessionId: session.id,
727
+ userId: session.userId
728
+ });
729
+ }
730
+ },
731
+ async revokeSessionById(sessionId) {
732
+ await sessions.revoke(sessionId);
733
+ },
734
+ async revokeAllSessions(userId) {
735
+ await sessions.revokeAll(userId);
736
+ },
737
+ async getUser(userId) {
738
+ return config.storage.getUserById(userId);
739
+ },
740
+ async isEmailAvailable(email) {
741
+ if (!isValidEmail(email)) return false;
742
+ return await config.storage.getUserByEmail(email) === null;
743
+ },
744
+ async linkEmail({ userId, email }) {
745
+ if (!isValidEmail(email)) throw new Error("Invalid email address");
746
+ const existing = await config.storage.getUserByEmail(email);
747
+ if (existing && existing.id !== userId) throw new Error("Email is already linked to another account");
748
+ const user = await config.storage.updateUser(userId, { email });
749
+ emitter.emit("email:linked", {
750
+ userId,
751
+ email
752
+ });
753
+ return { user };
754
+ },
755
+ async unlinkEmail({ userId }) {
756
+ const user = await config.storage.getUserById(userId);
757
+ if (!user) throw new Error("User not found");
758
+ if (!user.email) throw new Error("User has no email to unlink");
759
+ if ((await config.storage.getCredentialsByUserId(userId)).length === 0) throw new Error("Cannot unlink email — user has no passkeys for recovery");
760
+ const oldEmail = user.email;
761
+ const updated = await config.storage.updateUser(userId, { email: void 0 });
762
+ emitter.emit("email:unlinked", {
763
+ userId,
764
+ email: oldEmail
765
+ });
766
+ return { user: updated };
767
+ },
768
+ async deleteAccount(userId) {
769
+ const creds = await config.storage.getCredentialsByUserId(userId);
770
+ for (const cred of creds) await config.storage.deleteCredential(cred.id);
771
+ await config.storage.deleteSessionsByUserId(userId);
772
+ await config.storage.deleteUser(userId);
773
+ emitter.emit("user:deleted", { userId });
774
+ },
775
+ on(event, handler) {
776
+ return emitter.on(event, handler);
777
+ },
778
+ createHandler(opts) {
779
+ return createHandler(this, opts);
780
+ }
781
+ };
782
+ if (config.email) {
783
+ const magicLinks = createMagicLinkManager(config.storage, config.email, {
784
+ magicLinkURL: config.magicLinkURL,
785
+ ttl: config.magicLinkTTL ?? FIFTEEN_MINUTES,
786
+ generateId: config.generateId
787
+ });
788
+ Object.assign(base, {
789
+ async sendMagicLink({ email }) {
790
+ if (hooks.beforeMagicLink) {
791
+ if (await hooks.beforeMagicLink({ email }) === false) throw new Error("Magic link blocked by hook");
792
+ }
793
+ const response = await magicLinks.send({ email });
794
+ emitter.emit("magic-link:sent", { email });
795
+ return response;
796
+ },
797
+ async verifyMagicLink({ token }) {
798
+ const { user, isNewUser } = await magicLinks.verify({ token });
799
+ const session = await sessions.create(user.id);
800
+ if (isNewUser) emitter.emit("user:created", { user });
801
+ emitter.emit("session:created", {
802
+ session,
803
+ user,
804
+ method: "magic-link"
805
+ });
806
+ if (hooks.afterMagicLink) await hooks.afterMagicLink({
807
+ user,
808
+ session,
809
+ isNewUser
810
+ });
811
+ return {
812
+ method: "magic-link",
813
+ user,
814
+ session,
815
+ isNewUser
816
+ };
817
+ }
818
+ });
819
+ }
820
+ return base;
821
+ }
822
+ //#endregion
823
+ export { createAuth as t };