Skip to content

Commit 7518a62

Browse files
prevent email send on verified email hash
1 parent e025ab4 commit 7518a62

2 files changed

Lines changed: 35 additions & 22 deletions

File tree

src/app/api/auth/magic/route.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { logApiReject } from "@/lib/api-log";
99
import { admitMagic } from "@/lib/ratelimit";
1010
import { clientIp } from "@/lib/geo";
1111
import { siteBaseUrl, siteHost } from "@/lib/site";
12+
import { emailHashVerified } from "@/lib/account-link";
1213

1314
export const runtime = "nodejs";
1415

@@ -27,32 +28,36 @@ export async function POST(req: NextRequest) {
2728
const hashed = emailHash(email);
2829
const ipHash = hashKeyed(clientIp(req.headers));
2930

30-
const verdict = await admitMagic(ipHash, hashed);
31-
if (!verdict.ok) {
32-
logApiReject("auth/magic", "skipped", { reason: verdict.reason });
31+
if (await emailHashVerified(hashed)) {
32+
logApiReject("auth/magic", "skipped", { reason: "already_verified" });
3333
} else {
34-
const nonce = newNonce();
35-
const token = signNonce(nonce);
36-
const expiresAt = new Date(Date.now() + MAGIC_TTL_MS);
34+
const verdict = await admitMagic(ipHash, hashed);
35+
if (!verdict.ok) {
36+
logApiReject("auth/magic", "skipped", { reason: verdict.reason });
37+
} else {
38+
const nonce = newNonce();
39+
const token = signNonce(nonce);
40+
const expiresAt = new Date(Date.now() + MAGIC_TTL_MS);
3741

38-
await db.insert(magicTokens).values({
39-
nonce,
40-
emailHash: hashed,
41-
localId,
42-
expiresAt,
43-
});
42+
await db.insert(magicTokens).values({
43+
nonce,
44+
emailHash: hashed,
45+
localId,
46+
expiresAt,
47+
});
4448

45-
const base = siteBaseUrl(req.nextUrl.origin);
46-
const host = siteHost(req.nextUrl.origin);
47-
const link = `${base}/api/auth/verify?token=${encodeURIComponent(token)}`;
49+
const base = siteBaseUrl(req.nextUrl.origin);
50+
const host = siteHost(req.nextUrl.origin);
51+
const link = `${base}/api/auth/verify?token=${encodeURIComponent(token)}`;
4852

49-
await sendMail({
50-
to: email,
51-
subject: `Verify your ${host} account`,
52-
html: `<p>Click below to verify your account on ${host}:</p><p><a href="${link}">Verify my account</a></p><p>Confirm on the same device that requested this email. Linked devices stay connected. Expires in 30 minutes. If you didn't request this, ignore it.</p>`,
53-
}).catch((err) => {
54-
console.error("[auth/magic] send failed:", err);
55-
});
53+
await sendMail({
54+
to: email,
55+
subject: `Verify your ${host} account`,
56+
html: `<p>Click below to verify your account on ${host}:</p><p><a href="${link}">Verify my account</a></p><p>Confirm on the same device that requested this email. Linked devices stay connected. Expires in 30 minutes. If you didn't request this, ignore it.</p>`,
57+
}).catch((err) => {
58+
console.error("[auth/magic] send failed:", err);
59+
});
60+
}
5661
}
5762
} else {
5863
logApiReject("auth/magic", "skipped", {

src/lib/account-link.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export type DeviceLinkStatus = {
1919
siblingCount: number;
2020
};
2121

22+
/** True when this email has completed magic-link verification (any device). */
23+
export async function emailHashVerified(hashed: string): Promise<boolean> {
24+
const r = (await db.execute(sql`
25+
SELECT 1 FROM accounts WHERE email_hash = ${hashed} LIMIT 1;
26+
`)) as unknown as { rows: unknown[] };
27+
return !!r.rows?.[0];
28+
}
29+
2230
/** Email verification + device link status for the current device. */
2331
export async function deviceLinkStatus(localId: string): Promise<DeviceLinkStatus | null> {
2432
const r = (await db.execute(sql`

0 commit comments

Comments
 (0)