Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

magic-link.tsBlame331 lines · 2 contributors
cd4f63bTest User1/**
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
30import { eq } from "drizzle-orm";
31import { db } from "../db";
32import { users, magicLinkTokens } from "../db/schema";
33import { hashPassword } from "./auth";
34import { sendEmail, absoluteUrl, type EmailMessage } from "./email";
35
36const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes
37const MAX_TOKENS_PER_EMAIL_PER_HOUR = 3;
38
39// ---------------------------------------------------------------------------
40// Test seam — swap the email sender without `mock.module`. Mirrors P1.
41// ---------------------------------------------------------------------------
42type EmailSender = (msg: EmailMessage) => Promise<unknown> | unknown;
43let _emailSender: EmailSender = sendEmail;
44export function __setEmailForTests(fn: EmailSender | null): void {
45 _emailSender = fn ?? sendEmail;
46}
47
48// ---------------------------------------------------------------------------
49// Token primitives.
50// ---------------------------------------------------------------------------
51
52function 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
59async 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
65export 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
78function escapeHtml(s: string): string {
79 return s
80 .replace(/&/g, "&amp;")
81 .replace(/</g, "&lt;")
82 .replace(/>/g, "&gt;")
83 .replace(/"/g, "&quot;")
84 .replace(/'/g, "&#39;");
85}
86
87function 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">
6fd5915Claude109 <tr><td style="background:linear-gradient(135deg,#5b6ee8 0%,#5f8fa0 100%);padding:24px 28px">
cd4f63bTest User110 <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>
6fd5915Claude116 <p style="margin:0 0 24px"><a href="${safeUrl}" style="display:inline-block;background:linear-gradient(135deg,#5b6ee8 0%,#5f8fa0 100%);color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9999px">Sign in</a></p>
cd4f63bTest User117 <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
130export 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
139export 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
220export 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
300function 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. */
306function 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 */
318async 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}