@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/src/config.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Configuration schema for @emdash-cms/auth
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ import type { RoleName } from "./types.js";
8
+
9
+ /** Matches http(s) scheme at start of URL */
10
+ const HTTP_SCHEME_RE = /^https?:\/\//i;
11
+
12
+ /** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */
13
+ const httpUrl = z
14
+ .string()
15
+ .url()
16
+ .refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
17
+
18
+ /**
19
+ * OAuth provider configuration
20
+ */
21
+ const oauthProviderSchema = z.object({
22
+ clientId: z.string(),
23
+ clientSecret: z.string(),
24
+ });
25
+
26
+ /**
27
+ * Full auth configuration schema
28
+ */
29
+ export const authConfigSchema = z.object({
30
+ /**
31
+ * Secret key for encrypting tokens and session data.
32
+ * Generate with: `emdash auth secret`
33
+ */
34
+ secret: z.string().min(32, "Auth secret must be at least 32 characters"),
35
+
36
+ /**
37
+ * Passkey (WebAuthn) configuration
38
+ */
39
+ passkeys: z
40
+ .object({
41
+ /**
42
+ * Relying party name shown to users during passkey registration
43
+ */
44
+ rpName: z.string(),
45
+ /**
46
+ * Relying party ID (domain). Defaults to the hostname from baseUrl.
47
+ */
48
+ rpId: z.string().optional(),
49
+ })
50
+ .optional(),
51
+
52
+ /**
53
+ * Self-signup configuration
54
+ */
55
+ selfSignup: z
56
+ .object({
57
+ /**
58
+ * Email domains allowed to self-register
59
+ */
60
+ domains: z.array(z.string()),
61
+ /**
62
+ * Default role for self-registered users
63
+ */
64
+ defaultRole: z.enum(["subscriber", "contributor", "author"] as const).default("contributor"),
65
+ })
66
+ .optional(),
67
+
68
+ /**
69
+ * OAuth provider configurations (for "Login with X")
70
+ */
71
+ oauth: z
72
+ .object({
73
+ github: oauthProviderSchema.optional(),
74
+ google: oauthProviderSchema.optional(),
75
+ })
76
+ .optional(),
77
+
78
+ /**
79
+ * Configure EmDash as an OAuth provider
80
+ */
81
+ provider: z
82
+ .object({
83
+ enabled: z.boolean(),
84
+ /**
85
+ * Issuer URL for OIDC. Defaults to site URL.
86
+ */
87
+ issuer: httpUrl.optional(),
88
+ })
89
+ .optional(),
90
+
91
+ /**
92
+ * Enterprise SSO configuration
93
+ */
94
+ sso: z
95
+ .object({
96
+ enabled: z.boolean(),
97
+ })
98
+ .optional(),
99
+
100
+ /**
101
+ * Session configuration
102
+ */
103
+ session: z
104
+ .object({
105
+ /**
106
+ * Session max age in seconds. Default: 30 days
107
+ */
108
+ maxAge: z.number().default(30 * 24 * 60 * 60),
109
+ /**
110
+ * Extend session on activity. Default: true
111
+ */
112
+ sliding: z.boolean().default(true),
113
+ })
114
+ .optional(),
115
+ });
116
+
117
+ export type AuthConfig = z.infer<typeof authConfigSchema>;
118
+
119
+ /**
120
+ * Validated and resolved auth configuration
121
+ */
122
+ export interface ResolvedAuthConfig {
123
+ secret: string;
124
+ baseUrl: string;
125
+ siteName: string;
126
+
127
+ passkeys: {
128
+ rpName: string;
129
+ rpId: string;
130
+ origin: string;
131
+ };
132
+
133
+ selfSignup?: {
134
+ domains: string[];
135
+ defaultRole: RoleName;
136
+ };
137
+
138
+ oauth?: {
139
+ github?: {
140
+ clientId: string;
141
+ clientSecret: string;
142
+ };
143
+ google?: {
144
+ clientId: string;
145
+ clientSecret: string;
146
+ };
147
+ };
148
+
149
+ provider?: {
150
+ enabled: boolean;
151
+ issuer: string;
152
+ };
153
+
154
+ sso?: {
155
+ enabled: boolean;
156
+ };
157
+
158
+ session: {
159
+ maxAge: number;
160
+ sliding: boolean;
161
+ };
162
+ }
163
+
164
+ const selfSignupRoleMap: Record<"subscriber" | "contributor" | "author", RoleName> = {
165
+ subscriber: "SUBSCRIBER",
166
+ contributor: "CONTRIBUTOR",
167
+ author: "AUTHOR",
168
+ };
169
+
170
+ /**
171
+ * Resolve auth configuration with defaults
172
+ */
173
+ export function resolveConfig(
174
+ config: AuthConfig,
175
+ baseUrl: string,
176
+ siteName: string,
177
+ ): ResolvedAuthConfig {
178
+ const url = new URL(baseUrl);
179
+
180
+ return {
181
+ secret: config.secret,
182
+ baseUrl,
183
+ siteName,
184
+
185
+ passkeys: {
186
+ rpName: config.passkeys?.rpName ?? siteName,
187
+ rpId: config.passkeys?.rpId ?? url.hostname,
188
+ origin: url.origin,
189
+ },
190
+
191
+ selfSignup: config.selfSignup
192
+ ? {
193
+ domains: config.selfSignup.domains.map((d) => d.toLowerCase()),
194
+ defaultRole: selfSignupRoleMap[config.selfSignup.defaultRole],
195
+ }
196
+ : undefined,
197
+
198
+ oauth: config.oauth,
199
+
200
+ provider: config.provider
201
+ ? {
202
+ enabled: config.provider.enabled,
203
+ issuer: config.provider.issuer ?? baseUrl,
204
+ }
205
+ : undefined,
206
+
207
+ sso: config.sso,
208
+
209
+ session: {
210
+ maxAge: config.session?.maxAge ?? 30 * 24 * 60 * 60,
211
+ sliding: config.session?.sliding ?? true,
212
+ },
213
+ };
214
+ }
package/src/index.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @emdash-cms/auth - Passkey-first authentication for EmDash
3
+ *
4
+ * Email is now handled by the plugin email pipeline (see PLUGIN-EMAIL.md).
5
+ * Auth functions accept an optional `email` send function instead of a
6
+ * hardcoded adapter. The route layer bridges `emdash.email.send()` from
7
+ * the pipeline into the auth functions.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { auth } from '@emdash-cms/auth'
12
+ *
13
+ * export default defineConfig({
14
+ * integrations: [
15
+ * emdash({
16
+ * auth: auth({
17
+ * secret: import.meta.env.EMDASH_AUTH_SECRET,
18
+ * passkeys: { rpName: 'My Site' },
19
+ * }),
20
+ * }),
21
+ * ],
22
+ * })
23
+ * ```
24
+ */
25
+
26
+ // Types
27
+ export * from "./types.js";
28
+
29
+ // Config
30
+ import { authConfigSchema as _authConfigSchema } from "./config.js";
31
+ export {
32
+ authConfigSchema,
33
+ resolveConfig,
34
+ type AuthConfig,
35
+ type ResolvedAuthConfig,
36
+ } from "./config.js";
37
+
38
+ // RBAC
39
+ export {
40
+ Permissions,
41
+ hasPermission,
42
+ requirePermission,
43
+ canActOnOwn,
44
+ requirePermissionOnResource,
45
+ PermissionError,
46
+ scopesForRole,
47
+ clampScopes,
48
+ type Permission,
49
+ } from "./rbac.js";
50
+
51
+ // Tokens
52
+ export {
53
+ generateToken,
54
+ hashToken,
55
+ generateTokenWithHash,
56
+ generateSessionId,
57
+ generateAuthSecret,
58
+ secureCompare,
59
+ encrypt,
60
+ decrypt,
61
+ // Prefixed API tokens (ec_pat_, ec_oat_, ec_ort_)
62
+ TOKEN_PREFIXES,
63
+ generatePrefixedToken,
64
+ hashPrefixedToken,
65
+ // Scopes
66
+ VALID_SCOPES,
67
+ validateScopes,
68
+ hasScope,
69
+ type ApiTokenScope,
70
+ // PKCE
71
+ computeS256Challenge,
72
+ } from "./tokens.js";
73
+
74
+ // Passkey
75
+ export * from "./passkey/index.js";
76
+
77
+ // Magic Link
78
+ export {
79
+ sendMagicLink,
80
+ verifyMagicLink,
81
+ MagicLinkError,
82
+ type MagicLinkConfig,
83
+ } from "./magic-link/index.js";
84
+
85
+ // Invite
86
+ export {
87
+ createInvite,
88
+ createInviteToken,
89
+ validateInvite,
90
+ completeInvite,
91
+ InviteError,
92
+ escapeHtml,
93
+ type InviteConfig,
94
+ type InviteTokenResult,
95
+ type EmailSendFn,
96
+ } from "./invite.js";
97
+
98
+ // Signup
99
+ export {
100
+ canSignup,
101
+ requestSignup,
102
+ validateSignupToken,
103
+ completeSignup,
104
+ SignupError,
105
+ type SignupConfig,
106
+ } from "./signup.js";
107
+
108
+ // OAuth
109
+ export {
110
+ createAuthorizationUrl,
111
+ handleOAuthCallback,
112
+ OAuthError,
113
+ github,
114
+ google,
115
+ type StateStore,
116
+ type OAuthConsumerConfig,
117
+ } from "./oauth/consumer.js";
118
+ export type { OAuthProvider, OAuthConfig, OAuthProfile, OAuthState } from "./oauth/types.js";
119
+
120
+ // Email types (implementations moved to plugin email pipeline)
121
+ export type { EmailAdapter, EmailMessage } from "./types.js";
122
+
123
+ /**
124
+ * Create an auth configuration
125
+ *
126
+ * This is a helper function that validates the config at runtime.
127
+ */
128
+ export function auth(config: import("./config.js").AuthConfig): import("./config.js").AuthConfig {
129
+ // Validate config
130
+ const result = _authConfigSchema.safeParse(config);
131
+ if (!result.success) {
132
+ throw new Error(`Invalid auth config: ${result.error.message}`);
133
+ }
134
+ return result.data;
135
+ }
package/src/invite.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Invite system for new users
3
+ */
4
+
5
+ import { generateTokenWithHash, hashToken } from "./tokens.js";
6
+ import type { AuthAdapter, RoleLevel, EmailMessage, User } from "./types.js";
7
+
8
+ /** Escape HTML special characters to prevent injection in email templates */
9
+ export function escapeHtml(s: string): string {
10
+ return s
11
+ .replaceAll("&", "&amp;")
12
+ .replaceAll("<", "&lt;")
13
+ .replaceAll(">", "&gt;")
14
+ .replaceAll('"', "&quot;");
15
+ }
16
+
17
+ const TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
18
+
19
+ /** Function that sends an email (matches the EmailPipeline.send signature) */
20
+ export type EmailSendFn = (message: EmailMessage) => Promise<void>;
21
+
22
+ export interface InviteConfig {
23
+ baseUrl: string;
24
+ siteName: string;
25
+ /** Optional email sender. When omitted, invite URL is returned without sending. */
26
+ email?: EmailSendFn;
27
+ }
28
+
29
+ /** Result of creating an invite token (without sending email) */
30
+ export interface InviteTokenResult {
31
+ /** The complete invite URL */
32
+ url: string;
33
+ /** The invite email address */
34
+ email: string;
35
+ }
36
+
37
+ /**
38
+ * Create an invite token and URL without sending email.
39
+ *
40
+ * Validates the user doesn't already exist, generates a token, stores it,
41
+ * and returns the invite URL. Callers decide whether to send email or
42
+ * display the URL as a copy-link fallback.
43
+ */
44
+ export async function createInviteToken(
45
+ config: Pick<InviteConfig, "baseUrl">,
46
+ adapter: AuthAdapter,
47
+ email: string,
48
+ role: RoleLevel,
49
+ invitedBy: string,
50
+ ): Promise<InviteTokenResult> {
51
+ // Check if user already exists
52
+ const existing = await adapter.getUserByEmail(email);
53
+ if (existing) {
54
+ throw new InviteError("user_exists", "A user with this email already exists");
55
+ }
56
+
57
+ // Generate token
58
+ const { token, hash } = generateTokenWithHash();
59
+
60
+ // Store token
61
+ await adapter.createToken({
62
+ hash,
63
+ email,
64
+ type: "invite",
65
+ role,
66
+ invitedBy,
67
+ expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS),
68
+ });
69
+
70
+ // Build invite URL
71
+ const url = new URL("/api/auth/invite/accept", config.baseUrl);
72
+ url.searchParams.set("token", token);
73
+
74
+ return { url: url.toString(), email };
75
+ }
76
+
77
+ /**
78
+ * Build the invite email message.
79
+ */
80
+ function buildInviteEmail(inviteUrl: string, email: string, siteName: string): EmailMessage {
81
+ const safeName = escapeHtml(siteName);
82
+ return {
83
+ to: email,
84
+ subject: `You've been invited to ${siteName}`,
85
+ 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.`,
86
+ html: `
87
+ <!DOCTYPE html>
88
+ <html>
89
+ <head>
90
+ <meta charset="utf-8">
91
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
92
+ </head>
93
+ <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;">
94
+ <h1 style="font-size: 24px; margin-bottom: 20px;">You've been invited to ${safeName}</h1>
95
+ <p>Click the button below to create your account:</p>
96
+ <p style="margin: 30px 0;">
97
+ <a href="${inviteUrl}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Accept Invite</a>
98
+ </p>
99
+ <p style="color: #666; font-size: 14px;">This link expires in 7 days.</p>
100
+ </body>
101
+ </html>`,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Create and send an invite to a new user.
107
+ *
108
+ * When `config.email` is provided, sends the invite email.
109
+ * When omitted, creates the token and returns the invite URL
110
+ * without sending (for the copy-link fallback).
111
+ */
112
+ export async function createInvite(
113
+ config: InviteConfig,
114
+ adapter: AuthAdapter,
115
+ email: string,
116
+ role: RoleLevel,
117
+ invitedBy: string,
118
+ ): Promise<InviteTokenResult> {
119
+ const result = await createInviteToken(config, adapter, email, role, invitedBy);
120
+
121
+ // Send email if a sender is configured
122
+ if (config.email) {
123
+ const message = buildInviteEmail(result.url, email, config.siteName);
124
+ await config.email(message);
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Validate an invite token and return the invite data
132
+ */
133
+ export async function validateInvite(
134
+ adapter: AuthAdapter,
135
+ token: string,
136
+ ): Promise<{ email: string; role: RoleLevel }> {
137
+ const hash = hashToken(token);
138
+
139
+ const authToken = await adapter.getToken(hash, "invite");
140
+ if (!authToken) {
141
+ throw new InviteError("invalid_token", "Invalid or expired invite link");
142
+ }
143
+
144
+ if (authToken.expiresAt < new Date()) {
145
+ await adapter.deleteToken(hash);
146
+ throw new InviteError("token_expired", "This invite has expired");
147
+ }
148
+
149
+ if (!authToken.email || authToken.role === null) {
150
+ throw new InviteError("invalid_token", "Invalid invite data");
151
+ }
152
+
153
+ return {
154
+ email: authToken.email,
155
+ role: authToken.role,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Complete the invite process (after passkey registration)
161
+ */
162
+ export async function completeInvite(
163
+ adapter: AuthAdapter,
164
+ token: string,
165
+ userData: {
166
+ name?: string;
167
+ avatarUrl?: string;
168
+ },
169
+ ): Promise<User> {
170
+ const hash = hashToken(token);
171
+
172
+ // Validate token one more time
173
+ const authToken = await adapter.getToken(hash, "invite");
174
+ if (!authToken || authToken.expiresAt < new Date()) {
175
+ throw new InviteError("invalid_token", "Invalid or expired invite");
176
+ }
177
+
178
+ if (!authToken.email || authToken.role === null) {
179
+ throw new InviteError("invalid_token", "Invalid invite data");
180
+ }
181
+
182
+ // Delete token (single-use)
183
+ await adapter.deleteToken(hash);
184
+
185
+ // Create user
186
+ const user = await adapter.createUser({
187
+ email: authToken.email,
188
+ name: userData.name,
189
+ avatarUrl: userData.avatarUrl,
190
+ role: authToken.role,
191
+ emailVerified: true, // Email verified by accepting invite
192
+ });
193
+
194
+ return user;
195
+ }
196
+
197
+ export class InviteError extends Error {
198
+ constructor(
199
+ public code: "invalid_token" | "token_expired" | "user_exists",
200
+ message: string,
201
+ ) {
202
+ super(message);
203
+ this.name = "InviteError";
204
+ }
205
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Magic link authentication
3
+ */
4
+
5
+ import { escapeHtml } from "../invite.js";
6
+ import { generateTokenWithHash, hashToken } from "../tokens.js";
7
+ import type { AuthAdapter, User, EmailMessage } from "../types.js";
8
+
9
+ const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
10
+
11
+ /** Function that sends an email (matches the EmailPipeline.send signature) */
12
+ export type EmailSendFn = (message: EmailMessage) => Promise<void>;
13
+
14
+ export interface MagicLinkConfig {
15
+ baseUrl: string;
16
+ siteName: string;
17
+ /** Optional email sender. When omitted, magic links cannot be sent. */
18
+ email?: EmailSendFn;
19
+ }
20
+
21
+ /**
22
+ * Add artificial delay with jitter to prevent timing attacks.
23
+ * Range approximates the time for token creation + email send.
24
+ */
25
+ async function timingDelay(): Promise<void> {
26
+ const delay = 100 + Math.random() * 150; // 100-250ms
27
+ await new Promise((resolve) => setTimeout(resolve, delay));
28
+ }
29
+
30
+ /**
31
+ * Send a magic link to a user's email.
32
+ *
33
+ * Requires `config.email` to be set. Throws if no email sender is configured.
34
+ */
35
+ export async function sendMagicLink(
36
+ config: MagicLinkConfig,
37
+ adapter: AuthAdapter,
38
+ email: string,
39
+ type: "magic_link" | "recovery" = "magic_link",
40
+ ): Promise<void> {
41
+ if (!config.email) {
42
+ throw new MagicLinkError("email_not_configured", "Email is not configured");
43
+ }
44
+
45
+ // Find user
46
+ const user = await adapter.getUserByEmail(email);
47
+ if (!user) {
48
+ // Don't reveal whether user exists - add delay to match successful path timing
49
+ await timingDelay();
50
+ return;
51
+ }
52
+
53
+ // Generate token
54
+ const { token, hash } = generateTokenWithHash();
55
+
56
+ // Store token hash
57
+ await adapter.createToken({
58
+ hash,
59
+ userId: user.id,
60
+ email: user.email,
61
+ type,
62
+ expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS),
63
+ });
64
+
65
+ // Build magic link URL
66
+ const url = new URL("/api/auth/magic-link/verify", config.baseUrl);
67
+ url.searchParams.set("token", token);
68
+
69
+ // Send email
70
+ const safeName = escapeHtml(config.siteName);
71
+ await config.email({
72
+ to: user.email,
73
+ subject: `Sign in to ${config.siteName}`,
74
+ 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.`,
75
+ html: `
76
+ <!DOCTYPE html>
77
+ <html>
78
+ <head>
79
+ <meta charset="utf-8">
80
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
81
+ </head>
82
+ <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;">
83
+ <h1 style="font-size: 24px; margin-bottom: 20px;">Sign in to ${safeName}</h1>
84
+ <p>Click the button below to sign in:</p>
85
+ <p style="margin: 30px 0;">
86
+ <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>
87
+ </p>
88
+ <p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>
89
+ <p style="color: #666; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
90
+ </body>
91
+ </html>`,
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Verify a magic link token and return the user
97
+ */
98
+ export async function verifyMagicLink(adapter: AuthAdapter, token: string): Promise<User> {
99
+ const hash = hashToken(token);
100
+
101
+ // Find and validate token
102
+ const authToken = await adapter.getToken(hash, "magic_link");
103
+ if (!authToken) {
104
+ // Also check for recovery tokens
105
+ const recoveryToken = await adapter.getToken(hash, "recovery");
106
+ if (!recoveryToken) {
107
+ throw new MagicLinkError("invalid_token", "Invalid or expired link");
108
+ }
109
+ return verifyTokenAndGetUser(adapter, recoveryToken, hash);
110
+ }
111
+
112
+ return verifyTokenAndGetUser(adapter, authToken, hash);
113
+ }
114
+
115
+ async function verifyTokenAndGetUser(
116
+ adapter: AuthAdapter,
117
+ authToken: { userId: string | null; expiresAt: Date },
118
+ hash: string,
119
+ ): Promise<User> {
120
+ // Check expiry
121
+ if (authToken.expiresAt < new Date()) {
122
+ await adapter.deleteToken(hash);
123
+ throw new MagicLinkError("token_expired", "This link has expired");
124
+ }
125
+
126
+ // Delete token (single-use)
127
+ await adapter.deleteToken(hash);
128
+
129
+ // Get user
130
+ if (!authToken.userId) {
131
+ throw new MagicLinkError("invalid_token", "Invalid token");
132
+ }
133
+
134
+ const user = await adapter.getUserById(authToken.userId);
135
+ if (!user) {
136
+ throw new MagicLinkError("user_not_found", "User not found");
137
+ }
138
+
139
+ return user;
140
+ }
141
+
142
+ export class MagicLinkError extends Error {
143
+ constructor(
144
+ public code: "invalid_token" | "token_expired" | "user_not_found" | "email_not_configured",
145
+ message: string,
146
+ ) {
147
+ super(message);
148
+ this.name = "MagicLinkError";
149
+ }
150
+ }