fix(sync): harden passkey registration verification (#8985)

* fix(sync): preserve offline ops and harden auth

* fix(sync): prevent unverified passkey replacement

Keep existing credentials until email ownership is verified and scope failed-delivery cleanup to the exact pending registration. Reject negative operation timestamps while still allowing old offline operations.\n\nRefs #8961

* test(sync): cover registration races in PostgreSQL

* ci(sync): run PostgreSQL integration tests

* fix(sync): bind passkeys to email verification
This commit is contained in:
Johannes Millan 2026-07-13 22:58:37 +02:00 committed by GitHub
parent 329da9b9f3
commit 46e98c67dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 721 additions and 116 deletions

View file

@ -9,6 +9,7 @@
"dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts",
"pretest": "prisma generate",
"test": "vitest run",
"test:integration:postgres": "vitest run --config vitest.integration.config.ts --maxWorkers=1 tests/integration/registration-races.integration.spec.ts tests/integration/clean-slate-atomicity-sql.integration.spec.ts tests/integration/conflict-detection-sql.integration.spec.ts tests/integration/snapshot-vector-clock-sql.integration.spec.ts",
"docker:build": "./scripts/build-and-push.sh",
"docker:deploy": "./scripts/deploy.sh",
"docker:deploy:build": "./scripts/deploy.sh --build",

View file

@ -0,0 +1,61 @@
CREATE TABLE "pending_passkey_registrations" (
"id" TEXT NOT NULL,
"verification_token" TEXT NOT NULL,
"verification_token_expires_at" BIGINT NOT NULL,
"credential_id" BYTEA NOT NULL,
"public_key" BYTEA NOT NULL,
"counter" BIGINT NOT NULL DEFAULT 0,
"transports" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"user_id" INTEGER NOT NULL,
CONSTRAINT "pending_passkey_registrations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "pending_passkey_registrations_verification_token_key"
ON "pending_passkey_registrations"("verification_token");
CREATE INDEX "pending_passkey_registrations_user_id_idx"
ON "pending_passkey_registrations"("user_id");
ALTER TABLE "pending_passkey_registrations"
ADD CONSTRAINT "pending_passkey_registrations_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Move credentials created by the legacy registration flow out of the active
-- passkey set. The most recently created credential is the one associated with
-- the user's current verification token because re-registration replaced the
-- previous credential before rotating that token.
INSERT INTO "pending_passkey_registrations" (
"id",
"verification_token",
"verification_token_expires_at",
"credential_id",
"public_key",
"counter",
"transports",
"created_at",
"user_id"
)
SELECT DISTINCT ON (p."user_id")
'legacy-' || p."id",
u."verification_token",
u."verification_token_expires_at",
p."credential_id",
p."public_key",
p."counter",
p."transports",
p."created_at",
p."user_id"
FROM "passkeys" p
JOIN "users" u ON u."id" = p."user_id"
WHERE u."is_verified" = 0
AND u."verification_token" IS NOT NULL
AND u."verification_token_expires_at" IS NOT NULL
ORDER BY p."user_id", p."created_at" DESC, p."id" DESC
ON CONFLICT ("verification_token") DO NOTHING;
DELETE FROM "passkeys" p
USING "users" u
WHERE u."id" = p."user_id"
AND u."is_verified" = 0;

View file

@ -36,6 +36,7 @@ model User {
syncState UserSyncState?
devices SyncDevice[]
passkeys Passkey[]
pendingPasskeyRegistrations PendingPasskeyRegistration[]
@@index([verificationToken])
@@index([resetPasswordToken])
@ -60,6 +61,23 @@ model Passkey {
@@map("passkeys")
}
model PendingPasskeyRegistration {
id String @id @default(cuid())
verificationToken String @unique @map("verification_token")
verificationTokenExpiresAt BigInt @map("verification_token_expires_at")
credentialId Bytes @map("credential_id")
publicKey Bytes @map("public_key")
counter BigInt @default(0)
transports String?
createdAt DateTime @default(now()) @map("created_at")
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("pending_passkey_registrations")
}
model Operation {
id String @id
userId Int @map("user_id")

View file

@ -41,6 +41,51 @@ export const getJwtSecret = (): string => {
const JWT_SECRET = getJwtSecret();
export const verifyEmail = async (token: string): Promise<boolean> => {
const pendingPasskey = await prisma.pendingPasskeyRegistration.findUnique({
where: { verificationToken: token },
});
if (pendingPasskey) {
if (pendingPasskey.verificationTokenExpiresAt < BigInt(Date.now())) {
throw new Error('Verification token has expired');
}
const activated = await prisma.$transaction(async (tx) => {
const claim = await tx.user.updateMany({
where: { id: pendingPasskey.userId, isVerified: 0 },
data: {
isVerified: 1,
verificationToken: null,
verificationTokenExpiresAt: null,
verificationResendCount: 0,
},
});
if (claim.count !== 1) return false;
// Only the credential carried by this exact email link is trusted. Other
// attempts for the same address may have been initiated by someone else.
await tx.passkey.deleteMany({ where: { userId: pendingPasskey.userId } });
await tx.passkey.create({
data: {
userId: pendingPasskey.userId,
credentialId: pendingPasskey.credentialId,
publicKey: pendingPasskey.publicKey,
counter: pendingPasskey.counter,
transports: pendingPasskey.transports,
},
});
await tx.pendingPasskeyRegistration.deleteMany({
where: { userId: pendingPasskey.userId },
});
return true;
});
if (!activated) throw new Error('Invalid verification token');
authCache.invalidate(pendingPasskey.userId);
Logger.info(`User verified with passkey (ID: ${pendingPasskey.userId})`);
return true;
}
const user = await prisma.user.findFirst({
where: { verificationToken: token },
});
@ -56,15 +101,26 @@ export const verifyEmail = async (token: string): Promise<boolean> => {
throw new Error('Verification token has expired');
}
await prisma.user.update({
where: { id: user.id },
data: {
isVerified: 1,
verificationToken: null,
verificationTokenExpiresAt: null,
verificationResendCount: 0,
},
const verified = await prisma.$transaction(async (tx) => {
const claim = await tx.user.updateMany({
where: { id: user.id, isVerified: 0, verificationToken: token },
data: {
isVerified: 1,
verificationToken: null,
verificationTokenExpiresAt: null,
verificationResendCount: 0,
},
});
if (claim.count !== 1) return false;
// Reaching the user-token path means this is a magic-link registration:
// passkey registration tokens live only in pendingPasskeyRegistration.
// Email ownership does not prove ownership of a separately submitted key.
await tx.passkey.deleteMany({ where: { userId: user.id } });
await tx.pendingPasskeyRegistration.deleteMany({ where: { userId: user.id } });
return true;
});
if (!verified) throw new Error('Invalid verification token');
// AUTH_CACHE_INVALIDATION: drop any negative (isVerified:false) entry so the
// now-verified user isn't denied for up to the cache TTL, and so the cache
@ -374,9 +430,15 @@ export const registerWithMagicLink = async (
}
}
// Update existing unverified user with new verification token
await prisma.user.update({
where: { id: existingUser.id },
// Update the same still-unverified row that was checked above. If another
// request verified or removed it while the email was in flight, the link
// is simply left inactive and the response remains neutral.
const updated = await prisma.user.updateMany({
where: {
id: existingUser.id,
isVerified: 0,
verificationResendCount: { lt: MAX_VERIFICATION_RESEND_COUNT },
},
data: {
verificationToken,
verificationTokenExpiresAt: tokenExpiresAt,
@ -384,6 +446,7 @@ export const registerWithMagicLink = async (
...(acceptedAt !== undefined && { termsAcceptedAt: acceptedAt }),
},
});
if (updated.count !== 1) return { message: REGISTRATION_SUCCESS_MESSAGE };
Logger.info(
`Updated verification token for unverified user (ID: ${existingUser.id})`,
@ -403,18 +466,10 @@ export const registerWithMagicLink = async (
Logger.info(`Created new magic-link user`);
if (!config.testMode?.autoVerifyUsers) {
// Send verification email; clean up new user on failure
// Keep the unverified row on delivery failure. Deleting it can race a
// concurrent registration that has already started using the same row.
const emailSent = await sendVerificationEmail(normalizedEmail, verificationToken);
if (!emailSent) {
// AUTH_CACHE_INVALIDATION: no invalidate needed here. This only
// deletes the user just created above (isVerified: 0). No JWT is
// issued for an unverified user, so verifyToken was never called for
// it and no authCache entry can exist. Documented to keep the
// bracket-every-delete convention auditable.
await prisma.user.deleteMany({
where: { email: normalizedEmail, isVerified: 0 },
});
Logger.info(`Cleaned up failed magic-link registration for new user`);
return { message: REGISTRATION_SUCCESS_MESSAGE };
}
}

View file

@ -13,10 +13,14 @@ import type {
import { prisma } from './db';
import { Logger } from './logger';
import { randomBytes } from 'crypto';
import { sendPasskeyRecoveryEmail } from './email';
import { sendPasskeyRecoveryEmail, sendVerificationEmail } from './email';
import { Prisma } from '@prisma/client';
import { loadConfigFromEnv } from './config';
import { VERIFICATION_TOKEN_EXPIRY_MS, MAX_VERIFICATION_RESEND_COUNT } from './auth';
import {
VERIFICATION_TOKEN_EXPIRY_MS,
MAX_VERIFICATION_RESEND_COUNT,
verifyEmail,
} from './auth';
import { authCache } from './auth-cache';
// Constants
@ -176,6 +180,8 @@ export const verifyRegistration = async (
const acceptedAt = termsAcceptedAt ? BigInt(termsAcceptedAt) : BigInt(Date.now());
try {
const config = loadConfigFromEnv();
// Check if unverified user exists (re-registration attempt)
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
@ -190,73 +196,54 @@ export const verifyRegistration = async (
Logger.warn(`Verification resend cap reached (ID: ${existingUser.id})`);
return { message: REGISTRATION_SUCCESS_MESSAGE };
}
}
// Update existing unverified user with new passkey
await prisma.$transaction(async (tx) => {
// Delete old passkeys
await tx.passkey.deleteMany({ where: { userId: existingUser.id } });
// Create new passkey
await tx.passkey.create({
data: {
credentialId: credentialIdRawBytes,
publicKey: Buffer.from(credentialInfo.publicKey),
counter: BigInt(credentialInfo.counter),
transports: credential.response.transports
? JSON.stringify(credential.response.transports)
: null,
userId: existingUser.id,
const pendingCreated = await prisma.$transaction(async (tx) => {
let userId: number;
if (existingUser) {
const claim = await tx.user.updateMany({
where: {
id: existingUser.id,
isVerified: 0,
verificationResendCount: { lt: MAX_VERIFICATION_RESEND_COUNT },
},
});
// Update user with new verification token
await tx.user.update({
where: { id: existingUser.id },
data: {
verificationToken,
verificationTokenExpiresAt: tokenExpiresAt,
verificationResendCount: { increment: 1 },
},
});
});
if (claim.count !== 1) return false;
userId = existingUser.id;
} else {
const createdUser = await tx.user.create({
data: {
email: email.toLowerCase(),
passwordHash: null,
termsAcceptedAt: acceptedAt,
},
});
userId = createdUser.id;
}
Logger.info(`Updated passkey for unverified user (ID: ${existingUser.id})`);
} else {
// Create new user with passkey
await prisma.user.create({
await tx.pendingPasskeyRegistration.create({
data: {
email: email.toLowerCase(),
passwordHash: null, // Passkey-only user
userId,
verificationToken,
verificationTokenExpiresAt: tokenExpiresAt,
termsAcceptedAt: acceptedAt,
passkeys: {
create: {
credentialId: credentialIdRawBytes,
publicKey: Buffer.from(credentialInfo.publicKey),
counter: BigInt(credentialInfo.counter),
transports: credential.response.transports
? JSON.stringify(credential.response.transports)
: null,
},
},
credentialId: credentialIdRawBytes,
publicKey: Buffer.from(credentialInfo.publicKey),
counter: BigInt(credentialInfo.counter),
transports: credential.response.transports
? JSON.stringify(credential.response.transports)
: null,
},
});
Logger.info(`Created new passkey user`);
}
return true;
});
if (!pendingCreated) return { message: REGISTRATION_SUCCESS_MESSAGE };
// In TEST_MODE with autoVerifyUsers, skip email and auto-verify
const config = loadConfigFromEnv();
if (config.testMode?.autoVerifyUsers) {
await prisma.user.update({
where: { email: email.toLowerCase() },
data: {
isVerified: 1,
verificationToken: null,
verificationTokenExpiresAt: null,
},
});
await verifyEmail(verificationToken);
Logger.info(`[TEST_MODE] Auto-verified passkey user`);
return {
message: 'Registration successful. Your account has been automatically verified.',
@ -264,24 +251,8 @@ export const verifyRegistration = async (
}
// Normal flow: send verification email
const { sendVerificationEmail } = await import('./email');
const emailSent = await sendVerificationEmail(email, verificationToken);
if (!emailSent) {
// Clean up on email failure for new users
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (user && user.isVerified === 0) {
// AUTH_CACHE_INVALIDATION: bracket the delete pre + post, matching every
// other user-delete site, so the convention stays uniformly auditable.
authCache.invalidate(user.id);
await prisma.user.delete({ where: { id: user.id } });
authCache.invalidate(user.id);
Logger.info(`Cleaned up failed passkey registration (ID: ${user.id})`);
}
return { message: REGISTRATION_SUCCESS_MESSAGE };
}
if (!emailSent) return { message: REGISTRATION_SUCCESS_MESSAGE };
Logger.info(`Passkey registration initiated`);
return { message: REGISTRATION_SUCCESS_MESSAGE };

View file

@ -0,0 +1,245 @@
/**
* Real-PostgreSQL coverage for registration races that mocks cannot prove.
*
* Prerequisites:
* DATABASE_URL=postgresql://supersync:superpassword@localhost:55432/supersync_db
*
* Run with:
* npx vitest run --config vitest.integration.config.ts \
* tests/integration/registration-races.integration.spec.ts
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
Mock,
vi,
} from 'vitest';
import { PrismaClient } from '@prisma/client';
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
vi.hoisted(() => {
process.env.JWT_SECRET = 'integration-test-jwt-secret-at-least-32-characters';
delete process.env.TEST_MODE;
delete process.env.TEST_MODE_CONFIRM;
});
vi.mock('../../src/email', () => ({
sendVerificationEmail: vi.fn(),
sendLoginMagicLinkEmail: vi.fn(),
sendPasskeyRecoveryEmail: vi.fn(),
}));
vi.mock('@simplewebauthn/server', async (importOriginal) => ({
...(await importOriginal<typeof import('@simplewebauthn/server')>()),
verifyRegistrationResponse: vi.fn(),
}));
import { disconnectDb } from '../../src/db';
import { sendVerificationEmail } from '../../src/email';
import * as webAuthn from '@simplewebauthn/server';
import { registerWithMagicLink, verifyEmail } from '../../src/auth';
import { generateRegistrationOptions, verifyRegistration } from '../../src/passkey';
const DATABASE_URL = process.env.DATABASE_URL;
const describeWithDb = DATABASE_URL ? describe : describe.skip;
const RUN_ID = `${Date.now()}-${process.pid}`;
const EMAIL_PREFIX = `registration-race-${RUN_ID}`;
const mockSendVerificationEmail = sendVerificationEmail as Mock;
const mockVerifyRegistrationResponse = webAuthn.verifyRegistrationResponse as Mock;
const registrationCredential = (id: string): RegistrationResponseJSON => ({
id,
rawId: id,
type: 'public-key',
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation',
transports: ['internal'],
},
clientExtensionResults: {},
});
const preparePasskeyRegistration = (credentialId: Buffer): void => {
const credentialIdBase64url = credentialId.toString('base64url');
mockVerifyRegistrationResponse.mockResolvedValueOnce({
verified: true,
registrationInfo: {
credential: {
id: new Uint8Array(Buffer.from(credentialIdBase64url)),
publicKey: new Uint8Array([5, 6, 7, 8]),
counter: 0,
},
credentialDeviceType: 'multiDevice',
credentialBackedUp: true,
},
});
};
const registerPasskey = async (email: string, credentialId: Buffer): Promise<void> => {
preparePasskeyRegistration(credentialId);
await generateRegistrationOptions(email);
await verifyRegistration(
email,
registrationCredential(credentialId.toString('base64url')),
);
};
const deferred = <T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
};
describeWithDb('Registration races (PostgreSQL)', () => {
let observer: PrismaClient;
beforeAll(() => {
if (!DATABASE_URL) throw new Error('DATABASE_URL is required for integration tests');
observer = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } });
});
beforeEach(() => {
vi.clearAllMocks();
mockSendVerificationEmail.mockResolvedValue(true);
});
afterEach(async () => {
await observer.user.deleteMany({ where: { email: { startsWith: EMAIL_PREFIX } } });
});
afterAll(async () => {
await observer.user.deleteMany({ where: { email: { startsWith: EMAIL_PREFIX } } });
await observer.$disconnect();
await disconnectDb();
});
it('activates only the passkey bound to the verification link', async () => {
const email = `${EMAIL_PREFIX}-existing@test.local`;
const attackerCredentialId = Buffer.from(`attacker-${RUN_ID}`);
const ownerCredentialId = Buffer.from(`owner-${RUN_ID}`);
await observer.user.create({
data: {
email,
verificationToken: 'original-verification-token',
verificationTokenExpiresAt: BigInt(Date.now() + 60_000),
verificationResendCount: 1,
passkeys: {
create: {
credentialId: attackerCredentialId,
publicKey: Buffer.from([1, 2, 3, 4]),
},
},
},
});
await registerPasskey(email, ownerCredentialId);
const verificationToken = mockSendVerificationEmail.mock.calls[0][1] as string;
await verifyEmail(verificationToken);
const storedUser = await observer.user.findUniqueOrThrow({
where: { email },
include: { passkeys: true },
});
expect(storedUser.passkeys).toHaveLength(1);
expect(storedUser.passkeys[0].credentialId).toEqual(ownerCredentialId);
expect(storedUser.isVerified).toBe(1);
});
it('keeps a passkey registration valid when a concurrent email delivery fails', async () => {
const email = `${EMAIL_PREFIX}-passkey-cleanup@test.local`;
const firstCredentialId = Buffer.from(`first-${RUN_ID}`);
const secondCredentialId = Buffer.from(`second-${RUN_ID}`);
const firstEmailStarted = deferred<void>();
const secondEmailStarted = deferred<void>();
const firstEmailResult = deferred<boolean>();
const secondEmailResult = deferred<boolean>();
mockSendVerificationEmail
.mockImplementationOnce(async () => {
firstEmailStarted.resolve();
return firstEmailResult.promise;
})
.mockImplementationOnce(async () => {
secondEmailStarted.resolve();
return secondEmailResult.promise;
});
const firstRegistration = registerPasskey(email, firstCredentialId);
await firstEmailStarted.promise;
const secondRegistration = registerPasskey(email, secondCredentialId);
await secondEmailStarted.promise;
firstEmailResult.resolve(false);
await firstRegistration;
secondEmailResult.resolve(true);
await secondRegistration;
const secondVerificationToken = mockSendVerificationEmail.mock.calls[1][1] as string;
await verifyEmail(secondVerificationToken);
const storedUser = await observer.user.findUnique({
where: { email },
include: { passkeys: true },
});
expect(storedUser?.passkeys).toHaveLength(1);
expect(storedUser?.passkeys[0].credentialId).toEqual(secondCredentialId);
expect(storedUser?.isVerified).toBe(1);
});
it('keeps a magic-link registration valid when a concurrent email delivery fails', async () => {
const email = `${EMAIL_PREFIX}-magic-link-cleanup@test.local`;
const firstEmailStarted = deferred<void>();
const secondEmailStarted = deferred<void>();
const firstEmailResult = deferred<boolean>();
const secondEmailResult = deferred<boolean>();
mockSendVerificationEmail
.mockImplementationOnce(async () => {
firstEmailStarted.resolve();
return firstEmailResult.promise;
})
.mockImplementationOnce(async () => {
secondEmailStarted.resolve();
return secondEmailResult.promise;
});
const firstRegistration = registerWithMagicLink(email, Date.now());
await firstEmailStarted.promise;
const secondRegistration = registerWithMagicLink(email, Date.now());
await secondEmailStarted.promise;
firstEmailResult.resolve(false);
await firstRegistration;
secondEmailResult.resolve(true);
await secondRegistration;
const secondVerificationToken = mockSendVerificationEmail.mock.calls[1][1] as string;
await verifyEmail(secondVerificationToken);
const storedUser = await observer.user.findUnique({ where: { email } });
expect(storedUser).not.toBeNull();
expect(storedUser?.isVerified).toBe(1);
});
it('does not let a later passkey attempt invalidate a magic-link verification', async () => {
const email = `${EMAIL_PREFIX}-magic-before-passkey@test.local`;
await registerWithMagicLink(email, Date.now());
const magicLinkToken = mockSendVerificationEmail.mock.calls[0][1] as string;
await registerPasskey(email, Buffer.from(`untrusted-${RUN_ID}`));
await verifyEmail(magicLinkToken);
const storedUser = await observer.user.findUniqueOrThrow({
where: { email },
include: { passkeys: true, pendingPasskeyRegistrations: true },
});
expect(storedUser.isVerified).toBe(1);
expect(storedUser.passkeys).toHaveLength(0);
expect(storedUser.pendingPasskeyRegistrations).toHaveLength(0);
});
});

View file

@ -25,6 +25,15 @@ vi.mock('../src/db', () => {
delete: vi.fn(),
deleteMany: vi.fn(),
},
passkey: {
create: vi.fn(),
deleteMany: vi.fn(),
},
pendingPasskeyRegistration: {
findUnique: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
};
return { prisma: mockPrisma };
});
@ -75,6 +84,7 @@ import { Prisma } from '@prisma/client';
import {
registerWithMagicLink,
requestLoginMagicLink,
verifyEmail,
verifyLoginMagicLink,
} from '../src/auth';
@ -98,6 +108,15 @@ describe('Magic Link Registration', () => {
delete: Mock;
deleteMany: Mock;
};
passkey: {
create: Mock;
deleteMany: Mock;
};
pendingPasskeyRegistration: {
findUnique: Mock;
deleteMany: Mock;
};
$transaction: Mock;
};
const mockSendVerificationEmail = sendVerificationEmail as Mock;
@ -105,6 +124,12 @@ describe('Magic Link Registration', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPrisma.user.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.pendingPasskeyRegistration.findUnique.mockResolvedValue(null);
mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) =>
callback(mockPrisma),
);
});
describe('New user registration', () => {
@ -200,8 +225,6 @@ describe('Magic Link Registration', () => {
isVerified: 0,
verificationResendCount: 2,
});
mockPrisma.user.update.mockResolvedValue({});
const result = await registerWithMagicLink(testEmail, Date.now());
expect(result).toEqual(registrationResponse);
@ -211,9 +234,9 @@ describe('Magic Link Registration', () => {
testEmail,
expect.any(String),
);
expect(mockPrisma.user.update).toHaveBeenCalledWith(
expect(mockPrisma.user.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 1 },
where: expect.objectContaining({ id: 1, isVerified: 0 }),
data: expect.objectContaining({
verificationToken: expect.any(String),
verificationTokenExpiresAt: expect.any(BigInt),
@ -235,29 +258,24 @@ describe('Magic Link Registration', () => {
expect(result).toEqual(registrationResponse);
expect(mockSendVerificationEmail).not.toHaveBeenCalled();
expect(mockPrisma.user.update).not.toHaveBeenCalled();
expect(mockPrisma.user.updateMany).not.toHaveBeenCalled();
});
});
describe('Email failure cleanup', () => {
it('should delete newly created user when email sending fails', async () => {
mockPrisma.user.findUnique
.mockResolvedValueOnce(null) // Initial lookup: no existing user
.mockResolvedValueOnce({ id: 1, email: testEmail, isVerified: 0 }); // Cleanup lookup
describe('Email failure handling', () => {
it('should retain a newly created user when email sending fails', async () => {
mockPrisma.user.findUnique.mockResolvedValueOnce(null);
mockPrisma.user.create.mockResolvedValue({
id: 1,
email: testEmail,
isVerified: 0,
});
mockSendVerificationEmail.mockResolvedValueOnce(false);
mockPrisma.user.deleteMany.mockResolvedValue({ count: 1 });
const result = await registerWithMagicLink(testEmail, Date.now());
expect(result).toEqual(registrationResponse);
expect(mockPrisma.user.deleteMany).toHaveBeenCalledWith({
where: { email: testEmail, isVerified: 0 },
});
expect(mockPrisma.user.deleteMany).not.toHaveBeenCalled();
});
it('should NOT delete or update pre-existing unverified user when email sending fails', async () => {
@ -280,6 +298,54 @@ describe('Magic Link Registration', () => {
});
});
describe('Email verification', () => {
it('should activate only the passkey bound to the supplied token', async () => {
mockPrisma.pendingPasskeyRegistration.findUnique.mockResolvedValue({
userId: 1,
verificationToken: 'pending-token',
verificationTokenExpiresAt: BigInt(Date.now() + 60_000),
credentialId: Buffer.from('owner-credential'),
publicKey: Buffer.from('owner-public-key'),
counter: 0n,
transports: '["internal"]',
});
await verifyEmail('pending-token');
expect(mockPrisma.passkey.deleteMany).toHaveBeenCalledWith({
where: { userId: 1 },
});
expect(mockPrisma.passkey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 1,
credentialId: Buffer.from('owner-credential'),
}),
});
expect(mockPrisma.pendingPasskeyRegistration.deleteMany).toHaveBeenCalledWith({
where: { userId: 1 },
});
});
it('should not activate passkeys when a magic-link token verifies the email', async () => {
const token = 'legacy-or-current-magic-token';
mockPrisma.user.findFirst.mockResolvedValue({
id: 1,
verificationToken: token,
verificationTokenExpiresAt: BigInt(Date.now() + 60_000),
});
await verifyEmail(token);
expect(mockPrisma.passkey.deleteMany).toHaveBeenCalledWith({
where: { userId: 1 },
});
expect(mockPrisma.passkey.create).not.toHaveBeenCalled();
expect(mockPrisma.pendingPasskeyRegistration.deleteMany).toHaveBeenCalledWith({
where: { userId: 1 },
});
});
});
describe('P2002 unique constraint handling', () => {
it('should return the neutral response after a P2002 registration race', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);

View file

@ -21,7 +21,7 @@ vi.mock('../src/db', () => {
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
passkey: {
create: vi.fn(),
@ -29,6 +29,11 @@ vi.mock('../src/db', () => {
deleteMany: vi.fn(),
findUnique: vi.fn(),
},
pendingPasskeyRegistration: {
findUnique: vi.fn(),
create: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
};
return { prisma: mockPrisma };
@ -99,7 +104,7 @@ describe('Passkey Authentication', () => {
create: Mock;
update: Mock;
updateMany: Mock;
delete: Mock;
deleteMany: Mock;
};
passkey: {
create: Mock;
@ -107,6 +112,11 @@ describe('Passkey Authentication', () => {
deleteMany: Mock;
findUnique: Mock;
};
pendingPasskeyRegistration: {
findUnique: Mock;
create: Mock;
deleteMany: Mock;
};
$transaction: Mock;
};
@ -134,6 +144,13 @@ describe('Passkey Authentication', () => {
allowCredentials: [],
userVerification: 'preferred',
} as PublicKeyCredentialRequestOptionsJSON);
mockPrisma.user.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.pendingPasskeyRegistration.findUnique.mockResolvedValue(null);
mockPrisma.pendingPasskeyRegistration.create.mockResolvedValue({});
mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) =>
callback(mockPrisma),
);
});
afterEach(() => {
@ -238,15 +255,22 @@ describe('Passkey Authentication', () => {
result.message.includes('automatically verified'),
).toBe(true);
expect(mockPrisma.user.create).toHaveBeenCalled();
expect(mockPrisma.pendingPasskeyRegistration.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 1,
verificationToken: expect.any(String),
credentialId: expect.any(Buffer),
publicKey: expect.any(Buffer),
}),
});
});
it('should return the neutral response without changing an existing verified user', async () => {
const mockCredentialId = new Uint8Array([1, 2, 3, 4]);
mockVerifyRegistration.mockResolvedValue({
verified: true,
registrationInfo: {
credential: {
id: mockCredentialId,
id: new Uint8Array([1, 2, 3, 4]),
publicKey: new Uint8Array([5, 6, 7, 8]),
counter: 0,
},
@ -314,7 +338,96 @@ describe('Passkey Authentication', () => {
expect(sendVerificationEmail).not.toHaveBeenCalled();
});
it('should return the neutral response when verification email delivery fails', async () => {
it('should bind a re-registration passkey to its own pending email link', async () => {
mockVerifyRegistration.mockResolvedValue({
verified: true,
registrationInfo: {
credential: {
id: new Uint8Array([9, 10, 11, 12]),
publicKey: new Uint8Array([13, 14, 15, 16]),
counter: 0,
},
},
});
mockPrisma.user.findUnique.mockResolvedValue({
id: 1,
email: testEmail,
isVerified: 0,
verificationResendCount: 1,
});
await generateRegistrationOptions(testEmail);
const result = await verifyRegistration(testEmail, {
id: 'attacker-credential-id',
rawId: 'raw-id',
type: 'public-key',
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation',
},
clientExtensionResults: {},
} as RegistrationResponseJSON);
expect(result).toEqual(registrationResponse);
expect(mockPrisma.user.updateMany).toHaveBeenCalledWith({
where: {
id: 1,
isVerified: 0,
verificationResendCount: { lt: 20 },
},
data: {
verificationResendCount: { increment: 1 },
},
});
expect(mockPrisma.pendingPasskeyRegistration.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: 1,
verificationToken: expect.any(String),
credentialId: expect.any(Buffer),
}),
});
expect(mockPrisma.passkey.create).not.toHaveBeenCalled();
expect(sendVerificationEmail).toHaveBeenCalledOnce();
});
it('should keep a failed-delivery credential pending and inactive', async () => {
mockVerifyRegistration.mockResolvedValue({
verified: true,
registrationInfo: {
credential: {
id: new Uint8Array([9, 10, 11, 12]),
publicKey: new Uint8Array([13, 14, 15, 16]),
counter: 0,
},
},
});
mockPrisma.user.findUnique.mockResolvedValue({
id: 1,
email: testEmail,
isVerified: 0,
verificationResendCount: 1,
});
(sendVerificationEmail as Mock).mockResolvedValueOnce(false);
await generateRegistrationOptions(testEmail);
const result = await verifyRegistration(testEmail, {
id: 'attacker-credential-id',
rawId: 'raw-id',
type: 'public-key',
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation',
},
clientExtensionResults: {},
} as RegistrationResponseJSON);
expect(result).toEqual(registrationResponse);
expect(mockPrisma.pendingPasskeyRegistration.create).toHaveBeenCalledOnce();
expect(mockPrisma.passkey.create).not.toHaveBeenCalled();
expect(mockPrisma.user.deleteMany).not.toHaveBeenCalled();
});
it('should retain a new pending registration when email delivery fails', async () => {
mockVerifyRegistration.mockResolvedValue({
verified: true,
registrationInfo: {
@ -325,9 +438,7 @@ describe('Passkey Authentication', () => {
},
},
});
mockPrisma.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 1, email: testEmail, isVerified: 0 });
mockPrisma.user.findUnique.mockResolvedValueOnce(null);
mockPrisma.user.create.mockResolvedValue({ id: 1 });
(sendVerificationEmail as Mock).mockResolvedValueOnce(false);
await generateRegistrationOptions(testEmail);
@ -344,7 +455,8 @@ describe('Passkey Authentication', () => {
} as RegistrationResponseJSON);
expect(result).toEqual(registrationResponse);
expect(mockPrisma.user.delete).toHaveBeenCalledWith({ where: { id: 1 } });
expect(mockPrisma.pendingPasskeyRegistration.create).toHaveBeenCalledOnce();
expect(mockPrisma.user.deleteMany).not.toHaveBeenCalled();
});
it('should reject verification with expired challenge', async () => {

View file

@ -370,6 +370,7 @@ vi.mock('../src/auth', () => ({
.mockResolvedValue({ valid: true, userId: 1, email: 'test@test.com' }),
VERIFICATION_TOKEN_EXPIRY_MS: 24 * 60 * 60 * 1000,
MAX_VERIFICATION_RESEND_COUNT: 20,
verifyEmail: vi.fn().mockResolvedValue(true),
}));
// Reset test data before each test

View file

@ -22,6 +22,8 @@ export default defineConfig({
'tests/integration/multi-client-sync.integration.spec.ts',
'tests/integration/snapshot-skip-optimization.integration.spec.ts',
// Integration test that requires real PostgreSQL (run with vitest.integration.config.ts)
'tests/integration/registration-races.integration.spec.ts',
'tests/integration/clean-slate-atomicity-sql.integration.spec.ts',
'tests/integration/snapshot-vector-clock-sql.integration.spec.ts',
'tests/integration/conflict-detection-sql.integration.spec.ts',
'tests/integration/repair-causality.integration.spec.ts',