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.
- package/README.md +302 -0
- package/dist/adapters/memory.d.mts +10 -0
- package/dist/adapters/memory.mjs +142 -0
- package/dist/adapters/unstorage.d.mts +30 -0
- package/dist/adapters/unstorage.mjs +238 -0
- package/dist/better-auth/client.d.mts +9 -0
- package/dist/better-auth/client.mjs +7 -0
- package/dist/better-auth/index.d.mts +2 -0
- package/dist/better-auth/index.mjs +4044 -0
- package/dist/client/index.d.mts +167 -0
- package/dist/client/index.mjs +166 -0
- package/dist/crypto-KHRNe6EL.mjs +45 -0
- package/dist/index-BoHvgaqz.d.mts +2816 -0
- package/dist/index-Cqqpr_uS.d.mts +176 -0
- package/dist/nitro/index.d.mts +89 -0
- package/dist/nitro/index.mjs +117 -0
- package/dist/server/index.d.mts +3 -0
- package/dist/server/index.mjs +3 -0
- package/dist/server-BXkm8lU0.mjs +823 -0
- package/dist/types-BjM1f6uu.d.mts +249 -0
- package/package.json +74 -0
|
@@ -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 };
|