fix: keep one active email OTP per account
This commit is contained in:
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user