Add purpose column to magic_tokens, fix email change race condition
- Add `purpose` column (default: 'login') to distinguish login tokens from email-change tokens - verifyMagicToken only matches purpose='login' - verifyEmailChange only matches purpose='email-change' - Re-check email availability at verification time, not just initiation — prevents race where someone registers with the new email between request and verification Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
715b272395
commit
a766bffad5
2 changed files with 14 additions and 8 deletions
|
|
@ -262,11 +262,10 @@ export async function initiateEmailChange(userId: string, newEmail: string): Pro
|
|||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
|
||||
// Store new email in the token — verifyMagicToken will detect the mismatch
|
||||
// and update the user's email
|
||||
await db.insert(magicTokens).values({
|
||||
id: randomUUID(),
|
||||
email: newEmail,
|
||||
purpose: "email-change",
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
|
@ -283,6 +282,7 @@ export async function verifyMagicToken(token: string): Promise<string> {
|
|||
.where(
|
||||
and(
|
||||
eq(magicTokens.token, token),
|
||||
eq(magicTokens.purpose, "login"),
|
||||
gt(magicTokens.expiresAt, new Date()),
|
||||
isNull(magicTokens.usedAt),
|
||||
),
|
||||
|
|
@ -290,14 +290,11 @@ export async function verifyMagicToken(token: string): Promise<string> {
|
|||
|
||||
if (!record) throw new Error("Invalid or expired magic link");
|
||||
|
||||
// Mark as used
|
||||
await db
|
||||
.update(magicTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(magicTokens.id, record.id));
|
||||
|
||||
// Find user — for login/registration tokens, email matches an existing user.
|
||||
// For email-change tokens, the email is the NEW email (no user yet).
|
||||
const [user] = await db.select().from(users).where(eq(users.email, record.email));
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
|
|
@ -313,6 +310,7 @@ export async function verifyEmailChange(token: string, userId: string): Promise<
|
|||
.where(
|
||||
and(
|
||||
eq(magicTokens.token, token),
|
||||
eq(magicTokens.purpose, "email-change"),
|
||||
gt(magicTokens.expiresAt, new Date()),
|
||||
isNull(magicTokens.usedAt),
|
||||
),
|
||||
|
|
@ -320,14 +318,21 @@ export async function verifyEmailChange(token: string, userId: string): Promise<
|
|||
|
||||
if (!record) throw new Error("Invalid or expired verification link");
|
||||
|
||||
const newEmail = record.email;
|
||||
|
||||
// Re-check email availability at verification time — someone may have
|
||||
// registered with this email between initiation and verification
|
||||
const [existing] = await db.select().from(users).where(eq(users.email, newEmail));
|
||||
if (existing) {
|
||||
await db.update(magicTokens).set({ usedAt: new Date() }).where(eq(magicTokens.id, record.id));
|
||||
throw new Error("This email is now in use by another account");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(magicTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(magicTokens.id, record.id));
|
||||
|
||||
const newEmail = record.email;
|
||||
|
||||
// Update user email
|
||||
await db
|
||||
.update(users)
|
||||
.set({ email: newEmail })
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const magicTokens = journalSchema.table("magic_tokens", {
|
|||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
purpose: text("purpose").notNull().default("login"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue