CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
magic-link.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| cd4f63b | 1 | /** |
| 2 | * Block Q2 — Magic-link sign-in. | |
| 3 | * | |
| 4 | * Public surface: | |
| 5 | * - generateMagicLinkToken() | |
| 6 | * - startMagicLinkSignIn() → always { ok: true } (no enumeration) | |
| 7 | * - consumeMagicLinkToken() | |
| 8 | * | |
| 9 | * Structurally identical to P1 (`password-reset.ts`) and P2 | |
| 10 | * (`email-verification.ts`): short random token, sha256-hashed at rest, | |
| 11 | * single-use, time-limited. The only meaningful differences are: | |
| 12 | * | |
| 13 | * - 15-minute TTL (vs P1's 1h reset) — magic-link is a session-issuer, | |
| 14 | * not a one-shot password rotation, so the blast radius of a stolen | |
| 15 | * link is higher and we want the window tight. | |
| 16 | * - `user_id` is nullable. When a not-yet-registered email is entered, | |
| 17 | * we still mint a token row; consume creates the account on click | |
| 18 | * (autoCreate=true). The click itself is proof the recipient owns | |
| 19 | * the address — same trust model as a verification link. | |
| 20 | * | |
| 21 | * Security: | |
| 22 | * - Plaintext token NEVER persists; we store only SHA-256(token). | |
| 23 | * - startMagicLinkSignIn never reveals whether the email exists. | |
| 24 | * - consumeMagicLinkToken invalidates every other unused magic-link | |
| 25 | * for the same email on success (prevents multi-link abuse). | |
| 26 | * - Per-email rate limit: at most 3 token mints per hour. The HTTP | |
| 27 | * surface adds a per-IP rate limit on top of that. | |
| 28 | */ | |
| 29 | ||
| 30 | import { eq } from "drizzle-orm"; | |
| 31 | import { db } from "../db"; | |
| 32 | import { users, magicLinkTokens } from "../db/schema"; | |
| 33 | import { hashPassword } from "./auth"; | |
| 34 | import { sendEmail, absoluteUrl, type EmailMessage } from "./email"; | |
| 35 | ||
| 36 | const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes | |
| 37 | const MAX_TOKENS_PER_EMAIL_PER_HOUR = 3; | |
| 38 | ||
| 39 | // --------------------------------------------------------------------------- | |
| 40 | // Test seam — swap the email sender without `mock.module`. Mirrors P1. | |
| 41 | // --------------------------------------------------------------------------- | |
| 42 | type EmailSender = (msg: EmailMessage) => Promise<unknown> | unknown; | |
| 43 | let _emailSender: EmailSender = sendEmail; | |
| 44 | export function __setEmailForTests(fn: EmailSender | null): void { | |
| 45 | _emailSender = fn ?? sendEmail; | |
| 46 | } | |
| 47 | ||
| 48 | // --------------------------------------------------------------------------- | |
| 49 | // Token primitives. | |
| 50 | // --------------------------------------------------------------------------- | |
| 51 | ||
| 52 | function toHex(bytes: Uint8Array): string { | |
| 53 | let out = ""; | |
| 54 | for (let i = 0; i < bytes.length; i++) | |
| 55 | out += bytes[i]!.toString(16).padStart(2, "0"); | |
| 56 | return out; | |
| 57 | } | |
| 58 | ||
| 59 | async function sha256Hex(input: string): Promise<string> { | |
| 60 | const buf = new TextEncoder().encode(input); | |
| 61 | const digest = await crypto.subtle.digest("SHA-256", buf); | |
| 62 | return toHex(new Uint8Array(digest)); | |
| 63 | } | |
| 64 | ||
| 65 | export function generateMagicLinkToken(): { plaintext: string; hash: string } { | |
| 66 | const bytes = crypto.getRandomValues(new Uint8Array(32)); | |
| 67 | const plaintext = toHex(bytes); | |
| 68 | const hasher = new Bun.CryptoHasher("sha256"); | |
| 69 | hasher.update(plaintext); | |
| 70 | const hash = hasher.digest("hex"); | |
| 71 | return { plaintext, hash }; | |
| 72 | } | |
| 73 | ||
| 74 | // --------------------------------------------------------------------------- | |
| 75 | // Email template — reuses the same dark-theme gradient shell as P1. | |
| 76 | // --------------------------------------------------------------------------- | |
| 77 | ||
| 78 | function escapeHtml(s: string): string { | |
| 79 | return s | |
| 80 | .replace(/&/g, "&") | |
| 81 | .replace(/</g, "<") | |
| 82 | .replace(/>/g, ">") | |
| 83 | .replace(/"/g, """) | |
| 84 | .replace(/'/g, "'"); | |
| 85 | } | |
| 86 | ||
| 87 | function buildMagicLinkEmail(opts: { signInUrl: string }) { | |
| 88 | const subject = "Your Gluecron sign-in link"; | |
| 89 | const text = [ | |
| 90 | "Hi,", | |
| 91 | "", | |
| 92 | "Click the link below to sign in to Gluecron. This link expires in 15 minutes.", | |
| 93 | "", | |
| 94 | `Sign in: ${opts.signInUrl}`, | |
| 95 | "", | |
| 96 | "If you didn't request this, ignore this email — no one can sign in", | |
| 97 | "without clicking the link.", | |
| 98 | "", | |
| 99 | "— gluecron", | |
| 100 | ].join("\n"); | |
| 101 | ||
| 102 | const safeUrl = escapeHtml(opts.signInUrl); | |
| 103 | const html = `<!doctype html> | |
| 104 | <html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title></head> | |
| 105 | <body style="margin:0;padding:0;background:#0d1117;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#c9d1d9"> | |
| 106 | <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0d1117"> | |
| 107 | <tr><td align="center" style="padding:32px 16px"> | |
| 108 | <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden"> | |
| 109 | <tr><td style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);padding:24px 28px"> | |
| 110 | <div style="font-size:20px;font-weight:700;color:#fff;letter-spacing:-0.01em">gluecron</div> | |
| 111 | <div style="font-size:13px;color:rgba(255,255,255,0.85);margin-top:2px">Magic sign-in link</div> | |
| 112 | </td></tr> | |
| 113 | <tr><td style="padding:28px"> | |
| 114 | <p style="margin:0 0 12px;font-size:15px;color:#e6edf3">Hi,</p> | |
| 115 | <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9">Click the button below to sign in to Gluecron. This link expires in 15 minutes.</p> | |
| 116 | <p style="margin:0 0 24px"><a href="${safeUrl}" style="display:inline-block;background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9999px">Sign in</a></p> | |
| 117 | <p style="margin:0 0 8px;font-size:13px;color:#8b949e">Or copy this link into your browser:</p> | |
| 118 | <p style="margin:0 0 24px;font-size:12px;color:#8b949e;word-break:break-all"><a href="${safeUrl}" style="color:#58a6ff;text-decoration:none">${safeUrl}</a></p> | |
| 119 | <p style="margin:0;font-size:12px;color:#8b949e;line-height:1.55">If you didn't request this, ignore this email — no one can sign in without clicking the link.</p> | |
| 120 | </td></tr> | |
| 121 | <tr><td style="padding:16px 28px;border-top:1px solid #30363d;font-size:11px;color:#6e7681">gluecron — AI-native code intelligence</td></tr> | |
| 122 | </table> | |
| 123 | </td></tr> | |
| 124 | </table> | |
| 125 | </body></html>`; | |
| 126 | ||
| 127 | return { subject, text, html }; | |
| 128 | } | |
| 129 | ||
| 130 | export function buildMagicLinkUrl(plaintextToken: string): string { | |
| 131 | const path = `/login/magic/callback?token=${encodeURIComponent(plaintextToken)}`; | |
| 132 | return absoluteUrl(path); | |
| 133 | } | |
| 134 | ||
| 135 | // --------------------------------------------------------------------------- | |
| 136 | // startMagicLinkSignIn — always returns ok, never reveals enumeration. | |
| 137 | // --------------------------------------------------------------------------- | |
| 138 | ||
| 139 | export async function startMagicLinkSignIn( | |
| 140 | email: string, | |
| 141 | opts: { requestIp?: string; autoCreate?: boolean } = {} | |
| 142 | ): Promise<{ ok: boolean }> { | |
| 143 | const autoCreate = opts.autoCreate !== false; // default true | |
| 144 | const normalized = String(email || "").trim().toLowerCase(); | |
| 145 | if (!normalized || !normalized.includes("@")) return { ok: true }; | |
| 146 | ||
| 147 | try { | |
| 148 | // Per-email throttle — prevents enumeration via timing AND volume. | |
| 149 | const recent = await db | |
| 150 | .select({ | |
| 151 | id: magicLinkTokens.id, | |
| 152 | createdAt: magicLinkTokens.createdAt, | |
| 153 | }) | |
| 154 | .from(magicLinkTokens) | |
| 155 | .where(eq(magicLinkTokens.email, normalized)) | |
| 156 | .limit(100); | |
| 157 | const cutoff = Date.now() - 60 * 60 * 1000; | |
| 158 | const recentCount = recent.filter( | |
| 159 | (r) => new Date(r.createdAt).getTime() > cutoff | |
| 160 | ).length; | |
| 161 | if (recentCount >= MAX_TOKENS_PER_EMAIL_PER_HOUR) { | |
| 162 | console.error( | |
| 163 | `[magic-link] per-email rate limit hit for ${JSON.stringify(normalized)} ip=${opts.requestIp || "?"} — generic success returned` | |
| 164 | ); | |
| 165 | return { ok: true }; | |
| 166 | } | |
| 167 | ||
| 168 | const [user] = await db | |
| 169 | .select({ id: users.id, email: users.email }) | |
| 170 | .from(users) | |
| 171 | .where(eq(users.email, normalized)) | |
| 172 | .limit(1); | |
| 173 | ||
| 174 | if (!user && !autoCreate) { | |
| 175 | console.error( | |
| 176 | `[magic-link] no user for email=${JSON.stringify(normalized)} ip=${opts.requestIp || "?"} autoCreate=false — generic success returned` | |
| 177 | ); | |
| 178 | return { ok: true }; | |
| 179 | } | |
| 180 | ||
| 181 | const { plaintext, hash } = generateMagicLinkToken(); | |
| 182 | const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS); | |
| 183 | ||
| 184 | await db.insert(magicLinkTokens).values({ | |
| 185 | email: normalized, | |
| 186 | userId: user?.id ?? null, | |
| 187 | tokenHash: hash, | |
| 188 | expiresAt, | |
| 189 | requestIp: opts.requestIp || null, | |
| 190 | }); | |
| 191 | ||
| 192 | const signInUrl = buildMagicLinkUrl(plaintext); | |
| 193 | const msg = buildMagicLinkEmail({ signInUrl }); | |
| 194 | ||
| 195 | // Fire-and-forget — never block the response on email send. | |
| 196 | Promise.resolve() | |
| 197 | .then(() => | |
| 198 | _emailSender({ | |
| 199 | to: normalized, | |
| 200 | subject: msg.subject, | |
| 201 | text: msg.text, | |
| 202 | html: msg.html, | |
| 203 | }) | |
| 204 | ) | |
| 205 | .catch((err) => | |
| 206 | console.error("[magic-link] email send error:", err) | |
| 207 | ); | |
| 208 | ||
| 209 | return { ok: true }; | |
| 210 | } catch (err) { | |
| 211 | console.error("[magic-link] startMagicLinkSignIn error:", err); | |
| 212 | return { ok: true }; | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | // --------------------------------------------------------------------------- | |
| 217 | // consumeMagicLinkToken — happy path returns a userId + createdAccount flag. | |
| 218 | // --------------------------------------------------------------------------- | |
| 219 | ||
| 220 | export async function consumeMagicLinkToken( | |
| 221 | token: string, | |
| 222 | opts: { autoCreate?: boolean; requestIp?: string } = {} | |
| 223 | ): Promise<{ | |
| 224 | ok: boolean; | |
| 225 | userId?: string; | |
| 226 | createdAccount?: boolean; | |
| 227 | reason?: string; | |
| 228 | }> { | |
| 229 | const autoCreate = opts.autoCreate !== false; // default true | |
| 230 | const plaintext = String(token || "").trim(); | |
| 231 | if (!plaintext) return { ok: false, reason: "invalid" }; | |
| 232 | ||
| 233 | try { | |
| 234 | const hash = await sha256Hex(plaintext); | |
| 235 | const [row] = await db | |
| 236 | .select() | |
| 237 | .from(magicLinkTokens) | |
| 238 | .where(eq(magicLinkTokens.tokenHash, hash)) | |
| 239 | .limit(1); | |
| 240 | ||
| 241 | if (!row) return { ok: false, reason: "invalid" }; | |
| 242 | if (row.usedAt) return { ok: false, reason: "used" }; | |
| 243 | if (new Date(row.expiresAt).getTime() < Date.now()) | |
| 244 | return { ok: false, reason: "expired" }; | |
| 245 | ||
| 246 | let userId = row.userId ?? undefined; | |
| 247 | let createdAccount = false; | |
| 248 | ||
| 249 | if (!userId) { | |
| 250 | if (!autoCreate) return { ok: false, reason: "no-account" }; | |
| 251 | // Mint a fresh account. The click is proof of email ownership, so | |
| 252 | // we set emailVerifiedAt immediately. The password hash is a non- | |
| 253 | // matchable placeholder — the user can set a real password later | |
| 254 | // via /settings, or just keep using magic links forever. | |
| 255 | // We mint the UUID client-side so we don't need .returning() on the | |
| 256 | // insert (keeps the surface minimal + cheap to stub in tests). | |
| 257 | const username = await pickFreshUsername(row.email); | |
| 258 | const placeholderPw = await hashPassword(generateRandomString(32)); | |
| 259 | const newUserId = crypto.randomUUID(); | |
| 260 | await db.insert(users).values({ | |
| 261 | id: newUserId, | |
| 262 | username, | |
| 263 | email: row.email, | |
| 264 | passwordHash: placeholderPw, | |
| 265 | emailVerifiedAt: new Date(), | |
| 266 | }); | |
| 267 | userId = newUserId; | |
| 268 | createdAccount = true; | |
| 269 | } | |
| 270 | ||
| 271 | const now = new Date(); | |
| 272 | ||
| 273 | // Mark the current token used. | |
| 274 | await db | |
| 275 | .update(magicLinkTokens) | |
| 276 | .set({ usedAt: now }) | |
| 277 | .where(eq(magicLinkTokens.id, row.id)); | |
| 278 | ||
| 279 | // Invalidate every other unused magic-link for this email. We use a | |
| 280 | // broad eq(email) — already-used rows already have usedAt set, so | |
| 281 | // this is a no-op for them; what matters is unused rows being | |
| 282 | // burned so a second link mailed within the 15-min window can't be | |
| 283 | // replayed. | |
| 284 | await db | |
| 285 | .update(magicLinkTokens) | |
| 286 | .set({ usedAt: now }) | |
| 287 | .where(eq(magicLinkTokens.email, row.email)); | |
| 288 | ||
| 289 | return { ok: true, userId, createdAccount }; | |
| 290 | } catch (err) { | |
| 291 | console.error("[magic-link] consumeMagicLinkToken error:", err); | |
| 292 | return { ok: false, reason: "invalid" }; | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | // --------------------------------------------------------------------------- | |
| 297 | // Auto-create helpers. | |
| 298 | // --------------------------------------------------------------------------- | |
| 299 | ||
| 300 | function generateRandomString(n: number): string { | |
| 301 | const bytes = crypto.getRandomValues(new Uint8Array(n)); | |
| 302 | return toHex(bytes); | |
| 303 | } | |
| 304 | ||
| 305 | /** 8 url-safe lowercase chars derived from random bytes. */ | |
| 306 | function shortSuffix(): string { | |
| 307 | const bytes = crypto.getRandomValues(new Uint8Array(8)); | |
| 308 | const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; | |
| 309 | let out = ""; | |
| 310 | for (let i = 0; i < 8; i++) out += alphabet[bytes[i]! % alphabet.length]; | |
| 311 | return out; | |
| 312 | } | |
| 313 | ||
| 314 | /** | |
| 315 | * Pick a free `user-XXXXXXXX` username. Retries a handful of times on | |
| 316 | * collision. Pure name minting — no DB writes here. | |
| 317 | */ | |
| 318 | async function pickFreshUsername(_emailHint: string): Promise<string> { | |
| 319 | for (let attempt = 0; attempt < 8; attempt++) { | |
| 320 | const candidate = `user-${shortSuffix()}`; | |
| 321 | const [clash] = await db | |
| 322 | .select({ id: users.id }) | |
| 323 | .from(users) | |
| 324 | .where(eq(users.username, candidate)) | |
| 325 | .limit(1); | |
| 326 | if (!clash) return candidate; | |
| 327 | } | |
| 328 | // 8 random 8-char rolls all colliding is astronomically unlikely; if it | |
| 329 | // happens we fall back to a longer suffix that's effectively unique. | |
| 330 | return `user-${shortSuffix()}${shortSuffix()}`; | |
| 331 | } |