@emdash-cms/auth 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/adapters/kysely.d.mts +62 -0
  2. package/dist/adapters/kysely.d.mts.map +1 -0
  3. package/dist/adapters/kysely.mjs +379 -0
  4. package/dist/adapters/kysely.mjs.map +1 -0
  5. package/dist/authenticate-D5UgaoTH.d.mts +124 -0
  6. package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
  7. package/dist/authenticate-j5GayLXB.mjs +373 -0
  8. package/dist/authenticate-j5GayLXB.mjs.map +1 -0
  9. package/dist/index.d.mts +444 -0
  10. package/dist/index.d.mts.map +1 -0
  11. package/dist/index.mjs +728 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/dist/oauth/providers/github.d.mts +12 -0
  14. package/dist/oauth/providers/github.d.mts.map +1 -0
  15. package/dist/oauth/providers/github.mjs +55 -0
  16. package/dist/oauth/providers/github.mjs.map +1 -0
  17. package/dist/oauth/providers/google.d.mts +7 -0
  18. package/dist/oauth/providers/google.d.mts.map +1 -0
  19. package/dist/oauth/providers/google.mjs +38 -0
  20. package/dist/oauth/providers/google.mjs.map +1 -0
  21. package/dist/passkey/index.d.mts +2 -0
  22. package/dist/passkey/index.mjs +3 -0
  23. package/dist/types-Bu4irX9A.d.mts +35 -0
  24. package/dist/types-Bu4irX9A.d.mts.map +1 -0
  25. package/dist/types-CiSNpRI9.mjs +60 -0
  26. package/dist/types-CiSNpRI9.mjs.map +1 -0
  27. package/dist/types-HtRc90Wi.d.mts +208 -0
  28. package/dist/types-HtRc90Wi.d.mts.map +1 -0
  29. package/package.json +72 -0
  30. package/src/adapters/kysely.ts +715 -0
  31. package/src/config.ts +214 -0
  32. package/src/index.ts +135 -0
  33. package/src/invite.ts +205 -0
  34. package/src/magic-link/index.ts +150 -0
  35. package/src/oauth/consumer.ts +324 -0
  36. package/src/oauth/providers/github.ts +68 -0
  37. package/src/oauth/providers/google.ts +34 -0
  38. package/src/oauth/types.ts +36 -0
  39. package/src/passkey/authenticate.ts +183 -0
  40. package/src/passkey/index.ts +27 -0
  41. package/src/passkey/register.ts +232 -0
  42. package/src/passkey/types.ts +120 -0
  43. package/src/rbac.test.ts +141 -0
  44. package/src/rbac.ts +205 -0
  45. package/src/signup.ts +210 -0
  46. package/src/tokens.test.ts +141 -0
  47. package/src/tokens.ts +238 -0
  48. package/src/types.ts +352 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,728 @@
1
+ import { a as toDeviceType, i as roleToLevel, n as Role, o as toRoleLevel, r as roleFromLevel, s as toTokenType, t as AuthError } from "./types-CiSNpRI9.mjs";
2
+ import { _ as hasScope, a as registerPasskey, b as secureCompare, c as VALID_SCOPES, d as encrypt, f as generateAuthSecret, g as generateTokenWithHash, h as generateToken, i as generateRegistrationOptions, l as computeS256Challenge, m as generateSessionId, n as generateAuthenticationOptions, o as verifyRegistrationResponse, p as generatePrefixedToken, r as verifyAuthenticationResponse, s as TOKEN_PREFIXES, t as authenticateWithPasskey, u as decrypt, v as hashPrefixedToken, x as validateScopes, y as hashToken } from "./authenticate-j5GayLXB.mjs";
3
+ import "./passkey/index.mjs";
4
+ import { fetchGitHubEmail, github } from "./oauth/providers/github.mjs";
5
+ import { google } from "./oauth/providers/google.mjs";
6
+ import { z } from "zod";
7
+ import { sha256 } from "@oslojs/crypto/sha2";
8
+ import { encodeBase64urlNoPadding } from "@oslojs/encoding";
9
+
10
+ //#region src/config.ts
11
+ /**
12
+ * Configuration schema for @emdashcms/auth
13
+ */
14
+ /** Matches http(s) scheme at start of URL */
15
+ const HTTP_SCHEME_RE = /^https?:\/\//i;
16
+ /** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */
17
+ const httpUrl = z.string().url().refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
18
+ /**
19
+ * OAuth provider configuration
20
+ */
21
+ const oauthProviderSchema = z.object({
22
+ clientId: z.string(),
23
+ clientSecret: z.string()
24
+ });
25
+ /**
26
+ * Full auth configuration schema
27
+ */
28
+ const authConfigSchema = z.object({
29
+ secret: z.string().min(32, "Auth secret must be at least 32 characters"),
30
+ passkeys: z.object({
31
+ rpName: z.string(),
32
+ rpId: z.string().optional()
33
+ }).optional(),
34
+ selfSignup: z.object({
35
+ domains: z.array(z.string()),
36
+ defaultRole: z.enum([
37
+ "subscriber",
38
+ "contributor",
39
+ "author"
40
+ ]).default("contributor")
41
+ }).optional(),
42
+ oauth: z.object({
43
+ github: oauthProviderSchema.optional(),
44
+ google: oauthProviderSchema.optional()
45
+ }).optional(),
46
+ provider: z.object({
47
+ enabled: z.boolean(),
48
+ issuer: httpUrl.optional()
49
+ }).optional(),
50
+ sso: z.object({ enabled: z.boolean() }).optional(),
51
+ session: z.object({
52
+ maxAge: z.number().default(720 * 60 * 60),
53
+ sliding: z.boolean().default(true)
54
+ }).optional()
55
+ });
56
+ const selfSignupRoleMap = {
57
+ subscriber: "SUBSCRIBER",
58
+ contributor: "CONTRIBUTOR",
59
+ author: "AUTHOR"
60
+ };
61
+ /**
62
+ * Resolve auth configuration with defaults
63
+ */
64
+ function resolveConfig(config, baseUrl, siteName) {
65
+ const url = new URL(baseUrl);
66
+ return {
67
+ secret: config.secret,
68
+ baseUrl,
69
+ siteName,
70
+ passkeys: {
71
+ rpName: config.passkeys?.rpName ?? siteName,
72
+ rpId: config.passkeys?.rpId ?? url.hostname,
73
+ origin: url.origin
74
+ },
75
+ selfSignup: config.selfSignup ? {
76
+ domains: config.selfSignup.domains.map((d) => d.toLowerCase()),
77
+ defaultRole: selfSignupRoleMap[config.selfSignup.defaultRole]
78
+ } : void 0,
79
+ oauth: config.oauth,
80
+ provider: config.provider ? {
81
+ enabled: config.provider.enabled,
82
+ issuer: config.provider.issuer ?? baseUrl
83
+ } : void 0,
84
+ sso: config.sso,
85
+ session: {
86
+ maxAge: config.session?.maxAge ?? 720 * 60 * 60,
87
+ sliding: config.session?.sliding ?? true
88
+ }
89
+ };
90
+ }
91
+
92
+ //#endregion
93
+ //#region src/rbac.ts
94
+ /**
95
+ * Permission definitions with minimum role required
96
+ */
97
+ const Permissions = {
98
+ "content:read": Role.SUBSCRIBER,
99
+ "content:create": Role.CONTRIBUTOR,
100
+ "content:edit_own": Role.AUTHOR,
101
+ "content:edit_any": Role.EDITOR,
102
+ "content:delete_own": Role.AUTHOR,
103
+ "content:delete_any": Role.EDITOR,
104
+ "content:publish_own": Role.AUTHOR,
105
+ "content:publish_any": Role.EDITOR,
106
+ "media:read": Role.SUBSCRIBER,
107
+ "media:upload": Role.CONTRIBUTOR,
108
+ "media:edit_own": Role.AUTHOR,
109
+ "media:edit_any": Role.EDITOR,
110
+ "media:delete_own": Role.AUTHOR,
111
+ "media:delete_any": Role.EDITOR,
112
+ "taxonomies:read": Role.SUBSCRIBER,
113
+ "taxonomies:manage": Role.EDITOR,
114
+ "comments:read": Role.SUBSCRIBER,
115
+ "comments:moderate": Role.EDITOR,
116
+ "comments:delete": Role.ADMIN,
117
+ "comments:settings": Role.ADMIN,
118
+ "menus:read": Role.SUBSCRIBER,
119
+ "menus:manage": Role.EDITOR,
120
+ "widgets:read": Role.SUBSCRIBER,
121
+ "widgets:manage": Role.EDITOR,
122
+ "sections:read": Role.SUBSCRIBER,
123
+ "sections:manage": Role.EDITOR,
124
+ "redirects:read": Role.EDITOR,
125
+ "redirects:manage": Role.ADMIN,
126
+ "users:read": Role.ADMIN,
127
+ "users:invite": Role.ADMIN,
128
+ "users:manage": Role.ADMIN,
129
+ "settings:read": Role.EDITOR,
130
+ "settings:manage": Role.ADMIN,
131
+ "schema:read": Role.EDITOR,
132
+ "schema:manage": Role.ADMIN,
133
+ "plugins:read": Role.EDITOR,
134
+ "plugins:manage": Role.ADMIN,
135
+ "import:execute": Role.ADMIN,
136
+ "search:read": Role.SUBSCRIBER,
137
+ "search:manage": Role.ADMIN,
138
+ "auth:manage_own_credentials": Role.SUBSCRIBER,
139
+ "auth:manage_connections": Role.ADMIN
140
+ };
141
+ /**
142
+ * Check if a user has a specific permission
143
+ */
144
+ function hasPermission(user, permission) {
145
+ if (!user) return false;
146
+ return user.role >= Permissions[permission];
147
+ }
148
+ /**
149
+ * Require a permission, throwing if not met
150
+ */
151
+ function requirePermission(user, permission) {
152
+ if (!user) throw new PermissionError("unauthorized", "Authentication required");
153
+ if (!hasPermission(user, permission)) throw new PermissionError("forbidden", `Missing permission: ${permission}`);
154
+ }
155
+ /**
156
+ * Check if user can perform action on a resource they own
157
+ */
158
+ function canActOnOwn(user, ownerId, ownPermission, anyPermission) {
159
+ if (!user) return false;
160
+ if (user.id === ownerId) return hasPermission(user, ownPermission);
161
+ return hasPermission(user, anyPermission);
162
+ }
163
+ /**
164
+ * Require permission on a resource, checking ownership
165
+ */
166
+ function requirePermissionOnResource(user, ownerId, ownPermission, anyPermission) {
167
+ if (!user) throw new PermissionError("unauthorized", "Authentication required");
168
+ if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) throw new PermissionError("forbidden", `Missing permission: ${anyPermission}`);
169
+ }
170
+ var PermissionError = class extends Error {
171
+ constructor(code, message) {
172
+ super(message);
173
+ this.code = code;
174
+ this.name = "PermissionError";
175
+ }
176
+ };
177
+ /**
178
+ * Minimum role required for each API token scope.
179
+ *
180
+ * This is the authoritative mapping between the two authorization systems
181
+ * (RBAC roles and API token scopes). When issuing a token, the granted
182
+ * scopes must be intersected with the scopes allowed by the user's role.
183
+ */
184
+ const SCOPE_MIN_ROLE = {
185
+ "content:read": Role.SUBSCRIBER,
186
+ "content:write": Role.CONTRIBUTOR,
187
+ "media:read": Role.SUBSCRIBER,
188
+ "media:write": Role.CONTRIBUTOR,
189
+ "schema:read": Role.EDITOR,
190
+ "schema:write": Role.ADMIN,
191
+ admin: Role.ADMIN
192
+ };
193
+ /**
194
+ * Return the maximum set of API token scopes a given role level may hold.
195
+ *
196
+ * Used at token issuance time (device flow, authorization code exchange)
197
+ * to enforce: effective_scopes = requested_scopes ∩ scopesForRole(role).
198
+ */
199
+ function scopesForRole(role) {
200
+ return Object.entries(SCOPE_MIN_ROLE).reduce((acc, [scope, minRole]) => {
201
+ if (role >= minRole) acc.push(scope);
202
+ return acc;
203
+ }, []);
204
+ }
205
+ /**
206
+ * Clamp a set of requested scopes to those permitted by a user's role.
207
+ *
208
+ * Returns the intersection of `requested` and the scopes the role allows.
209
+ * This is the central policy enforcement point: effective permissions =
210
+ * role permissions ∩ token scopes.
211
+ */
212
+ function clampScopes(requested, role) {
213
+ const allowed = new Set(scopesForRole(role));
214
+ return requested.filter((s) => allowed.has(s));
215
+ }
216
+
217
+ //#endregion
218
+ //#region src/invite.ts
219
+ /**
220
+ * Invite system for new users
221
+ */
222
+ /** Escape HTML special characters to prevent injection in email templates */
223
+ function escapeHtml(s) {
224
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
225
+ }
226
+ const TOKEN_EXPIRY_MS$2 = 10080 * 60 * 1e3;
227
+ /**
228
+ * Create an invite token and URL without sending email.
229
+ *
230
+ * Validates the user doesn't already exist, generates a token, stores it,
231
+ * and returns the invite URL. Callers decide whether to send email or
232
+ * display the URL as a copy-link fallback.
233
+ */
234
+ async function createInviteToken(config, adapter, email, role, invitedBy) {
235
+ if (await adapter.getUserByEmail(email)) throw new InviteError("user_exists", "A user with this email already exists");
236
+ const { token, hash } = generateTokenWithHash();
237
+ await adapter.createToken({
238
+ hash,
239
+ email,
240
+ type: "invite",
241
+ role,
242
+ invitedBy,
243
+ expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS$2)
244
+ });
245
+ const url = new URL("/api/auth/invite/accept", config.baseUrl);
246
+ url.searchParams.set("token", token);
247
+ return {
248
+ url: url.toString(),
249
+ email
250
+ };
251
+ }
252
+ /**
253
+ * Build the invite email message.
254
+ */
255
+ function buildInviteEmail(inviteUrl, email, siteName) {
256
+ const safeName = escapeHtml(siteName);
257
+ return {
258
+ to: email,
259
+ subject: `You've been invited to ${siteName}`,
260
+ text: `You've been invited to join ${siteName}.\n\nClick this link to create your account:\n${inviteUrl}\n\nThis link expires in 7 days.`,
261
+ html: `
262
+ <!DOCTYPE html>
263
+ <html>
264
+ <head>
265
+ <meta charset="utf-8">
266
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
267
+ </head>
268
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
269
+ <h1 style="font-size: 24px; margin-bottom: 20px;">You've been invited to ${safeName}</h1>
270
+ <p>Click the button below to create your account:</p>
271
+ <p style="margin: 30px 0;">
272
+ <a href="${inviteUrl}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Accept Invite</a>
273
+ </p>
274
+ <p style="color: #666; font-size: 14px;">This link expires in 7 days.</p>
275
+ </body>
276
+ </html>`
277
+ };
278
+ }
279
+ /**
280
+ * Create and send an invite to a new user.
281
+ *
282
+ * When `config.email` is provided, sends the invite email.
283
+ * When omitted, creates the token and returns the invite URL
284
+ * without sending (for the copy-link fallback).
285
+ */
286
+ async function createInvite(config, adapter, email, role, invitedBy) {
287
+ const result = await createInviteToken(config, adapter, email, role, invitedBy);
288
+ if (config.email) {
289
+ const message = buildInviteEmail(result.url, email, config.siteName);
290
+ await config.email(message);
291
+ }
292
+ return result;
293
+ }
294
+ /**
295
+ * Validate an invite token and return the invite data
296
+ */
297
+ async function validateInvite(adapter, token) {
298
+ const hash = hashToken(token);
299
+ const authToken = await adapter.getToken(hash, "invite");
300
+ if (!authToken) throw new InviteError("invalid_token", "Invalid or expired invite link");
301
+ if (authToken.expiresAt < /* @__PURE__ */ new Date()) {
302
+ await adapter.deleteToken(hash);
303
+ throw new InviteError("token_expired", "This invite has expired");
304
+ }
305
+ if (!authToken.email || authToken.role === null) throw new InviteError("invalid_token", "Invalid invite data");
306
+ return {
307
+ email: authToken.email,
308
+ role: authToken.role
309
+ };
310
+ }
311
+ /**
312
+ * Complete the invite process (after passkey registration)
313
+ */
314
+ async function completeInvite(adapter, token, userData) {
315
+ const hash = hashToken(token);
316
+ const authToken = await adapter.getToken(hash, "invite");
317
+ if (!authToken || authToken.expiresAt < /* @__PURE__ */ new Date()) throw new InviteError("invalid_token", "Invalid or expired invite");
318
+ if (!authToken.email || authToken.role === null) throw new InviteError("invalid_token", "Invalid invite data");
319
+ await adapter.deleteToken(hash);
320
+ return await adapter.createUser({
321
+ email: authToken.email,
322
+ name: userData.name,
323
+ avatarUrl: userData.avatarUrl,
324
+ role: authToken.role,
325
+ emailVerified: true
326
+ });
327
+ }
328
+ var InviteError = class extends Error {
329
+ constructor(code, message) {
330
+ super(message);
331
+ this.code = code;
332
+ this.name = "InviteError";
333
+ }
334
+ };
335
+
336
+ //#endregion
337
+ //#region src/magic-link/index.ts
338
+ /**
339
+ * Magic link authentication
340
+ */
341
+ const TOKEN_EXPIRY_MS$1 = 900 * 1e3;
342
+ /**
343
+ * Add artificial delay with jitter to prevent timing attacks.
344
+ * Range approximates the time for token creation + email send.
345
+ */
346
+ async function timingDelay$1() {
347
+ const delay = 100 + Math.random() * 150;
348
+ await new Promise((resolve) => setTimeout(resolve, delay));
349
+ }
350
+ /**
351
+ * Send a magic link to a user's email.
352
+ *
353
+ * Requires `config.email` to be set. Throws if no email sender is configured.
354
+ */
355
+ async function sendMagicLink(config, adapter, email, type = "magic_link") {
356
+ if (!config.email) throw new MagicLinkError("email_not_configured", "Email is not configured");
357
+ const user = await adapter.getUserByEmail(email);
358
+ if (!user) {
359
+ await timingDelay$1();
360
+ return;
361
+ }
362
+ const { token, hash } = generateTokenWithHash();
363
+ await adapter.createToken({
364
+ hash,
365
+ userId: user.id,
366
+ email: user.email,
367
+ type,
368
+ expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS$1)
369
+ });
370
+ const url = new URL("/api/auth/magic-link/verify", config.baseUrl);
371
+ url.searchParams.set("token", token);
372
+ const safeName = escapeHtml(config.siteName);
373
+ await config.email({
374
+ to: user.email,
375
+ subject: `Sign in to ${config.siteName}`,
376
+ text: `Click this link to sign in to ${config.siteName}:\n\n${url.toString()}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`,
377
+ html: `
378
+ <!DOCTYPE html>
379
+ <html>
380
+ <head>
381
+ <meta charset="utf-8">
382
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
383
+ </head>
384
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
385
+ <h1 style="font-size: 24px; margin-bottom: 20px;">Sign in to ${safeName}</h1>
386
+ <p>Click the button below to sign in:</p>
387
+ <p style="margin: 30px 0;">
388
+ <a href="${url.toString()}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Sign in</a>
389
+ </p>
390
+ <p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>
391
+ <p style="color: #666; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
392
+ </body>
393
+ </html>`
394
+ });
395
+ }
396
+ /**
397
+ * Verify a magic link token and return the user
398
+ */
399
+ async function verifyMagicLink(adapter, token) {
400
+ const hash = hashToken(token);
401
+ const authToken = await adapter.getToken(hash, "magic_link");
402
+ if (!authToken) {
403
+ const recoveryToken = await adapter.getToken(hash, "recovery");
404
+ if (!recoveryToken) throw new MagicLinkError("invalid_token", "Invalid or expired link");
405
+ return verifyTokenAndGetUser(adapter, recoveryToken, hash);
406
+ }
407
+ return verifyTokenAndGetUser(adapter, authToken, hash);
408
+ }
409
+ async function verifyTokenAndGetUser(adapter, authToken, hash) {
410
+ if (authToken.expiresAt < /* @__PURE__ */ new Date()) {
411
+ await adapter.deleteToken(hash);
412
+ throw new MagicLinkError("token_expired", "This link has expired");
413
+ }
414
+ await adapter.deleteToken(hash);
415
+ if (!authToken.userId) throw new MagicLinkError("invalid_token", "Invalid token");
416
+ const user = await adapter.getUserById(authToken.userId);
417
+ if (!user) throw new MagicLinkError("user_not_found", "User not found");
418
+ return user;
419
+ }
420
+ var MagicLinkError = class extends Error {
421
+ constructor(code, message) {
422
+ super(message);
423
+ this.code = code;
424
+ this.name = "MagicLinkError";
425
+ }
426
+ };
427
+
428
+ //#endregion
429
+ //#region src/signup.ts
430
+ /**
431
+ * Self-signup for allowed email domains
432
+ */
433
+ const TOKEN_EXPIRY_MS = 900 * 1e3;
434
+ /**
435
+ * Add artificial delay with jitter to prevent timing attacks.
436
+ * Range approximates the time for token creation + email send.
437
+ */
438
+ async function timingDelay() {
439
+ const delay = 100 + Math.random() * 150;
440
+ await new Promise((resolve) => setTimeout(resolve, delay));
441
+ }
442
+ /**
443
+ * Check if an email domain is allowed for self-signup
444
+ */
445
+ async function canSignup(adapter, email) {
446
+ const domain = email.split("@")[1]?.toLowerCase();
447
+ if (!domain) return null;
448
+ const allowedDomain = await adapter.getAllowedDomain(domain);
449
+ if (!allowedDomain || !allowedDomain.enabled) return null;
450
+ return {
451
+ allowed: true,
452
+ role: allowedDomain.defaultRole
453
+ };
454
+ }
455
+ /**
456
+ * Request self-signup (sends verification email).
457
+ *
458
+ * Requires `config.email` to be set. Throws if no email sender is configured.
459
+ */
460
+ async function requestSignup(config, adapter, email) {
461
+ if (!config.email) throw new SignupError("email_not_configured", "Email is not configured");
462
+ if (await adapter.getUserByEmail(email)) {
463
+ await timingDelay();
464
+ return;
465
+ }
466
+ const signup = await canSignup(adapter, email);
467
+ if (!signup) {
468
+ await timingDelay();
469
+ return;
470
+ }
471
+ const { token, hash } = generateTokenWithHash();
472
+ await adapter.createToken({
473
+ hash,
474
+ email,
475
+ type: "email_verify",
476
+ role: signup.role,
477
+ expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS)
478
+ });
479
+ const url = new URL("/api/auth/signup/verify", config.baseUrl);
480
+ url.searchParams.set("token", token);
481
+ const safeName = escapeHtml(config.siteName);
482
+ await config.email({
483
+ to: email,
484
+ subject: `Verify your email for ${config.siteName}`,
485
+ text: `Click this link to verify your email and create your account:\n\n${url.toString()}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`,
486
+ html: `
487
+ <!DOCTYPE html>
488
+ <html>
489
+ <head>
490
+ <meta charset="utf-8">
491
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
492
+ </head>
493
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
494
+ <h1 style="font-size: 24px; margin-bottom: 20px;">Verify your email</h1>
495
+ <p>Click the button below to verify your email and create your ${safeName} account:</p>
496
+ <p style="margin: 30px 0;">
497
+ <a href="${url.toString()}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Verify Email</a>
498
+ </p>
499
+ <p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>
500
+ <p style="color: #666; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
501
+ </body>
502
+ </html>`
503
+ });
504
+ }
505
+ /**
506
+ * Validate a signup verification token
507
+ */
508
+ async function validateSignupToken(adapter, token) {
509
+ const hash = hashToken(token);
510
+ const authToken = await adapter.getToken(hash, "email_verify");
511
+ if (!authToken) throw new SignupError("invalid_token", "Invalid or expired verification link");
512
+ if (authToken.expiresAt < /* @__PURE__ */ new Date()) {
513
+ await adapter.deleteToken(hash);
514
+ throw new SignupError("token_expired", "This link has expired");
515
+ }
516
+ if (!authToken.email || authToken.role === null) throw new SignupError("invalid_token", "Invalid token data");
517
+ return {
518
+ email: authToken.email,
519
+ role: authToken.role
520
+ };
521
+ }
522
+ /**
523
+ * Complete signup process (after passkey registration)
524
+ */
525
+ async function completeSignup(adapter, token, userData) {
526
+ const hash = hashToken(token);
527
+ const authToken = await adapter.getToken(hash, "email_verify");
528
+ if (!authToken || authToken.expiresAt < /* @__PURE__ */ new Date()) throw new SignupError("invalid_token", "Invalid or expired verification");
529
+ if (!authToken.email || authToken.role === null) throw new SignupError("invalid_token", "Invalid token data");
530
+ if (await adapter.getUserByEmail(authToken.email)) {
531
+ await adapter.deleteToken(hash);
532
+ throw new SignupError("user_exists", "An account with this email already exists");
533
+ }
534
+ await adapter.deleteToken(hash);
535
+ return await adapter.createUser({
536
+ email: authToken.email,
537
+ name: userData.name,
538
+ avatarUrl: userData.avatarUrl,
539
+ role: authToken.role,
540
+ emailVerified: true
541
+ });
542
+ }
543
+ var SignupError = class extends Error {
544
+ constructor(code, message) {
545
+ super(message);
546
+ this.code = code;
547
+ this.name = "SignupError";
548
+ }
549
+ };
550
+
551
+ //#endregion
552
+ //#region src/oauth/consumer.ts
553
+ /**
554
+ * OAuth consumer - "Login with X" functionality
555
+ */
556
+ /**
557
+ * Generate an OAuth authorization URL
558
+ */
559
+ async function createAuthorizationUrl(config, providerName, stateStore) {
560
+ const providerConfig = config.providers[providerName];
561
+ if (!providerConfig) throw new Error(`OAuth provider ${providerName} not configured`);
562
+ const provider = getProvider(providerName);
563
+ const state = generateState();
564
+ const redirectUri = `${config.baseUrl}/api/auth/oauth/${providerName}/callback`;
565
+ const codeVerifier = generateCodeVerifier();
566
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
567
+ await stateStore.set(state, {
568
+ provider: providerName,
569
+ redirectUri,
570
+ codeVerifier
571
+ });
572
+ const url = new URL(provider.authorizeUrl);
573
+ url.searchParams.set("client_id", providerConfig.clientId);
574
+ url.searchParams.set("redirect_uri", redirectUri);
575
+ url.searchParams.set("response_type", "code");
576
+ url.searchParams.set("scope", provider.scopes.join(" "));
577
+ url.searchParams.set("state", state);
578
+ url.searchParams.set("code_challenge", codeChallenge);
579
+ url.searchParams.set("code_challenge_method", "S256");
580
+ return {
581
+ url: url.toString(),
582
+ state
583
+ };
584
+ }
585
+ /**
586
+ * Handle OAuth callback
587
+ */
588
+ async function handleOAuthCallback(config, adapter, providerName, code, state, stateStore) {
589
+ const providerConfig = config.providers[providerName];
590
+ if (!providerConfig) throw new Error(`OAuth provider ${providerName} not configured`);
591
+ const storedState = await stateStore.get(state);
592
+ if (!storedState || storedState.provider !== providerName) throw new OAuthError("invalid_state", "Invalid OAuth state");
593
+ await stateStore.delete(state);
594
+ const provider = getProvider(providerName);
595
+ return findOrCreateUser(config, adapter, providerName, await fetchProfile(provider, (await exchangeCode(provider, providerConfig, code, storedState.redirectUri, storedState.codeVerifier)).accessToken, providerName));
596
+ }
597
+ /**
598
+ * Exchange authorization code for tokens
599
+ */
600
+ async function exchangeCode(provider, config, code, redirectUri, codeVerifier) {
601
+ const body = new URLSearchParams({
602
+ grant_type: "authorization_code",
603
+ code,
604
+ redirect_uri: redirectUri,
605
+ client_id: config.clientId,
606
+ client_secret: config.clientSecret
607
+ });
608
+ if (codeVerifier) body.set("code_verifier", codeVerifier);
609
+ const response = await fetch(provider.tokenUrl, {
610
+ method: "POST",
611
+ headers: {
612
+ "Content-Type": "application/x-www-form-urlencoded",
613
+ Accept: "application/json"
614
+ },
615
+ body
616
+ });
617
+ if (!response.ok) throw new OAuthError("token_exchange_failed", `Token exchange failed: ${await response.text()}`);
618
+ const json = await response.json();
619
+ const data = z.object({
620
+ access_token: z.string(),
621
+ id_token: z.string().optional()
622
+ }).parse(json);
623
+ return {
624
+ accessToken: data.access_token,
625
+ idToken: data.id_token
626
+ };
627
+ }
628
+ /**
629
+ * Fetch user profile from OAuth provider
630
+ */
631
+ async function fetchProfile(provider, accessToken, providerName) {
632
+ if (!provider.userInfoUrl) throw new Error("Provider does not have userinfo URL");
633
+ const response = await fetch(provider.userInfoUrl, { headers: {
634
+ Authorization: `Bearer ${accessToken}`,
635
+ Accept: "application/json"
636
+ } });
637
+ if (!response.ok) throw new OAuthError("profile_fetch_failed", `Failed to fetch profile: ${response.status}`);
638
+ const data = await response.json();
639
+ const profile = provider.parseProfile(data);
640
+ if (providerName === "github" && !profile.email) profile.email = await fetchGitHubEmail(accessToken);
641
+ return profile;
642
+ }
643
+ /**
644
+ * Find existing user or create new one (with auto-linking)
645
+ */
646
+ async function findOrCreateUser(config, adapter, providerName, profile) {
647
+ const existingAccount = await adapter.getOAuthAccount(providerName, profile.id);
648
+ if (existingAccount) {
649
+ const user = await adapter.getUserById(existingAccount.userId);
650
+ if (!user) throw new OAuthError("user_not_found", "Linked user not found");
651
+ return user;
652
+ }
653
+ const existingUser = await adapter.getUserByEmail(profile.email);
654
+ if (existingUser) {
655
+ if (!profile.emailVerified) throw new OAuthError("signup_not_allowed", "Cannot link account: email not verified by provider");
656
+ await adapter.createOAuthAccount({
657
+ provider: providerName,
658
+ providerAccountId: profile.id,
659
+ userId: existingUser.id
660
+ });
661
+ return existingUser;
662
+ }
663
+ if (config.canSelfSignup) {
664
+ const signup = await config.canSelfSignup(profile.email);
665
+ if (signup?.allowed) {
666
+ const user = await adapter.createUser({
667
+ email: profile.email,
668
+ name: profile.name,
669
+ avatarUrl: profile.avatarUrl,
670
+ role: signup.role,
671
+ emailVerified: profile.emailVerified
672
+ });
673
+ await adapter.createOAuthAccount({
674
+ provider: providerName,
675
+ providerAccountId: profile.id,
676
+ userId: user.id
677
+ });
678
+ return user;
679
+ }
680
+ }
681
+ throw new OAuthError("signup_not_allowed", "Self-signup not allowed for this email domain");
682
+ }
683
+ function getProvider(name) {
684
+ switch (name) {
685
+ case "github": return github;
686
+ case "google": return google;
687
+ }
688
+ }
689
+ /**
690
+ * Generate a random state string for OAuth CSRF protection
691
+ */
692
+ function generateState() {
693
+ const bytes = new Uint8Array(32);
694
+ crypto.getRandomValues(bytes);
695
+ return encodeBase64urlNoPadding(bytes);
696
+ }
697
+ function generateCodeVerifier() {
698
+ const bytes = new Uint8Array(32);
699
+ crypto.getRandomValues(bytes);
700
+ return encodeBase64urlNoPadding(bytes);
701
+ }
702
+ async function generateCodeChallenge(verifier) {
703
+ return encodeBase64urlNoPadding(sha256(new TextEncoder().encode(verifier)));
704
+ }
705
+ var OAuthError = class extends Error {
706
+ constructor(code, message) {
707
+ super(message);
708
+ this.code = code;
709
+ this.name = "OAuthError";
710
+ }
711
+ };
712
+
713
+ //#endregion
714
+ //#region src/index.ts
715
+ /**
716
+ * Create an auth configuration
717
+ *
718
+ * This is a helper function that validates the config at runtime.
719
+ */
720
+ function auth(config) {
721
+ const result = authConfigSchema.safeParse(config);
722
+ if (!result.success) throw new Error(`Invalid auth config: ${result.error.message}`);
723
+ return result.data;
724
+ }
725
+
726
+ //#endregion
727
+ export { AuthError, InviteError, MagicLinkError, OAuthError, PermissionError, Permissions, Role, SignupError, TOKEN_PREFIXES, VALID_SCOPES, auth, authConfigSchema, authenticateWithPasskey, canActOnOwn, canSignup, clampScopes, completeInvite, completeSignup, computeS256Challenge, createAuthorizationUrl, createInvite, createInviteToken, decrypt, encrypt, escapeHtml, generateAuthSecret, generateAuthenticationOptions, generatePrefixedToken, generateRegistrationOptions, generateSessionId, generateToken, generateTokenWithHash, github, google, handleOAuthCallback, hasPermission, hasScope, hashPrefixedToken, hashToken, registerPasskey, requestSignup, requirePermission, requirePermissionOnResource, resolveConfig, roleFromLevel, roleToLevel, scopesForRole, secureCompare, sendMagicLink, toDeviceType, toRoleLevel, toTokenType, validateInvite, validateScopes, validateSignupToken, verifyAuthenticationResponse, verifyMagicLink, verifyRegistrationResponse };
728
+ //# sourceMappingURL=index.mjs.map