From 8696eca022a2641049fe2552f68dfffd35f1b58f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 10 Aug 2026 10:49:25 +0800 Subject: [PATCH] fix: keep one active email OTP per account --- ...024500_unique_verification_identifiers.sql | 18 +++++++++++ .../database-self-hosted-identity.test.ts | 29 +++++++++++++++++ .../tests/identity-auth-integration.test.ts | 31 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 frontend/db/migrations/20260810024500_unique_verification_identifiers.sql diff --git a/frontend/db/migrations/20260810024500_unique_verification_identifiers.sql b/frontend/db/migrations/20260810024500_unique_verification_identifiers.sql new file mode 100644 index 00000000..cf233680 --- /dev/null +++ b/frontend/db/migrations/20260810024500_unique_verification_identifiers.sql @@ -0,0 +1,18 @@ +lock table identity.verifications in share row exclusive mode; + +with ranked as ( + select + id, + row_number() over ( + partition by identifier + order by created_at desc, id desc + ) as position + from identity.verifications +) +delete from identity.verifications verification +using ranked +where verification.id = ranked.id + and ranked.position > 1; + +create unique index if not exists identity_verifications_identifier_key + on identity.verifications (identifier); diff --git a/frontend/tests/database-self-hosted-identity.test.ts b/frontend/tests/database-self-hosted-identity.test.ts index 0e0138e2..92f64bfe 100644 --- a/frontend/tests/database-self-hosted-identity.test.ts +++ b/frontend/tests/database-self-hosted-identity.test.ts @@ -18,6 +18,12 @@ const identityMigration = fileURLToPath( import.meta.url, ), ); +const uniqueVerificationMigration = fileURLToPath( + new URL( + "../db/migrations/20260810024500_unique_verification_identifiers.sql", + import.meta.url, + ), +); test("self-hosted identity migration creates Better Auth tables with least privilege", () => { const migrationSource = readFileSync(identityMigration, "utf8"); @@ -225,6 +231,29 @@ test("self-hosted identity migration creates Better Auth tables with least privi ), "1", ); + + fixture.psql("drop index identity.identity_verifications_identifier_key"); + fixture.psql(` + insert into identity.verifications (identifier, value, expires_at, created_at) + values + ('sign-in-otp-owner@example.com', 'older', now() + interval '5 minutes', now() - interval '1 second'), + ('sign-in-otp-owner@example.com', 'newer', now() + interval '5 minutes', now()) + `); + fixture.psql(readFileSync(uniqueVerificationMigration, "utf8")); + assert.equal( + fixture.psql(` + select value + from identity.verifications + where identifier = 'sign-in-otp-owner@example.com' + `), + "newer", + ); + assert.throws(() => + fixture.psql(` + insert into identity.verifications (identifier, value, expires_at) + values ('sign-in-otp-owner@example.com', 'duplicate', now() + interval '5 minutes') + `), + ); } finally { fixture.stop(); } diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts index 5ec4b4e3..1be745e8 100644 --- a/frontend/tests/identity-auth-integration.test.ts +++ b/frontend/tests/identity-auth-integration.test.ts @@ -204,6 +204,37 @@ test("Better Auth supports shared user OTP/password sessions for admins", async ); assert.equal(unauthenticatedSet.status, 401); + const rotatedEmail = "rotated-otp@example.com"; + for (let attempt = 0; attempt < 2; attempt += 1) { + const send = await handlers.POST( + request(userHost, "/api/auth/email-otp/send-verification-otp", { + email: rotatedEmail, + type: "sign-in", + }), + ); + assert.equal(send.status, 200); + } + const rotatedMessage = sender.messages.at(-1); + assert.equal( + fixture.psql(` + select count(*) + from identity.verifications + where identifier = 'sign-in-otp-${rotatedEmail}' + `), + "1", + ); + assert.equal( + ( + await handlers.POST( + request(userHost, "/api/auth/sign-in/email-otp", { + email: rotatedEmail, + otp: rotatedMessage?.otp, + }), + ) + ).status, + 200, + ); + const newEmail = "new-user@example.com"; const firstPassword = "first-password"; const resetPassword = "reset-password";