diff --git a/apps/api/.env.example b/apps/api/.env.example index 64c0a551..397b45bd 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,6 +9,9 @@ DATABASE_AUTH_TOKEN= JWT_SECRET=change-me-in-production JWT_REFRESH_SECRET=change-me-refresh-in-production +# Passphrase used to derive the key that encrypts TOTP secrets at rest +TOTP_ENCRYPTION_KEY=change-me-in-production + # Storage: 'local' for dev, 'gcs' for production STORAGE_PROVIDER=local diff --git a/apps/api/package.json b/apps/api/package.json index 8e0031af..0b60b90b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -49,11 +49,14 @@ "graphql": "^16.10.0", "mercurius": "^16.0.0", "nanoid": "^5.0.9", + "otplib": "^13.4.1", + "qrcode": "^1.5.4", "zod": "^3.23.8" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.10", + "@types/qrcode": "^1.5.6", "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", diff --git a/apps/api/prisma/migrations/20260722223632_add_totp/migration.sql b/apps/api/prisma/migrations/20260722223632_add_totp/migration.sql new file mode 100644 index 00000000..4ac3744e --- /dev/null +++ b/apps/api/prisma/migrations/20260722223632_add_totp/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT; +ALTER TABLE "User" ADD COLUMN "totpEnabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql b/apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql new file mode 100644 index 00000000..438ecdc2 --- /dev/null +++ b/apps/api/prisma/migrations/20260723000000_add_totp_backup_codes/migration.sql @@ -0,0 +1,10 @@ +CREATE TABLE "TotpBackupCode" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL UNIQUE, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "TotpBackupCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "TotpBackupCode_userId_idx" ON "TotpBackupCode"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b4db299c..c96cac8a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -17,6 +17,8 @@ model User { emailVerifiedAt DateTime? weeklyDigestEnabled Boolean @default(true) followUpRemindersEnabled Boolean @default(true) + totpSecret String? + totpEnabled Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt applications JobApplication[] @@ -24,9 +26,21 @@ model User { emailVerificationTokens EmailVerificationToken[] sessions Session[] loginEvents LoginEvent[] + totpBackupCodes TotpBackupCode[] passwordResetTokens PasswordResetToken[] } +model TotpBackupCode { + id String @id + userId String + codeHash String @unique + usedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + model LoginEvent { id String @id userId String diff --git a/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts b/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts new file mode 100644 index 00000000..79f2e91a --- /dev/null +++ b/apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'crypto'; +import bcrypt from 'bcryptjs'; +import { LoginWithTotpUseCase } from '@/use-cases/auth/LoginWithTotpUseCase.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { + makeUserRepository, + makeUser, + makeTotpBackupCodeRepository, + makeTotpBackupCode, + makeRateLimiter, +} from '@/__tests__/helpers/mocks.js'; + +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +vi.mock('@/infrastructure/auth/totpSecretCrypto.js', () => ({ + encryptTotpSecret: (secret: string) => `encrypted:${secret}`, + decryptTotpSecret: (secret: string) => secret.replace(/^encrypted:/, ''), +})); + +describe('LoginWithTotpUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const input = { + email: 'test@example.com', + password: 'password123', + code: '123456', + ipAddress: '127.0.0.1', + }; + + it('throws UNAUTHORIZED when the user does not exist', async () => { + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(null) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('throws UNAUTHORIZED when the password is wrong', async () => { + const user = makeUser({ totpEnabled: true, totpSecret: 'encrypted:ABCD1234' }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('throws UNAUTHORIZED when 2FA is not enabled for the user', async () => { + const user = makeUser({ totpEnabled: false, totpSecret: null }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('throws RATE_LIMITED when too many verification attempts have been made', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter({ consume: vi.fn().mockReturnValue(false) }); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('RATE_LIMITED'); + }); + + it('throws UNAUTHORIZED for an invalid code with no matching backup code', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute({ ...input, code: '000000' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + }); + + it('returns the user for valid credentials and a valid code', async () => { + const secret = createTotp().generateSecret(); + const validCode = await createTotp({ secret }).generate(); + const user = makeUser({ totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const result = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }).execute({ + ...input, + code: validCode, + }); + + expect(result).toEqual(user); + }); + + it('accepts a valid, unused backup code as a fallback and marks it used', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ id: 'user-1', totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const rawBackupCode = 'a1b2c3d4e5f60718'; + const codeHash = createHash('sha256').update(rawBackupCode).digest('hex'); + const backupCode = makeTotpBackupCode({ id: 'backup-1', userId: 'user-1', codeHash }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository({ + findByCodeHash: vi.fn().mockResolvedValue(backupCode), + }); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const result = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }).execute({ ...input, code: rawBackupCode }); + + expect(result).toEqual(user); + expect(totpBackupCodeRepository.markUsed).toHaveBeenCalledWith('backup-1'); + }); + + it('rejects an already-used backup code', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ id: 'user-1', totpEnabled: true, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findByEmail: vi.fn().mockResolvedValue(user) }); + const rawBackupCode = 'a1b2c3d4e5f60718'; + const codeHash = createHash('sha256').update(rawBackupCode).digest('hex'); + const backupCode = makeTotpBackupCode({ + id: 'backup-1', + userId: 'user-1', + codeHash, + usedAt: new Date('2024-01-01T00:00:00.000Z'), + }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository({ + findByCodeHash: vi.fn().mockResolvedValue(backupCode), + }); + const totpRateLimiter = makeRateLimiter(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + const err = await new LoginWithTotpUseCase({ + userRepository, + totpBackupCodeRepository, + totpRateLimiter, + }) + .execute({ ...input, code: rawBackupCode }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(totpBackupCodeRepository.markUsed).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts b/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts new file mode 100644 index 00000000..c970a3ec --- /dev/null +++ b/apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConfirmTotpSetupUseCase } from '@/use-cases/user/ConfirmTotpSetupUseCase.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { + makeUserRepository, + makeUser, + makeTotpBackupCodeRepository, +} from '@/__tests__/helpers/mocks.js'; + +vi.mock('@/infrastructure/auth/totpSecretCrypto.js', () => ({ + encryptTotpSecret: (secret: string) => `encrypted:${secret}`, + decryptTotpSecret: (secret: string) => secret.replace(/^encrypted:/, ''), +})); + +describe('ConfirmTotpSetupUseCase', () => { + const generateId = vi.fn(() => 'backup-code-id'); + + beforeEach(() => { + vi.clearAllMocks(); + generateId.mockReturnValue('backup-code-id'); + }); + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) + .execute({ userId: 'missing', code: '123456' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('throws CONFLICT when 2FA is already enabled', async () => { + const user = makeUser({ totpEnabled: true, totpSecret: 'encrypted:ABCD1234' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) + .execute({ userId: 'user-1', code: '123456' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('CONFLICT'); + }); + + it('throws CONFLICT when no setup is in progress', async () => { + const user = makeUser({ totpEnabled: false, totpSecret: null }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) + .execute({ userId: 'user-1', code: '123456' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('CONFLICT'); + }); + + it('throws UNAUTHORIZED for an invalid code', async () => { + const secret = createTotp().generateSecret(); + const user = makeUser({ + totpEnabled: false, + totpSecret: `encrypted:${secret}`, + }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + + const err = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }) + .execute({ userId: 'user-1', code: '000000' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + expect(totpBackupCodeRepository.create).not.toHaveBeenCalled(); + }); + + it('enables 2FA and issues backup codes when given a valid code', async () => { + const secret = createTotp().generateSecret(); + const validCode = await createTotp({ secret }).generate(); + const user = makeUser({ id: 'user-1', totpEnabled: false, totpSecret: `encrypted:${secret}` }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + + const result = await new ConfirmTotpSetupUseCase({ + userRepository, + totpBackupCodeRepository, + generateId, + }).execute({ + userId: 'user-1', + code: validCode, + }); + + expect(userRepository.update).toHaveBeenCalledWith('user-1', { totpEnabled: true }); + expect(result.backupCodes).toHaveLength(10); + expect(new Set(result.backupCodes).size).toBe(10); // all unique + result.backupCodes.forEach((code) => expect(code).toMatch(/^[0-9a-f]{16}$/)); + expect(totpBackupCodeRepository.create).toHaveBeenCalledTimes(10); + expect(totpBackupCodeRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', id: 'backup-code-id' }), + ); + }); +}); diff --git a/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts b/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts new file mode 100644 index 00000000..92cbb1c6 --- /dev/null +++ b/apps/api/src/__tests__/application/user/DisableTotpUseCase.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { DisableTotpUseCase } from '@/use-cases/user/DisableTotpUseCase.js'; +import { + makeUserRepository, + makeUser, + makeTotpBackupCodeRepository, +} from '@/__tests__/helpers/mocks.js'; + +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +describe('DisableTotpUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const input = { userId: 'user-1', password: 'secret123' }; + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + + const err = await new DisableTotpUseCase({ userRepository, totpBackupCodeRepository }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('throws UNAUTHORIZED when the password is wrong', async () => { + const user = makeUser({ totpEnabled: true, totpSecret: 'ABCD1234' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); + + const err = await new DisableTotpUseCase({ userRepository, totpBackupCodeRepository }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + expect(totpBackupCodeRepository.deleteAllForUser).not.toHaveBeenCalled(); + }); + + it('clears totpSecret, disables totpEnabled, and deletes backup codes on correct password', async () => { + const user = makeUser({ id: 'user-1', totpEnabled: true, totpSecret: 'ABCD1234' }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + const totpBackupCodeRepository = makeTotpBackupCodeRepository(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + + await new DisableTotpUseCase({ userRepository, totpBackupCodeRepository }).execute(input); + + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + totpEnabled: false, + totpSecret: null, + }); + expect(totpBackupCodeRepository.deleteAllForUser).toHaveBeenCalledWith('user-1'); + }); +}); diff --git a/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts b/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts new file mode 100644 index 00000000..a5e46dc8 --- /dev/null +++ b/apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { GenerateTotpSecretUseCase } from '@/use-cases/user/GenerateTotpSecretUseCase.js'; +import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; + +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +vi.mock('@/infrastructure/auth/totpSecretCrypto.js', () => ({ + encryptTotpSecret: (secret: string) => `encrypted:${secret}`, + decryptTotpSecret: (secret: string) => secret.replace(/^encrypted:/, ''), +})); + +describe('GenerateTotpSecretUseCase', () => { + const input = { userId: 'user-1', password: 'secret123' }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + }); + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new GenerateTotpSecretUseCase({ userRepository }) + .execute({ ...input, userId: 'missing' }) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('throws CONFLICT when 2FA is already enabled', async () => { + const user = makeUser({ totpEnabled: true }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const err = await new GenerateTotpSecretUseCase({ userRepository }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('CONFLICT'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when the password is wrong', async () => { + const user = makeUser({ totpEnabled: false }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); + + const err = await new GenerateTotpSecretUseCase({ userRepository }) + .execute(input) + .catch((e) => e); + + expect((err as { code: string }).code).toBe('UNAUTHORIZED'); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + + it('generates a secret, persists it encrypted (not yet enabled), and returns setup data', async () => { + const user = makeUser({ id: 'user-1', email: 'test@example.com', totpEnabled: false }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const result = await new GenerateTotpSecretUseCase({ userRepository }).execute(input); + + expect(result.secret).toMatch(/^[A-Z2-7]+$/); // base32 + expect(result.otpauthUrl).toMatch(/^otpauth:\/\/totp\//); + expect(result.otpauthUrl).toContain(encodeURIComponent(user.email)); + expect(result.qrCodeDataUrl).toMatch(/^data:image\/png;base64,/); + expect(userRepository.update).toHaveBeenCalledWith('user-1', { + totpSecret: `encrypted:${result.secret}`, + }); + }); +}); diff --git a/apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts b/apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts new file mode 100644 index 00000000..715d649a --- /dev/null +++ b/apps/api/src/__tests__/application/user/GetTotpStatusUseCase.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetTotpStatusUseCase } from '@/use-cases/user/GetTotpStatusUseCase.js'; +import { makeUserRepository, makeUser } from '@/__tests__/helpers/mocks.js'; + +describe('GetTotpStatusUseCase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws NOT_FOUND when the user does not exist', async () => { + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(null) }); + + const err = await new GetTotpStatusUseCase({ userRepository }) + .execute('missing') + .catch((e) => e); + + expect((err as { code: string }).code).toBe('NOT_FOUND'); + }); + + it('returns true when 2FA is enabled', async () => { + const user = makeUser({ totpEnabled: true }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const result = await new GetTotpStatusUseCase({ userRepository }).execute('user-1'); + + expect(result).toBe(true); + }); + + it('returns false when 2FA is disabled', async () => { + const user = makeUser({ totpEnabled: false }); + const userRepository = makeUserRepository({ findById: vi.fn().mockResolvedValue(user) }); + + const result = await new GetTotpStatusUseCase({ userRepository }).execute('user-1'); + + expect(result).toBe(false); + }); +}); diff --git a/apps/api/src/__tests__/helpers/createTestDb.ts b/apps/api/src/__tests__/helpers/createTestDb.ts index 977b7c95..a321559a 100644 --- a/apps/api/src/__tests__/helpers/createTestDb.ts +++ b/apps/api/src/__tests__/helpers/createTestDb.ts @@ -15,6 +15,8 @@ const SCHEMA_STATEMENTS = [ "emailVerifiedAt" DATETIME, "weeklyDigestEnabled" INTEGER NOT NULL DEFAULT 1, "followUpRemindersEnabled" INTEGER NOT NULL DEFAULT 1, + "totpSecret" TEXT, + "totpEnabled" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, @@ -149,6 +151,15 @@ const SCHEMA_STATEMENTS = [ FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE )`, `CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId")`, + `CREATE TABLE "TotpBackupCode" ( + "id" TEXT PRIMARY KEY, + "userId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL UNIQUE, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE + )`, + `CREATE INDEX "TotpBackupCode_userId_idx" ON "TotpBackupCode"("userId")`, `CREATE TABLE "PasswordResetToken" ( "id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, diff --git a/apps/api/src/__tests__/helpers/mocks.ts b/apps/api/src/__tests__/helpers/mocks.ts index 1c556be6..1dac6d85 100644 --- a/apps/api/src/__tests__/helpers/mocks.ts +++ b/apps/api/src/__tests__/helpers/mocks.ts @@ -24,6 +24,8 @@ import type { Note } from '@/domain/note/Note.js'; import type { InterviewRound } from '@/domain/interviewRound/InterviewRound.js'; import type { Contact } from '@/domain/contact/Contact.js'; import type { LoginEvent } from '@/domain/loginEvent/LoginEvent.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import type { TotpBackupCode } from '@/domain/totpBackupCode/TotpBackupCode.js'; import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; export const makeUserRepository = (overrides?: Partial): IUserRepository => ({ @@ -195,6 +197,30 @@ export const makeEmailVerificationToken = ( ...overrides, }); +export const makeTotpBackupCodeRepository = ( + overrides?: Partial, +): ITotpBackupCodeRepository => ({ + create: vi.fn(), + findByCodeHash: vi.fn().mockResolvedValue(null), + markUsed: vi.fn().mockResolvedValue(undefined), + deleteAllForUser: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +export const makeTotpBackupCode = (overrides?: Partial): TotpBackupCode => ({ + id: 'backup-code-1', + userId: 'user-1', + codeHash: 'hashed-backup-code', + usedAt: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, +}); + +export const makeRateLimiter = (overrides?: Partial): IRateLimiter => ({ + consume: vi.fn().mockReturnValue(true), + ...overrides, +}); + // Minimal FastifyInstance stub for AuthResolver (only jwt is used) export const makeFastifyJwt = (): { jwt: { sign: ReturnType; verify: ReturnType }; @@ -214,11 +240,6 @@ export const makeLoginEvent = (overrides?: Partial): LoginEvent => ( ...overrides, }); -export const makeRateLimiter = (overrides?: Partial): IRateLimiter => ({ - consume: vi.fn().mockReturnValue(true), - ...overrides, -}); - // Domain object fixtures export const makeUser = (overrides?: Partial): User => ({ id: 'user-1', @@ -230,6 +251,8 @@ export const makeUser = (overrides?: Partial): User => ({ emailVerifiedAt: null, weeklyDigestEnabled: true, followUpRemindersEnabled: true, + totpSecret: null, + totpEnabled: false, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), ...overrides, diff --git a/apps/api/src/__tests__/http/errors/AppError.test.ts b/apps/api/src/__tests__/http/errors/AppError.test.ts index 38007e45..3416f5c2 100644 --- a/apps/api/src/__tests__/http/errors/AppError.test.ts +++ b/apps/api/src/__tests__/http/errors/AppError.test.ts @@ -97,6 +97,7 @@ describe('RateLimitedError', () => { }); it('uses the given message when provided', () => { + expect(new RateLimitedError('Slow down').message).toBe('Slow down'); expect(new RateLimitedError('Too many password reset requests').message).toBe( 'Too many password reset requests', ); diff --git a/apps/api/src/__tests__/http/errors/formatError.test.ts b/apps/api/src/__tests__/http/errors/formatError.test.ts index 6d4066b1..9961139e 100644 --- a/apps/api/src/__tests__/http/errors/formatError.test.ts +++ b/apps/api/src/__tests__/http/errors/formatError.test.ts @@ -93,6 +93,19 @@ describe('formatError', () => { expect(result.extensions.statusCode).toBe(500); }); + it('maps a RATE_LIMITED-coded error to a client-safe GraphQLError without logging', () => { + const original = Object.assign(new Error('Too many attempts'), { + code: ERROR_CODES.RATE_LIMITED, + }); + const wrapper = new GraphQLError('wrapped', { originalError: original }); + + const result = formatError(wrapper); + + expect(result.extensions.code).toBe(ERROR_CODES.RATE_LIMITED); + expect(result.extensions.statusCode).toBe(429); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + it('logs for an error whose code is not in the expected client-facing set', () => { const original = Object.assign(new Error('Unexpected'), { code: 'SOME_UNKNOWN_CODE' }); const wrapper = new GraphQLError('wrapped', { originalError: original }); diff --git a/apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts b/apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts new file mode 100644 index 00000000..cc5c2f19 --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ENV } from '@/constants.js'; +import { encryptTotpSecret, decryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; + +describe('totpSecretCrypto', () => { + const originalKey = process.env[ENV.TOTP_ENCRYPTION_KEY]; + + beforeEach(() => { + process.env[ENV.TOTP_ENCRYPTION_KEY] = 'test-encryption-key-not-for-production'; + }); + + afterEach(() => { + if (originalKey === undefined) { + delete process.env[ENV.TOTP_ENCRYPTION_KEY]; + } else { + process.env[ENV.TOTP_ENCRYPTION_KEY] = originalKey; + } + }); + + it('decrypts back to the original plaintext', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + + const encrypted = encryptTotpSecret(secret); + const decrypted = decryptTotpSecret(encrypted); + + expect(decrypted).toBe(secret); + }); + + it('does not store the plaintext secret in the ciphertext', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + + const encrypted = encryptTotpSecret(secret); + + expect(encrypted).not.toContain(secret); + }); + + it('produces different ciphertext for the same plaintext on each call (random IV)', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + + const first = encryptTotpSecret(secret); + const second = encryptTotpSecret(secret); + + expect(first).not.toBe(second); + expect(decryptTotpSecret(first)).toBe(secret); + expect(decryptTotpSecret(second)).toBe(secret); + }); + + it('throws when the ciphertext has been tampered with', () => { + const secret = 'JBSWY3DPEHPK3PXP'; + const encrypted = encryptTotpSecret(secret); + const tamperedBytes = Buffer.from(encrypted, 'base64'); + tamperedBytes[tamperedBytes.length - 1] ^= 0xff; + const tampered = tamperedBytes.toString('base64'); + + expect(() => decryptTotpSecret(tampered)).toThrow(); + }); + + it('throws a clear error when TOTP_ENCRYPTION_KEY is not configured', () => { + delete process.env[ENV.TOTP_ENCRYPTION_KEY]; + + expect(() => encryptTotpSecret('JBSWY3DPEHPK3PXP')).toThrow(/TOTP_ENCRYPTION_KEY/); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts new file mode 100644 index 00000000..0248bfb2 --- /dev/null +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaTotpBackupCodeRepository } from '@/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.js'; +import { createTestDb, type TestDb } from '@/__tests__/helpers/createTestDb.js'; + +describe('PrismaTotpBackupCodeRepository', () => { + let db: TestDb; + let repo: PrismaTotpBackupCodeRepository; + + beforeAll(async () => { + db = await createTestDb(); + repo = new PrismaTotpBackupCodeRepository({ prisma: db.prisma }); + }); + + afterAll(() => db.cleanup()); + + beforeEach(async () => { + await db.prisma.totpBackupCode.deleteMany(); + await db.prisma.user.deleteMany(); + await db.prisma.user.create({ + data: { id: 'user-1', email: 'a@b.com', passwordHash: 'hashed' }, + }); + }); + + describe('create', () => { + it('persists a backup code and returns the entity', async () => { + const code = await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + + expect(code.id).toBe('code-1'); + expect(code.userId).toBe('user-1'); + expect(code.codeHash).toBe('hash-1'); + expect(code.usedAt).toBeNull(); + expect(code.createdAt).toBeInstanceOf(Date); + }); + }); + + describe('findByCodeHash', () => { + it('returns the code when it exists', async () => { + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + + const found = await repo.findByCodeHash('hash-1'); + expect(found?.id).toBe('code-1'); + }); + + it('returns null when no code matches', async () => { + const found = await repo.findByCodeHash('missing-hash'); + expect(found).toBeNull(); + }); + }); + + describe('markUsed', () => { + it('sets usedAt on the code', async () => { + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + + await repo.markUsed('code-1'); + + const found = await repo.findByCodeHash('hash-1'); + expect(found?.usedAt).toBeInstanceOf(Date); + }); + }); + + describe('deleteAllForUser', () => { + it('removes all codes for the given user', async () => { + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + await repo.create({ id: 'code-2', userId: 'user-1', codeHash: 'hash-2' }); + + await repo.deleteAllForUser('user-1'); + + expect(await repo.findByCodeHash('hash-1')).toBeNull(); + expect(await repo.findByCodeHash('hash-2')).toBeNull(); + }); + + it('does not affect codes belonging to other users', async () => { + await db.prisma.user.create({ + data: { id: 'user-2', email: 'c@d.com', passwordHash: 'hashed' }, + }); + await repo.create({ id: 'code-1', userId: 'user-1', codeHash: 'hash-1' }); + await repo.create({ id: 'code-2', userId: 'user-2', codeHash: 'hash-2' }); + + await repo.deleteAllForUser('user-1'); + + expect(await repo.findByCodeHash('hash-1')).toBeNull(); + expect(await repo.findByCodeHash('hash-2')).not.toBeNull(); + }); + }); +}); diff --git a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts index a5d1060f..f56c0297 100644 --- a/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts +++ b/apps/api/src/__tests__/infrastructure/db/repositories/PrismaUserRepository.test.ts @@ -28,6 +28,12 @@ describe('PrismaUserRepository', () => { expect(user.updatedAt).toBeInstanceOf(Date); }); + it('defaults totpSecret to null and totpEnabled to false', async () => { + const user = await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + expect(user.totpSecret).toBeNull(); + expect(user.totpEnabled).toBe(false); + }); + it('defaults notification preferences to enabled', async () => { const user = await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); @@ -121,6 +127,23 @@ describe('PrismaUserRepository', () => { expect(updated.updatedAt).toBeInstanceOf(Date); }); + it('sets totpSecret and totpEnabled', async () => { + await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + const updated = await repo.update('u1', { totpSecret: 'ABCD1234', totpEnabled: true }); + + expect(updated.totpSecret).toBe('ABCD1234'); + expect(updated.totpEnabled).toBe(true); + }); + + it('clears totpSecret when given null', async () => { + await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); + await repo.update('u1', { totpSecret: 'ABCD1234', totpEnabled: true }); + const updated = await repo.update('u1', { totpSecret: null, totpEnabled: false }); + + expect(updated.totpSecret).toBeNull(); + expect(updated.totpEnabled).toBe(false); + }); + it('updates notification preferences', async () => { await repo.create({ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }); const updated = await repo.update('u1', { diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts index c77ae409..46dd1203 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/AuthResolver.test.ts @@ -3,6 +3,7 @@ import { AuthResolver } from '@/interface-adapters/resolvers/AuthResolver.js'; import { makeUser, makeSession } from '@/__tests__/helpers/mocks.js'; import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; +import type { ILoginWithTotpUseCase } from '@/use-cases/auth/ILoginWithTotpUseCase.js'; import type { IRequestPasswordResetUseCase } from '@/use-cases/auth/IRequestPasswordResetUseCase.js'; import type { IResetPasswordUseCase } from '@/use-cases/auth/IResetPasswordUseCase.js'; import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; @@ -22,6 +23,13 @@ const makeLoginUseCase = (overrides?: Partial): ILoginUseCase => ...overrides, }); +const makeLoginWithTotpUseCase = ( + overrides?: Partial, +): ILoginWithTotpUseCase => ({ + execute: vi.fn(), + ...overrides, +}); + const makeRequestPasswordResetUseCase = ( overrides?: Partial, ): IRequestPasswordResetUseCase => ({ @@ -52,6 +60,7 @@ const device = { userAgent: 'test-agent', ipAddress: '127.0.0.1' }; const baseDeps = () => ({ registerUseCase: makeRegisterUseCase(), loginUseCase: makeLoginUseCase(), + loginWithTotpUseCase: makeLoginWithTotpUseCase(), tokenService: makeTokenService(), requestPasswordResetUseCase: makeRequestPasswordResetUseCase(), resetPasswordUseCase: makeResetPasswordUseCase(), @@ -99,8 +108,8 @@ describe('AuthResolver', () => { }); describe('login', () => { - it('logs in, creates a session, and returns a token pair', async () => { - const user = makeUser(); + it('logs in, creates a session, and returns totpRequired: false for a user without 2FA', async () => { + const user = makeUser({ totpEnabled: false }); const loginUseCase = makeLoginUseCase({ execute: vi.fn().mockResolvedValue(user) }); const tokenService = makeTokenService(); const createSessionUseCase = stub({ @@ -124,8 +133,82 @@ describe('AuthResolver', () => { }); expect(createSessionUseCase.execute).toHaveBeenCalledWith({ userId: user.id, ...device }); expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email, 'session-1'); + expect(result).toEqual({ + totpRequired: false, + tokens: { accessToken: 'access-token', refreshToken: 'refresh-token' }, + }); + }); + + it('does not create a session or sign tokens for a user with 2FA enabled', async () => { + const user = makeUser({ totpEnabled: true }); + const loginUseCase = makeLoginUseCase({ execute: vi.fn().mockResolvedValue(user) }); + const tokenService = makeTokenService(); + const createSessionUseCase = stub({ execute: vi.fn() }); + + const resolver = new AuthResolver({ + ...baseDeps(), + loginUseCase, + tokenService, + createSessionUseCase, + }); + + const result = await resolver.login('test@example.com', 'password123', device); + + expect(createSessionUseCase.execute).not.toHaveBeenCalled(); + expect(tokenService.sign).not.toHaveBeenCalled(); + expect(result).toEqual({ totpRequired: true, tokens: null }); + }); + }); + + describe('loginWithTotp', () => { + it('delegates to loginWithTotpUseCase, creates a session, and signs tokens', async () => { + const user = makeUser({ totpEnabled: true }); + const loginWithTotpUseCase = makeLoginWithTotpUseCase({ + execute: vi.fn().mockResolvedValue(user), + }); + const tokenService = makeTokenService(); + const createSessionUseCase = stub({ + execute: vi.fn().mockResolvedValue(makeSession({ id: 'session-1' })), + }); + + const resolver = new AuthResolver({ + ...baseDeps(), + loginWithTotpUseCase, + tokenService, + createSessionUseCase, + }); + + const result = await resolver.loginWithTotp( + 'test@example.com', + 'password123', + '123456', + device, + ); + + expect(loginWithTotpUseCase.execute).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + code: '123456', + ipAddress: device.ipAddress, + }); + expect(createSessionUseCase.execute).toHaveBeenCalledWith({ userId: user.id, ...device }); + expect(tokenService.sign).toHaveBeenCalledWith(user.id, user.email, 'session-1'); expect(result).toEqual({ accessToken: 'access-token', refreshToken: 'refresh-token' }); }); + + it('propagates errors from the use case', async () => { + const err = Object.assign(new Error('Invalid verification code'), { + code: 'UNAUTHORIZED', + }); + const loginWithTotpUseCase = makeLoginWithTotpUseCase({ + execute: vi.fn().mockRejectedValue(err), + }); + const resolver = new AuthResolver({ ...baseDeps(), loginWithTotpUseCase }); + + await expect( + resolver.loginWithTotp('test@example.com', 'password123', 'bad-code', device), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); }); describe('refreshToken', () => { diff --git a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts index b5089a03..f9362f08 100644 --- a/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts +++ b/apps/api/src/__tests__/interface-adapters/resolvers/UserResolver.test.ts @@ -4,6 +4,10 @@ import type { IUpdateEmailUseCase } from '@/use-cases/user/IUpdateEmailUseCase.j import type { IUpdatePasswordUseCase } from '@/use-cases/user/IUpdatePasswordUseCase.js'; import type { IDeleteAccountUseCase } from '@/use-cases/user/IDeleteAccountUseCase.js'; import type { IExportUserDataUseCase } from '@/use-cases/user/IExportUserDataUseCase.js'; +import type { IGenerateTotpSecretUseCase } from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; +import type { IConfirmTotpSetupUseCase } from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; +import type { IDisableTotpUseCase } from '@/use-cases/user/IDisableTotpUseCase.js'; +import type { IGetTotpStatusUseCase } from '@/use-cases/user/IGetTotpStatusUseCase.js'; import type { IImportUserDataUseCase } from '@/use-cases/user/IImportUserDataUseCase.js'; import type { IGetNotificationPreferencesUseCase } from '@/use-cases/user/IGetNotificationPreferencesUseCase.js'; import type { IUpdateNotificationPreferencesUseCase } from '@/use-cases/user/IUpdateNotificationPreferencesUseCase.js'; @@ -22,6 +26,12 @@ const makeDeps = (overrides?: object) => ({ execute: vi.fn().mockResolvedValue(undefined), }), exportUserDataUseCase: stub({ execute: vi.fn() }), + generateTotpSecretUseCase: stub({ execute: vi.fn() }), + confirmTotpSetupUseCase: stub({ + execute: vi.fn().mockResolvedValue(undefined), + }), + disableTotpUseCase: stub({ execute: vi.fn().mockResolvedValue(undefined) }), + getTotpStatusUseCase: stub({ execute: vi.fn() }), importUserDataUseCase: stub({ execute: vi.fn() }), getNotificationPreferencesUseCase: stub({ execute: vi.fn(), @@ -118,6 +128,89 @@ describe('UserResolver', () => { }); }); + describe('beginTotpSetup', () => { + it('delegates to generateTotpSecretUseCase and returns the result', async () => { + const setup = { + secret: 'ABCD1234', + otpauthUrl: 'otpauth://totp/...', + qrCodeDataUrl: 'data:image/png;base64,...', + }; + const deps = makeDeps({ + generateTotpSecretUseCase: stub({ + execute: vi.fn().mockResolvedValue(setup), + }), + }); + + const result = await new UserResolver(deps).beginTotpSetup('user-1', 'secret123'); + + expect(deps.generateTotpSecretUseCase.execute).toHaveBeenCalledWith({ + userId: 'user-1', + password: 'secret123', + }); + expect(result).toEqual(setup); + }); + }); + + describe('confirmTotpSetup', () => { + it('delegates to confirmTotpSetupUseCase and returns the result', async () => { + const output = { backupCodes: ['aaaa1111bbbb2222'] }; + const deps = makeDeps({ + confirmTotpSetupUseCase: stub({ + execute: vi.fn().mockResolvedValue(output), + }), + }); + + const result = await new UserResolver(deps).confirmTotpSetup('user-1', '123456'); + + expect(deps.confirmTotpSetupUseCase.execute).toHaveBeenCalledWith({ + userId: 'user-1', + code: '123456', + }); + expect(result).toEqual(output); + }); + + it('propagates errors from the use case', async () => { + const err = Object.assign(new Error('Invalid verification code'), { code: 'UNAUTHORIZED' }); + const deps = makeDeps({ + confirmTotpSetupUseCase: stub({ + execute: vi.fn().mockRejectedValue(err), + }), + }); + + await expect( + new UserResolver(deps).confirmTotpSetup('user-1', 'bad-code'), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('disableTotp', () => { + it('delegates to disableTotpUseCase with the correct arguments', async () => { + const deps = makeDeps(); + + await new UserResolver(deps).disableTotp('user-1', 'secret'); + + expect(deps.disableTotpUseCase.execute).toHaveBeenCalledWith({ + userId: 'user-1', + password: 'secret', + }); + }); + }); + + describe('getTotpStatus', () => { + it('delegates to getTotpStatusUseCase and returns the result', async () => { + const deps = makeDeps({ + getTotpStatusUseCase: stub({ + execute: vi.fn().mockResolvedValue(true), + }), + }); + + const result = await new UserResolver(deps).getTotpStatus('user-1'); + + expect(deps.getTotpStatusUseCase.execute).toHaveBeenCalledWith('user-1'); + expect(result).toBe(true); + }); + }); + describe('importUserData', () => { it('delegates to importUserDataUseCase and returns the summary', async () => { const summary = { diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index 1dce1366..42cebb3e 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -46,6 +46,7 @@ export const ENV = { AXIOM_TOKEN: 'AXIOM_TOKEN', AXIOM_DATASET: 'AXIOM_DATASET', AXIOM_METRICS_DATASET: 'AXIOM_METRICS_DATASET', + TOTP_ENCRYPTION_KEY: 'TOTP_ENCRYPTION_KEY', } as const; /** `NODE_ENV` values. */ @@ -101,18 +102,37 @@ export const PASSWORD_RESET_TOKEN = { /** Minimum length enforced server-side for any newly-set password. */ export const PASSWORD_MIN_LENGTH = 8; +/** API-token scopes (mirrors the `ApiTokenScope` domain union). */ +export const API_TOKEN_SCOPE = { + FULL: 'full', + READ: 'read', +} as const; + +/** TOTP (RFC 6238) two-factor authentication settings. */ +export const TOTP_CONFIG = { + ISSUER: 'Job Finder', + /** Accept codes from the adjacent time step to absorb minor clock drift. */ + EPOCH_TOLERANCE_S: 30, +} as const; + +/** Backup/recovery codes issued alongside TOTP enrollment. */ +export const TOTP_BACKUP_CODES = { + /** How many single-use codes to generate at enrollment. */ + COUNT: 10, + /** Number of random bytes hex-encoded into each code (16 hex chars). */ + RANDOM_BYTES: 8, +} as const; + /** Rate limits for auth endpoints prone to abuse (in-process, fixed-window). */ export const RATE_LIMIT = { PASSWORD_RESET_REQUEST: { MAX_ATTEMPTS: 5, WINDOW_MS: 15 * 60 * 1000, // 15 minutes }, -} as const; - -/** API-token scopes (mirrors the `ApiTokenScope` domain union). */ -export const API_TOKEN_SCOPE = { - FULL: 'full', - READ: 'read', + TOTP_VERIFICATION: { + MAX_ATTEMPTS: 5, + WINDOW_MS: 15 * 60 * 1000, // 15 minutes + }, } as const; /** Email-verification token settings. */ diff --git a/apps/api/src/domain/totpBackupCode/TotpBackupCode.ts b/apps/api/src/domain/totpBackupCode/TotpBackupCode.ts new file mode 100644 index 00000000..e3839bb1 --- /dev/null +++ b/apps/api/src/domain/totpBackupCode/TotpBackupCode.ts @@ -0,0 +1,7 @@ +export interface TotpBackupCode { + id: string; + userId: string; + codeHash: string; + usedAt: Date | null; + createdAt: Date; +} diff --git a/apps/api/src/domain/user/User.ts b/apps/api/src/domain/user/User.ts index d3267ad2..b73d61f2 100644 --- a/apps/api/src/domain/user/User.ts +++ b/apps/api/src/domain/user/User.ts @@ -8,6 +8,8 @@ export interface User { emailVerifiedAt: Date | null; weeklyDigestEnabled: boolean; followUpRemindersEnabled: boolean; + totpSecret: string | null; + totpEnabled: boolean; createdAt: Date; updatedAt: Date; } diff --git a/apps/api/src/http/container.ts b/apps/api/src/http/container.ts index 915f726b..9c73ba8f 100644 --- a/apps/api/src/http/container.ts +++ b/apps/api/src/http/container.ts @@ -5,7 +5,6 @@ import { diContainer } from '@fastify/awilix'; import { prisma } from '@/infrastructure/db/client.js'; import { MemoryCache } from '@/infrastructure/cache/MemoryCache.js'; -import { RateLimiter } from '@/infrastructure/rateLimit/RateLimiter.js'; import { PrismaUserRepository } from '@/infrastructure/db/repositories/PrismaUserRepository.js'; import { PrismaApplicationRepository } from '@/infrastructure/db/repositories/PrismaApplicationRepository.js'; import { PrismaNoteRepository } from '@/infrastructure/db/repositories/PrismaNoteRepository.js'; @@ -21,6 +20,9 @@ import { PrismaPasswordResetTokenRepository } from '@/infrastructure/db/reposito import { PrismaLoginEventRepository } from '@/infrastructure/db/repositories/PrismaLoginEventRepository.js'; import { PrismaSessionRepository } from '@/infrastructure/db/repositories/PrismaSessionRepository.js'; import { PrismaEmailVerificationTokenRepository } from '@/infrastructure/db/repositories/PrismaEmailVerificationTokenRepository.js'; +import { PrismaTotpBackupCodeRepository } from '@/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.js'; +import { RateLimiter } from '@/infrastructure/rateLimit/RateLimiter.js'; +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; import { LocalStorageProvider } from '@/infrastructure/storage/LocalStorageProvider.js'; import { GCSStorageProvider } from '@/infrastructure/storage/GCSStorageProvider.js'; @@ -47,6 +49,7 @@ import { McpController } from '@/interface-adapters/mcp/McpController.js'; import { RegisterUseCase } from '@/use-cases/auth/RegisterUseCase.js'; import { LoginUseCase } from '@/use-cases/auth/LoginUseCase.js'; +import { LoginWithTotpUseCase } from '@/use-cases/auth/LoginWithTotpUseCase.js'; import { RequestPasswordResetUseCase } from '@/use-cases/auth/RequestPasswordResetUseCase.js'; import { ResetPasswordUseCase } from '@/use-cases/auth/ResetPasswordUseCase.js'; import { SendEmailVerificationUseCase } from '@/use-cases/auth/SendEmailVerificationUseCase.js'; @@ -68,6 +71,10 @@ import { UpdateEmailUseCase } from '@/use-cases/user/UpdateEmailUseCase.js'; import { UpdatePasswordUseCase } from '@/use-cases/user/UpdatePasswordUseCase.js'; import { DeleteAccountUseCase } from '@/use-cases/user/DeleteAccountUseCase.js'; import { ExportUserDataUseCase } from '@/use-cases/user/ExportUserDataUseCase.js'; +import { GenerateTotpSecretUseCase } from '@/use-cases/user/GenerateTotpSecretUseCase.js'; +import { ConfirmTotpSetupUseCase } from '@/use-cases/user/ConfirmTotpSetupUseCase.js'; +import { DisableTotpUseCase } from '@/use-cases/user/DisableTotpUseCase.js'; +import { GetTotpStatusUseCase } from '@/use-cases/user/GetTotpStatusUseCase.js'; import { ImportUserDataUseCase } from '@/use-cases/user/ImportUserDataUseCase.js'; import { GetNotificationPreferencesUseCase } from '@/use-cases/user/GetNotificationPreferencesUseCase.js'; import { UpdateNotificationPreferencesUseCase } from '@/use-cases/user/UpdateNotificationPreferencesUseCase.js'; @@ -140,6 +147,8 @@ declare module '@fastify/awilix' { loginEventRepository: PrismaLoginEventRepository; sessionRepository: PrismaSessionRepository; emailVerificationTokenRepository: PrismaEmailVerificationTokenRepository; + totpBackupCodeRepository: PrismaTotpBackupCodeRepository; + totpRateLimiter: IRateLimiter; applicationMapper: ApplicationMapper; apiTokenMapper: ApiTokenMapper; @@ -164,6 +173,7 @@ declare module '@fastify/awilix' { registerUseCase: RegisterUseCase; loginUseCase: LoginUseCase; + loginWithTotpUseCase: LoginWithTotpUseCase; requestPasswordResetUseCase: RequestPasswordResetUseCase; resetPasswordUseCase: ResetPasswordUseCase; sendEmailVerificationUseCase: SendEmailVerificationUseCase; @@ -185,6 +195,10 @@ declare module '@fastify/awilix' { updatePasswordUseCase: UpdatePasswordUseCase; deleteAccountUseCase: DeleteAccountUseCase; exportUserDataUseCase: ExportUserDataUseCase; + generateTotpSecretUseCase: GenerateTotpSecretUseCase; + confirmTotpSetupUseCase: ConfirmTotpSetupUseCase; + disableTotpUseCase: DisableTotpUseCase; + getTotpStatusUseCase: GetTotpStatusUseCase; importUserDataUseCase: ImportUserDataUseCase; getNotificationPreferencesUseCase: GetNotificationPreferencesUseCase; updateNotificationPreferencesUseCase: UpdateNotificationPreferencesUseCase; @@ -283,6 +297,15 @@ export function buildContainer(fastify: FastifyInstance): void { emailVerificationTokenRepository: asClass(PrismaEmailVerificationTokenRepository, { lifetime: Lifetime.SINGLETON, }), + totpBackupCodeRepository: asClass(PrismaTotpBackupCodeRepository, { + lifetime: Lifetime.SINGLETON, + }), + totpRateLimiter: asValue( + new RateLimiter( + RATE_LIMIT.TOTP_VERIFICATION.MAX_ATTEMPTS, + RATE_LIMIT.TOTP_VERIFICATION.WINDOW_MS, + ), + ), // Mappers applicationMapper: asClass(ApplicationMapper, { lifetime: Lifetime.SINGLETON }), @@ -310,6 +333,7 @@ export function buildContainer(fastify: FastifyInstance): void { // Use Cases registerUseCase: asClass(RegisterUseCase, { lifetime: Lifetime.TRANSIENT }), loginUseCase: asClass(LoginUseCase, { lifetime: Lifetime.TRANSIENT }), + loginWithTotpUseCase: asClass(LoginWithTotpUseCase, { lifetime: Lifetime.TRANSIENT }), requestPasswordResetUseCase: asClass(RequestPasswordResetUseCase, { lifetime: Lifetime.TRANSIENT, }), @@ -341,6 +365,12 @@ export function buildContainer(fastify: FastifyInstance): void { updatePasswordUseCase: asClass(UpdatePasswordUseCase, { lifetime: Lifetime.TRANSIENT }), deleteAccountUseCase: asClass(DeleteAccountUseCase, { lifetime: Lifetime.TRANSIENT }), exportUserDataUseCase: asClass(ExportUserDataUseCase, { lifetime: Lifetime.TRANSIENT }), + generateTotpSecretUseCase: asClass(GenerateTotpSecretUseCase, { + lifetime: Lifetime.TRANSIENT, + }), + confirmTotpSetupUseCase: asClass(ConfirmTotpSetupUseCase, { lifetime: Lifetime.TRANSIENT }), + disableTotpUseCase: asClass(DisableTotpUseCase, { lifetime: Lifetime.TRANSIENT }), + getTotpStatusUseCase: asClass(GetTotpStatusUseCase, { lifetime: Lifetime.TRANSIENT }), importUserDataUseCase: asClass(ImportUserDataUseCase, { lifetime: Lifetime.TRANSIENT }), getNotificationPreferencesUseCase: asClass(GetNotificationPreferencesUseCase, { lifetime: Lifetime.TRANSIENT, diff --git a/apps/api/src/http/schema/index.ts b/apps/api/src/http/schema/index.ts index dbaea8d3..f81d87d6 100644 --- a/apps/api/src/http/schema/index.ts +++ b/apps/api/src/http/schema/index.ts @@ -17,6 +17,8 @@ import './types/inputs/DocumentInputs.js'; import './types/ContactType.js'; import './types/ParsedJobDescriptionType.js'; import './types/HealthScoreType.js'; +import './types/LoginResultType.js'; +import './types/TotpSetupType.js'; import './types/LoginEventType.js'; import './types/ImportSummaryType.js'; import './types/NotificationPreferencesType.js'; diff --git a/apps/api/src/http/schema/mutations/authMutations.ts b/apps/api/src/http/schema/mutations/authMutations.ts index a46fc33d..5caf4d22 100644 --- a/apps/api/src/http/schema/mutations/authMutations.ts +++ b/apps/api/src/http/schema/mutations/authMutations.ts @@ -2,6 +2,7 @@ import { GraphQLError } from 'graphql'; import type { FastifyRequest } from 'fastify'; import { builder } from '@/http/schema/builder.js'; import { setAuthCookies, clearAuthCookies } from '@/http/schema/types/AuthPayloadType.js'; +import { LoginResultRef } from '@/http/schema/types/LoginResultType.js'; import type { DeviceInfo } from '@/interface-adapters/resolvers/AuthResolver.js'; import { fromCodedError } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; @@ -33,16 +34,40 @@ builder.mutationField('register', (t) => ); builder.mutationField('login', (t) => + t.field({ + type: LoginResultRef, + args: { + email: t.arg.string({ required: true }), + password: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + const { authResolver } = ctx.diScope.cradle; + const result = await authResolver.login( + args.email, + args.password, + deviceInfoFrom(ctx.request), + ); + if (result.tokens) { + setAuthCookies(ctx.reply, result.tokens.accessToken, result.tokens.refreshToken); + } + return { success: !result.totpRequired, totpRequired: result.totpRequired }; + }, + }), +); + +builder.mutationField('loginWithTotp', (t) => t.boolean({ args: { email: t.arg.string({ required: true }), password: t.arg.string({ required: true }), + code: t.arg.string({ required: true }), }, resolve: async (_root, args, ctx) => { const { authResolver } = ctx.diScope.cradle; - const tokens = await authResolver.login( + const tokens = await authResolver.loginWithTotp( args.email, args.password, + args.code, deviceInfoFrom(ctx.request), ); setAuthCookies(ctx.reply, tokens.accessToken, tokens.refreshToken); diff --git a/apps/api/src/http/schema/mutations/userMutations.ts b/apps/api/src/http/schema/mutations/userMutations.ts index 59e0f68b..d12d99f7 100644 --- a/apps/api/src/http/schema/mutations/userMutations.ts +++ b/apps/api/src/http/schema/mutations/userMutations.ts @@ -1,6 +1,8 @@ import { GraphQLError } from 'graphql'; import { builder } from '@/http/schema/builder.js'; import { clearAuthCookies } from '@/http/schema/types/AuthPayloadType.js'; +import { TotpSetupRef } from '@/http/schema/types/TotpSetupType.js'; +import { ConfirmTotpSetupResultRef } from '@/http/schema/types/ConfirmTotpSetupType.js'; import { ImportSummaryRef } from '@/http/schema/types/ImportSummaryType.js'; import { fromCodedError } from '@/http/errors/AppError.js'; import { ERROR_CODES } from '@/constants.js'; @@ -86,6 +88,63 @@ builder.mutationField('deleteAccount', (t) => }), ); +builder.mutationField('beginTotpSetup', (t) => + t.field({ + type: TotpSetupRef, + args: { + password: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + try { + return await userResolver.beginTotpSetup(ctx.user.sub, args.password); + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + +builder.mutationField('confirmTotpSetup', (t) => + t.field({ + type: ConfirmTotpSetupResultRef, + args: { + code: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + try { + return await userResolver.confirmTotpSetup(ctx.user.sub, args.code); + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + +builder.mutationField('disableTotp', (t) => + t.boolean({ + args: { + password: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + try { + await userResolver.disableTotp(ctx.user.sub, args.password); + return true; + } catch (err) { + throw fromCodedError(err); + } + }, + }), +); + builder.mutationField('importUserData', (t) => t.field({ type: ImportSummaryRef, diff --git a/apps/api/src/http/schema/queries/userQueries.ts b/apps/api/src/http/schema/queries/userQueries.ts index bd397837..b622724a 100644 --- a/apps/api/src/http/schema/queries/userQueries.ts +++ b/apps/api/src/http/schema/queries/userQueries.ts @@ -16,6 +16,17 @@ builder.queryField('exportUserData', (t) => }), ); +builder.queryField('totpEnabled', (t) => + t.boolean({ + resolve: async (_root, _args, ctx) => { + if (!ctx.user) + throw new GraphQLError('Unauthorized', { extensions: { code: ERROR_CODES.UNAUTHORIZED } }); + const { userResolver } = ctx.diScope.cradle; + return userResolver.getTotpStatus(ctx.user.sub); + }, + }), +); + builder.queryField('notificationPreferences', (t) => t.field({ type: NotificationPreferencesRef, diff --git a/apps/api/src/http/schema/types/ConfirmTotpSetupType.ts b/apps/api/src/http/schema/types/ConfirmTotpSetupType.ts new file mode 100644 index 00000000..7bb55984 --- /dev/null +++ b/apps/api/src/http/schema/types/ConfirmTotpSetupType.ts @@ -0,0 +1,10 @@ +import { builder } from '@/http/schema/builder.js'; +import type { ConfirmTotpSetupOutput } from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; + +export const ConfirmTotpSetupResultRef = + builder.objectRef('ConfirmTotpSetupResult'); +ConfirmTotpSetupResultRef.implement({ + fields: (t) => ({ + backupCodes: t.exposeStringList('backupCodes'), + }), +}); diff --git a/apps/api/src/http/schema/types/LoginResultType.ts b/apps/api/src/http/schema/types/LoginResultType.ts new file mode 100644 index 00000000..be1d5e11 --- /dev/null +++ b/apps/api/src/http/schema/types/LoginResultType.ts @@ -0,0 +1,14 @@ +import { builder } from '@/http/schema/builder.js'; + +export interface LoginResultDTO { + success: boolean; + totpRequired: boolean; +} + +export const LoginResultRef = builder.objectRef('LoginResult'); +LoginResultRef.implement({ + fields: (t) => ({ + success: t.exposeBoolean('success'), + totpRequired: t.exposeBoolean('totpRequired'), + }), +}); diff --git a/apps/api/src/http/schema/types/TotpSetupType.ts b/apps/api/src/http/schema/types/TotpSetupType.ts new file mode 100644 index 00000000..38543832 --- /dev/null +++ b/apps/api/src/http/schema/types/TotpSetupType.ts @@ -0,0 +1,11 @@ +import { builder } from '@/http/schema/builder.js'; +import type { TotpSetup } from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; + +export const TotpSetupRef = builder.objectRef('TotpSetup'); +TotpSetupRef.implement({ + fields: (t) => ({ + secret: t.exposeString('secret'), + otpauthUrl: t.exposeString('otpauthUrl'), + qrCodeDataUrl: t.exposeString('qrCodeDataUrl'), + }), +}); diff --git a/apps/api/src/infrastructure/auth/totp.ts b/apps/api/src/infrastructure/auth/totp.ts new file mode 100644 index 00000000..0c1d5775 --- /dev/null +++ b/apps/api/src/infrastructure/auth/totp.ts @@ -0,0 +1,10 @@ +import { TOTP, NobleCryptoPlugin, ScureBase32Plugin } from 'otplib'; +import { TOTP_CONFIG } from '@/constants.js'; + +const crypto = new NobleCryptoPlugin(); +const base32 = new ScureBase32Plugin(); + +/** Shared TOTP instance factory — `secret`/`label` are supplied per call site. */ +export function createTotp(options: { secret?: string; label?: string } = {}): TOTP { + return new TOTP({ crypto, base32, issuer: TOTP_CONFIG.ISSUER, ...options }); +} diff --git a/apps/api/src/infrastructure/auth/totpSecretCrypto.ts b/apps/api/src/infrastructure/auth/totpSecretCrypto.ts new file mode 100644 index 00000000..952c9c35 --- /dev/null +++ b/apps/api/src/infrastructure/auth/totpSecretCrypto.ts @@ -0,0 +1,39 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; +import { ENV } from '@/constants.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; +const KEY_LENGTH = 32; +const SCRYPT_SALT = 'job-finder-totp-secret'; + +function getKey(): Buffer { + const passphrase = process.env[ENV.TOTP_ENCRYPTION_KEY]; + if (!passphrase) { + throw new Error( + `${ENV.TOTP_ENCRYPTION_KEY} is not configured — required to encrypt/decrypt TOTP secrets`, + ); + } + return scryptSync(passphrase, SCRYPT_SALT, KEY_LENGTH); +} + +/** Encrypts a raw base32 TOTP secret for storage at rest. */ +export function encryptTotpSecret(plaintext: string): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, getKey(), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); +} + +/** Decrypts a TOTP secret previously produced by {@link encryptTotpSecret}. */ +export function decryptTotpSecret(ciphertext: string): string { + const data = Buffer.from(ciphertext, 'base64'); + const iv = data.subarray(0, IV_BYTES); + const authTag = data.subarray(IV_BYTES, IV_BYTES + 16); + const encrypted = data.subarray(IV_BYTES + 16); + + const decipher = createDecipheriv(ALGORITHM, getKey(), iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); +} diff --git a/apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts new file mode 100644 index 00000000..e18c9b56 --- /dev/null +++ b/apps/api/src/infrastructure/db/repositories/PrismaTotpBackupCodeRepository.ts @@ -0,0 +1,55 @@ +import type { PrismaClient } from '@prisma/client'; +import type { TotpBackupCode } from '@/domain/totpBackupCode/TotpBackupCode.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import { getClient } from '../transactionContext.js'; + +type PrismaTotpBackupCode = { + id: string; + userId: string; + codeHash: string; + usedAt: Date | null; + createdAt: Date; +}; + +export class PrismaTotpBackupCodeRepository implements ITotpBackupCodeRepository { + private readonly prisma: PrismaClient; + + constructor({ prisma }: { prisma: PrismaClient }) { + this.prisma = prisma; + } + + private get db(): PrismaClient { + return getClient(this.prisma); + } + + async create(data: { id: string; userId: string; codeHash: string }): Promise { + const row = await this.db.totpBackupCode.create({ data }); + return this.toEntity(row); + } + + async findByCodeHash(codeHash: string): Promise { + const row = await this.db.totpBackupCode.findUnique({ where: { codeHash } }); + return row ? this.toEntity(row) : null; + } + + async markUsed(id: string): Promise { + await this.db.totpBackupCode.update({ + where: { id }, + data: { usedAt: new Date() }, + }); + } + + async deleteAllForUser(userId: string): Promise { + await this.db.totpBackupCode.deleteMany({ where: { userId } }); + } + + private toEntity(row: PrismaTotpBackupCode): TotpBackupCode { + return { + id: row.id, + userId: row.userId, + codeHash: row.codeHash, + usedAt: row.usedAt, + createdAt: row.createdAt, + }; + } +} diff --git a/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts b/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts index e86e804a..af0aabac 100644 --- a/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts +++ b/apps/api/src/infrastructure/db/repositories/PrismaUserRepository.ts @@ -45,6 +45,8 @@ export class PrismaUserRepository implements IUserRepository { emailVerifiedAt?: Date | null; weeklyDigestEnabled?: boolean; followUpRemindersEnabled?: boolean; + totpSecret?: string | null; + totpEnabled?: boolean; }, ): Promise { const row = await this.db.user.update({ where: { id }, data }); @@ -65,6 +67,8 @@ export class PrismaUserRepository implements IUserRepository { emailVerifiedAt: Date | null; weeklyDigestEnabled: boolean; followUpRemindersEnabled: boolean; + totpSecret: string | null; + totpEnabled: boolean; createdAt: Date; updatedAt: Date; }): User { @@ -78,6 +82,8 @@ export class PrismaUserRepository implements IUserRepository { emailVerifiedAt: row.emailVerifiedAt, weeklyDigestEnabled: row.weeklyDigestEnabled, followUpRemindersEnabled: row.followUpRemindersEnabled, + totpSecret: row.totpSecret, + totpEnabled: row.totpEnabled, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts index 081fa0b0..79e823e5 100644 --- a/apps/api/src/interface-adapters/resolvers/AuthResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/AuthResolver.ts @@ -1,5 +1,6 @@ import type { IRegisterUseCase } from '@/use-cases/auth/IRegisterUseCase.js'; import type { ILoginUseCase } from '@/use-cases/auth/ILoginUseCase.js'; +import type { ILoginWithTotpUseCase } from '@/use-cases/auth/ILoginWithTotpUseCase.js'; import type { IRequestPasswordResetUseCase } from '@/use-cases/auth/IRequestPasswordResetUseCase.js'; import type { IResetPasswordUseCase } from '@/use-cases/auth/IResetPasswordUseCase.js'; import type { IVerifyEmailUseCase } from '@/use-cases/auth/IVerifyEmailUseCase.js'; @@ -10,6 +11,7 @@ import type { TouchSessionUseCase } from '@/use-cases/sessions/TouchSessionUseCa interface Deps { registerUseCase: IRegisterUseCase; loginUseCase: ILoginUseCase; + loginWithTotpUseCase: ILoginWithTotpUseCase; tokenService: ITokenService; requestPasswordResetUseCase: IRequestPasswordResetUseCase; resetPasswordUseCase: IResetPasswordUseCase; @@ -23,6 +25,11 @@ export interface DeviceInfo { ipAddress: string | null; } +export interface LoginResult { + totpRequired: boolean; + tokens: TokenPair | null; +} + export class AuthResolver { constructor(private readonly deps: Deps) {} @@ -32,13 +39,36 @@ export class AuthResolver { return this.deps.tokenService.sign(userId, email, session.id); } - async login(email: string, password: string, device: DeviceInfo): Promise { + async login(email: string, password: string, device: DeviceInfo): Promise { const user = await this.deps.loginUseCase.execute({ email, password, ipAddress: device.ipAddress, userAgent: device.userAgent, }); + if (user.totpEnabled) { + // Login isn't complete until TOTP is verified — no session yet. + return { totpRequired: true, tokens: null }; + } + const session = await this.deps.createSessionUseCase.execute({ userId: user.id, ...device }); + return { + totpRequired: false, + tokens: this.deps.tokenService.sign(user.id, user.email, session.id), + }; + } + + async loginWithTotp( + email: string, + password: string, + code: string, + device: DeviceInfo, + ): Promise { + const user = await this.deps.loginWithTotpUseCase.execute({ + email, + password, + code, + ipAddress: device.ipAddress, + }); const session = await this.deps.createSessionUseCase.execute({ userId: user.id, ...device }); return this.deps.tokenService.sign(user.id, user.email, session.id); } diff --git a/apps/api/src/interface-adapters/resolvers/UserResolver.ts b/apps/api/src/interface-adapters/resolvers/UserResolver.ts index 5811664f..4340ded8 100644 --- a/apps/api/src/interface-adapters/resolvers/UserResolver.ts +++ b/apps/api/src/interface-adapters/resolvers/UserResolver.ts @@ -5,6 +5,16 @@ import type { IExportUserDataUseCase, ExportUserDataOutput, } from '@/use-cases/user/IExportUserDataUseCase.js'; +import type { + IGenerateTotpSecretUseCase, + TotpSetup, +} from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; +import type { + IConfirmTotpSetupUseCase, + ConfirmTotpSetupOutput, +} from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; +import type { IDisableTotpUseCase } from '@/use-cases/user/IDisableTotpUseCase.js'; +import type { IGetTotpStatusUseCase } from '@/use-cases/user/IGetTotpStatusUseCase.js'; import type { IImportUserDataUseCase, ImportSummary, @@ -23,6 +33,10 @@ interface Deps { updatePasswordUseCase: IUpdatePasswordUseCase; deleteAccountUseCase: IDeleteAccountUseCase; exportUserDataUseCase: IExportUserDataUseCase; + generateTotpSecretUseCase: IGenerateTotpSecretUseCase; + confirmTotpSetupUseCase: IConfirmTotpSetupUseCase; + disableTotpUseCase: IDisableTotpUseCase; + getTotpStatusUseCase: IGetTotpStatusUseCase; importUserDataUseCase: IImportUserDataUseCase; getNotificationPreferencesUseCase: IGetNotificationPreferencesUseCase; updateNotificationPreferencesUseCase: IUpdateNotificationPreferencesUseCase; @@ -53,6 +67,22 @@ export class UserResolver { return this.deps.exportUserDataUseCase.execute(userId); } + async beginTotpSetup(userId: string, password: string): Promise { + return this.deps.generateTotpSecretUseCase.execute({ userId, password }); + } + + async confirmTotpSetup(userId: string, code: string): Promise { + return this.deps.confirmTotpSetupUseCase.execute({ userId, code }); + } + + async disableTotp(userId: string, password: string): Promise { + await this.deps.disableTotpUseCase.execute({ userId, password }); + } + + async getTotpStatus(userId: string): Promise { + return this.deps.getTotpStatusUseCase.execute(userId); + } + async importUserData(userId: string, rawData: string): Promise { return this.deps.importUserDataUseCase.execute(userId, rawData); } diff --git a/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts b/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts new file mode 100644 index 00000000..f116a150 --- /dev/null +++ b/apps/api/src/use-cases/auth/ILoginWithTotpUseCase.ts @@ -0,0 +1,12 @@ +import type { User } from '@/domain/user/User.js'; + +export interface LoginWithTotpInput { + email: string; + password: string; + code: string; + ipAddress: string | null; +} + +export interface ILoginWithTotpUseCase { + execute(input: LoginWithTotpInput): Promise; +} diff --git a/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts b/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts new file mode 100644 index 00000000..f59b7d1e --- /dev/null +++ b/apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts @@ -0,0 +1,76 @@ +import bcrypt from 'bcryptjs'; +import { createHash } from 'crypto'; +import type { User } from '@/domain/user/User.js'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import type { IRateLimiter } from '@/use-cases/ports/IRateLimiter.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { decryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; +import { ERROR_CODES, TOTP_CONFIG } from '@/constants.js'; +import type { + ILoginWithTotpUseCase, + LoginWithTotpInput, +} from '@/use-cases/auth/ILoginWithTotpUseCase.js'; + +interface Deps { + userRepository: IUserRepository; + totpBackupCodeRepository: ITotpBackupCodeRepository; + totpRateLimiter: IRateLimiter; +} + +export class LoginWithTotpUseCase implements ILoginWithTotpUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: LoginWithTotpInput): Promise { + const user = await this.deps.userRepository.findByEmail(input.email); + if (!user) { + throw Object.assign(new Error('Invalid credentials'), { code: ERROR_CODES.UNAUTHORIZED }); + } + + const validPassword = await bcrypt.compare(input.password, user.passwordHash); + if (!validPassword) { + throw Object.assign(new Error('Invalid credentials'), { code: ERROR_CODES.UNAUTHORIZED }); + } + + if (!user.totpEnabled || !user.totpSecret) { + throw Object.assign(new Error('Invalid credentials'), { code: ERROR_CODES.UNAUTHORIZED }); + } + + const allowedByEmail = this.deps.totpRateLimiter.consume( + `totp:email:${input.email.toLowerCase()}`, + ); + const allowedByIp = input.ipAddress + ? this.deps.totpRateLimiter.consume(`totp:ip:${input.ipAddress}`) + : true; + if (!allowedByEmail || !allowedByIp) { + throw Object.assign(new Error('Too many verification attempts. Please try again later.'), { + code: ERROR_CODES.RATE_LIMITED, + }); + } + + // TOTP codes are always 6 digits — anything else can only be a backup code. + const isTotpFormat = /^\d{6}$/.test(input.code); + const secret = decryptTotpSecret(user.totpSecret); + const validTotp = isTotpFormat + ? ( + await createTotp({ secret }).verify(input.code, { + epochTolerance: TOTP_CONFIG.EPOCH_TOLERANCE_S, + }) + ).valid + : false; + + if (!validTotp) { + const codeHash = createHash('sha256').update(input.code).digest('hex'); + const backupCode = await this.deps.totpBackupCodeRepository.findByCodeHash(codeHash); + const validBackupCode = backupCode && backupCode.userId === user.id && !backupCode.usedAt; + if (!validBackupCode) { + throw Object.assign(new Error('Invalid verification code'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + await this.deps.totpBackupCodeRepository.markUsed(backupCode.id); + } + + return user; + } +} diff --git a/apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts b/apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts new file mode 100644 index 00000000..efbeb684 --- /dev/null +++ b/apps/api/src/use-cases/ports/ITotpBackupCodeRepository.ts @@ -0,0 +1,8 @@ +import type { TotpBackupCode } from '@/domain/totpBackupCode/TotpBackupCode.js'; + +export interface ITotpBackupCodeRepository { + create(data: { id: string; userId: string; codeHash: string }): Promise; + findByCodeHash(codeHash: string): Promise; + markUsed(id: string): Promise; + deleteAllForUser(userId: string): Promise; +} diff --git a/apps/api/src/use-cases/ports/IUserRepository.ts b/apps/api/src/use-cases/ports/IUserRepository.ts index b6e2fee3..13acf838 100644 --- a/apps/api/src/use-cases/ports/IUserRepository.ts +++ b/apps/api/src/use-cases/ports/IUserRepository.ts @@ -16,6 +16,8 @@ export interface IUserRepository { emailVerifiedAt?: Date | null; weeklyDigestEnabled?: boolean; followUpRemindersEnabled?: boolean; + totpSecret?: string | null; + totpEnabled?: boolean; }, ): Promise; delete(id: string): Promise; diff --git a/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts b/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts new file mode 100644 index 00000000..15e62d1d --- /dev/null +++ b/apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts @@ -0,0 +1,64 @@ +import { createHash, randomBytes } from 'crypto'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { decryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; +import { ERROR_CODES, TOTP_CONFIG, TOTP_BACKUP_CODES } from '@/constants.js'; +import type { + IConfirmTotpSetupUseCase, + ConfirmTotpSetupInput, + ConfirmTotpSetupOutput, +} from '@/use-cases/user/IConfirmTotpSetupUseCase.js'; + +interface Deps { + userRepository: IUserRepository; + totpBackupCodeRepository: ITotpBackupCodeRepository; + generateId: () => string; +} + +export class ConfirmTotpSetupUseCase implements IConfirmTotpSetupUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: ConfirmTotpSetupInput): Promise { + const user = await this.deps.userRepository.findById(input.userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + if (user.totpEnabled) { + throw Object.assign(new Error('Two-factor authentication is already enabled'), { + code: ERROR_CODES.CONFLICT, + }); + } + if (!user.totpSecret) { + throw Object.assign(new Error('No two-factor setup in progress'), { + code: ERROR_CODES.CONFLICT, + }); + } + + const secret = decryptTotpSecret(user.totpSecret); + const result = await createTotp({ secret }).verify(input.code, { + epochTolerance: TOTP_CONFIG.EPOCH_TOLERANCE_S, + }); + if (!result.valid) { + throw Object.assign(new Error('Invalid verification code'), { + code: ERROR_CODES.UNAUTHORIZED, + }); + } + + await this.deps.userRepository.update(input.userId, { totpEnabled: true }); + + const backupCodes = Array.from({ length: TOTP_BACKUP_CODES.COUNT }, () => + randomBytes(TOTP_BACKUP_CODES.RANDOM_BYTES).toString('hex'), + ); + await Promise.all( + backupCodes.map((code) => + this.deps.totpBackupCodeRepository.create({ + id: this.deps.generateId(), + userId: input.userId, + codeHash: createHash('sha256').update(code).digest('hex'), + }), + ), + ); + + return { backupCodes }; + } +} diff --git a/apps/api/src/use-cases/user/DisableTotpUseCase.ts b/apps/api/src/use-cases/user/DisableTotpUseCase.ts new file mode 100644 index 00000000..edeb24a7 --- /dev/null +++ b/apps/api/src/use-cases/user/DisableTotpUseCase.ts @@ -0,0 +1,32 @@ +import bcrypt from 'bcryptjs'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import type { ITotpBackupCodeRepository } from '@/use-cases/ports/ITotpBackupCodeRepository.js'; +import { ERROR_CODES } from '@/constants.js'; +import type { + IDisableTotpUseCase, + DisableTotpInput, +} from '@/use-cases/user/IDisableTotpUseCase.js'; + +interface Deps { + userRepository: IUserRepository; + totpBackupCodeRepository: ITotpBackupCodeRepository; +} + +export class DisableTotpUseCase implements IDisableTotpUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: DisableTotpInput): Promise { + const user = await this.deps.userRepository.findById(input.userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + const valid = await bcrypt.compare(input.password, user.passwordHash); + if (!valid) + throw Object.assign(new Error('Invalid password'), { code: ERROR_CODES.UNAUTHORIZED }); + + await this.deps.userRepository.update(input.userId, { + totpEnabled: false, + totpSecret: null, + }); + await this.deps.totpBackupCodeRepository.deleteAllForUser(input.userId); + } +} diff --git a/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts b/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts new file mode 100644 index 00000000..405488e5 --- /dev/null +++ b/apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts @@ -0,0 +1,45 @@ +import QRCode from 'qrcode'; +import bcrypt from 'bcryptjs'; +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import { createTotp } from '@/infrastructure/auth/totp.js'; +import { encryptTotpSecret } from '@/infrastructure/auth/totpSecretCrypto.js'; +import { ERROR_CODES } from '@/constants.js'; +import type { + IGenerateTotpSecretUseCase, + GenerateTotpSecretInput, + TotpSetup, +} from '@/use-cases/user/IGenerateTotpSecretUseCase.js'; + +interface Deps { + userRepository: IUserRepository; +} + +export class GenerateTotpSecretUseCase implements IGenerateTotpSecretUseCase { + constructor(private readonly deps: Deps) {} + + async execute(input: GenerateTotpSecretInput): Promise { + const user = await this.deps.userRepository.findById(input.userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + if (user.totpEnabled) { + throw Object.assign(new Error('Two-factor authentication is already enabled'), { + code: ERROR_CODES.CONFLICT, + }); + } + + const validPassword = await bcrypt.compare(input.password, user.passwordHash); + if (!validPassword) { + throw Object.assign(new Error('Invalid password'), { code: ERROR_CODES.UNAUTHORIZED }); + } + + const secret = createTotp().generateSecret(); + const otpauthUrl = createTotp({ secret, label: user.email }).toURI(); + const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl); + + await this.deps.userRepository.update(input.userId, { + totpSecret: encryptTotpSecret(secret), + }); + + return { secret, otpauthUrl, qrCodeDataUrl }; + } +} diff --git a/apps/api/src/use-cases/user/GetTotpStatusUseCase.ts b/apps/api/src/use-cases/user/GetTotpStatusUseCase.ts new file mode 100644 index 00000000..fb223b78 --- /dev/null +++ b/apps/api/src/use-cases/user/GetTotpStatusUseCase.ts @@ -0,0 +1,18 @@ +import type { IUserRepository } from '@/use-cases/ports/IUserRepository.js'; +import { ERROR_CODES } from '@/constants.js'; +import type { IGetTotpStatusUseCase } from '@/use-cases/user/IGetTotpStatusUseCase.js'; + +interface Deps { + userRepository: IUserRepository; +} + +export class GetTotpStatusUseCase implements IGetTotpStatusUseCase { + constructor(private readonly deps: Deps) {} + + async execute(userId: string): Promise { + const user = await this.deps.userRepository.findById(userId); + if (!user) throw Object.assign(new Error('User not found'), { code: ERROR_CODES.NOT_FOUND }); + + return user.totpEnabled; + } +} diff --git a/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts b/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts new file mode 100644 index 00000000..bba5efcf --- /dev/null +++ b/apps/api/src/use-cases/user/IConfirmTotpSetupUseCase.ts @@ -0,0 +1,12 @@ +export interface ConfirmTotpSetupInput { + userId: string; + code: string; +} + +export interface ConfirmTotpSetupOutput { + backupCodes: string[]; +} + +export interface IConfirmTotpSetupUseCase { + execute(input: ConfirmTotpSetupInput): Promise; +} diff --git a/apps/api/src/use-cases/user/IDisableTotpUseCase.ts b/apps/api/src/use-cases/user/IDisableTotpUseCase.ts new file mode 100644 index 00000000..9409d3e0 --- /dev/null +++ b/apps/api/src/use-cases/user/IDisableTotpUseCase.ts @@ -0,0 +1,8 @@ +export interface DisableTotpInput { + userId: string; + password: string; +} + +export interface IDisableTotpUseCase { + execute(input: DisableTotpInput): Promise; +} diff --git a/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts b/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts new file mode 100644 index 00000000..f7815fd6 --- /dev/null +++ b/apps/api/src/use-cases/user/IGenerateTotpSecretUseCase.ts @@ -0,0 +1,14 @@ +export interface TotpSetup { + secret: string; + otpauthUrl: string; + qrCodeDataUrl: string; +} + +export interface GenerateTotpSecretInput { + userId: string; + password: string; +} + +export interface IGenerateTotpSecretUseCase { + execute(input: GenerateTotpSecretInput): Promise; +} diff --git a/apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts b/apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts new file mode 100644 index 00000000..45c2a310 --- /dev/null +++ b/apps/api/src/use-cases/user/IGetTotpStatusUseCase.ts @@ -0,0 +1,3 @@ +export interface IGetTotpStatusUseCase { + execute(userId: string): Promise; +} diff --git a/apps/web/src/__tests__/components/AccountPage.test.tsx b/apps/web/src/__tests__/components/AccountPage.test.tsx index 1717ccbd..aa24a734 100644 --- a/apps/web/src/__tests__/components/AccountPage.test.tsx +++ b/apps/web/src/__tests__/components/AccountPage.test.tsx @@ -57,6 +57,7 @@ const defaultResponse = { ], notificationPreferences: { weeklyDigestEnabled: true, followUpRemindersEnabled: true }, loginHistory: [], + totpEnabled: false, }; describe('AccountPage', () => { @@ -75,6 +76,7 @@ describe('AccountPage', () => { expect(screen.getByText('Profile')).toBeInTheDocument(); expect(screen.getByText('Email address')).toBeInTheDocument(); expect(screen.getByText('Password')).toBeInTheDocument(); + expect(screen.getByText('Two-factor authentication')).toBeInTheDocument(); expect(screen.getByText('Active sessions')).toBeInTheDocument(); expect(screen.getByText('Recent login activity')).toBeInTheDocument(); expect(screen.getByText('Notification preferences')).toBeInTheDocument(); @@ -95,6 +97,9 @@ describe('AccountPage', () => { await waitFor(() => { expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('LoginHistory')); }); + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('TotpEnabled')); + }); }); describe('profile form', () => { @@ -302,6 +307,83 @@ describe('AccountPage', () => { }); }); + describe('two-factor authentication', () => { + it('shows an "Enable 2FA" button when disabled', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => { + expect(screen.getByRole('button', { name: /enable 2fa/i })).toBeInTheDocument(); + }); + }); + + it('starts setup, shows the QR code and secret, and confirms with a code', async () => { + render(, { wrapper: Wrapper }); + await waitFor(() => expect(mockGqlRequest).toHaveBeenCalled()); + + const setupData = { + beginTotpSetup: { + secret: 'ABCD1234', + otpauthUrl: 'otpauth://totp/Job%20Finder:test@example.com?secret=ABCD1234', + qrCodeDataUrl: 'data:image/png;base64,abc123', + }, + }; + mockGqlRequest.mockResolvedValueOnce(setupData); + const enableBtn = screen.getByRole('button', { name: /enable 2fa/i }); + const beginForm = enableBtn.closest('form')!; + const beginPasswordInput = beginForm.querySelector('input[type="password"]')!; + fireEvent.change(beginPasswordInput, { target: { value: 'mypassword' } }); + fireEvent.click(enableBtn); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('BeginTotpSetup'), { + password: 'mypassword', + }); + }); + await waitFor(() => { + expect(screen.getByAltText('Two-factor authentication QR code')).toHaveAttribute( + 'src', + setupData.beginTotpSetup.qrCodeDataUrl, + ); + }); + expect(screen.getByText('ABCD1234')).toBeInTheDocument(); + + const backupCodes = ['aaaa1111bbbb2222', 'cccc3333dddd4444']; + mockGqlRequest.mockResolvedValueOnce({ confirmTotpSetup: { backupCodes } }); + fireEvent.change(screen.getByPlaceholderText('123456'), { target: { value: '654321' } }); + fireEvent.click(screen.getByRole('button', { name: /^confirm$/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('ConfirmTotpSetup'), { + code: '654321', + }); + }); + await waitFor(() => { + backupCodes.forEach((code) => expect(screen.getByText(code)).toBeInTheDocument()); + }); + }); + + it('shows a disable form when 2FA is already enabled', async () => { + mockGqlRequest.mockResolvedValue({ ...defaultResponse, totpEnabled: true }); + render(, { wrapper: Wrapper }); + + await waitFor(() => { + expect(screen.getByText('Two-factor authentication is enabled.')).toBeInTheDocument(); + }); + + mockGqlRequest.mockResolvedValueOnce({ disableTotp: true }); + const disableBtn = screen.getByRole('button', { name: /disable 2fa/i }); + const form = disableBtn.closest('form')!; + const passwordInput = form.querySelector('input[type="password"]')!; + fireEvent.change(passwordInput, { target: { value: 'mypassword' } }); + fireEvent.click(disableBtn); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('DisableTotp'), { + password: 'mypassword', + }); + }); + }); + }); + describe('active sessions', () => { it('shows the current session without a revoke button', async () => { render(, { wrapper: Wrapper }); diff --git a/apps/web/src/__tests__/components/LoginPage.test.tsx b/apps/web/src/__tests__/components/LoginPage.test.tsx index a36f09e6..695e71f6 100644 --- a/apps/web/src/__tests__/components/LoginPage.test.tsx +++ b/apps/web/src/__tests__/components/LoginPage.test.tsx @@ -31,6 +31,15 @@ vi.mock('#/lib/queryClient', () => ({ import { LoginPage } from '#/routes/login'; +const noTotpResponse = { login: { success: true, totpRequired: false } }; +const totpRequiredResponse = { login: { success: false, totpRequired: true } }; + +async function fillCredentials(email: string, password: string) { + fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: email } }); + fireEvent.change(screen.getByPlaceholderText('••••••••'), { target: { value: password } }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); +} + describe('LoginPage', () => { beforeEach(() => { vi.clearAllMocks(); @@ -68,13 +77,7 @@ describe('LoginPage', () => { it('shows validation error when password is too short', async () => { render(); - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'short' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'short'); await waitFor(() => { expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument(); @@ -83,16 +86,9 @@ describe('LoginPage', () => { }); it('calls gqlClient.request with email and password on valid submit', async () => { - mockGqlRequest.mockResolvedValue({}); + mockGqlRequest.mockResolvedValue(noTotpResponse); render(); - - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'password123'); await waitFor(() => { expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('Login'), { @@ -102,17 +98,10 @@ describe('LoginPage', () => { }); }); - it('navigates to /dashboard after successful login', async () => { - mockGqlRequest.mockResolvedValue({}); + it('navigates to /dashboard after successful login without 2FA', async () => { + mockGqlRequest.mockResolvedValue(noTotpResponse); render(); - - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'password123'); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ to: '/dashboard' }); @@ -124,14 +113,7 @@ describe('LoginPage', () => { response: { errors: [{ message: 'Invalid credentials' }] }, }); render(); - - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, - }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await fillCredentials('test@example.com', 'password123'); await waitFor(() => { expect(screen.getByText('Invalid credentials')).toBeInTheDocument(); @@ -142,17 +124,74 @@ describe('LoginPage', () => { it('shows generic error message when API error has no message', async () => { mockGqlRequest.mockRejectedValue(new Error('Network error')); render(); + await fillCredentials('test@example.com', 'password123'); - fireEvent.change(screen.getByPlaceholderText('you@example.com'), { - target: { value: 'test@example.com' }, + await waitFor(() => { + expect(screen.getByText('Login failed. Please try again.')).toBeInTheDocument(); }); - fireEvent.change(screen.getByPlaceholderText('••••••••'), { - target: { value: 'password123' }, + }); + + describe('two-factor authentication step', () => { + it('shows the code entry step when the login response requires 2FA', async () => { + mockGqlRequest.mockResolvedValue(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + + await waitFor(() => { + expect(screen.getByText('Two-factor authentication')).toBeInTheDocument(); + }); + expect(mockNavigate).not.toHaveBeenCalled(); }); - fireEvent.click(screen.getByRole('button', { name: /sign in/i })); - await waitFor(() => { - expect(screen.getByText('Login failed. Please try again.')).toBeInTheDocument(); + it('calls loginWithTotp with the original credentials and the code, then navigates', async () => { + mockGqlRequest.mockResolvedValueOnce(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + await waitFor(() => screen.getByPlaceholderText('123456')); + + mockGqlRequest.mockResolvedValueOnce({ loginWithTotp: true }); + fireEvent.change(screen.getByPlaceholderText('123456'), { target: { value: '654321' } }); + fireEvent.click(screen.getByRole('button', { name: /verify/i })); + + await waitFor(() => { + expect(mockGqlRequest).toHaveBeenCalledWith(expect.stringContaining('LoginWithTotp'), { + email: 'test@example.com', + password: 'password123', + code: '654321', + }); + }); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ to: '/dashboard' }); + }); + }); + + it('shows an error message when the code is invalid', async () => { + mockGqlRequest.mockResolvedValueOnce(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + await waitFor(() => screen.getByPlaceholderText('123456')); + + mockGqlRequest.mockRejectedValueOnce({ + response: { errors: [{ message: 'Invalid verification code' }] }, + }); + fireEvent.change(screen.getByPlaceholderText('123456'), { target: { value: '000000' } }); + fireEvent.click(screen.getByRole('button', { name: /verify/i })); + + await waitFor(() => { + expect(screen.getByText('Invalid verification code')).toBeInTheDocument(); + }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('returns to the credentials form when "Back to sign in" is clicked', async () => { + mockGqlRequest.mockResolvedValueOnce(totpRequiredResponse); + render(); + await fillCredentials('test@example.com', 'password123'); + await waitFor(() => screen.getByPlaceholderText('123456')); + + fireEvent.click(screen.getByRole('button', { name: /back to sign in/i })); + + expect(screen.getByPlaceholderText('you@example.com')).toBeInTheDocument(); }); }); }); diff --git a/apps/web/src/routes/_authenticated/account.tsx b/apps/web/src/routes/_authenticated/account.tsx index 5b443161..abd56508 100644 --- a/apps/web/src/routes/_authenticated/account.tsx +++ b/apps/web/src/routes/_authenticated/account.tsx @@ -120,6 +120,36 @@ const UPDATE_NOTIFICATION_PREFERENCES = ` } `; +const TOTP_ENABLED_QUERY = ` + query TotpEnabled { + totpEnabled + } +`; + +const BEGIN_TOTP_SETUP = ` + mutation BeginTotpSetup($password: String!) { + beginTotpSetup(password: $password) { + secret + otpauthUrl + qrCodeDataUrl + } + } +`; + +const CONFIRM_TOTP_SETUP = ` + mutation ConfirmTotpSetup($code: String!) { + confirmTotpSetup(code: $code) { + backupCodes + } + } +`; + +const DISABLE_TOTP = ` + mutation DisableTotp($password: String!) { + disableTotp(password: $password) + } +`; + // ── Schemas ──────────────────────────────────────────────────────────────── const profileSchema = z.object({ @@ -148,10 +178,27 @@ const deleteSchema = z.object({ password: z.string().min(1, 'Required'), }); +const totpBeginSchema = z.object({ + password: z.string().min(1, 'Required'), +}); + +const totpConfirmSchema = z.object({ + code: z.string().min(6, 'Enter the 6-digit code').max(6, 'Enter the 6-digit code'), +}); + +const totpDisableSchema = z.object({ + password: z.string().min(1, 'Required'), +}); + type ProfileForm = z.infer; type EmailForm = z.infer; type PasswordForm = z.infer; type DeleteForm = z.infer; +type TotpBeginForm = z.infer; +type TotpConfirmForm = z.infer; +type TotpDisableForm = z.infer; + +type TotpSetup = { secret: string; otpauthUrl: string; qrCodeDataUrl: string }; interface LoginEvent { id: string; @@ -312,6 +359,60 @@ export function AccountPage() { } }; + // Two-factor authentication + const { data: totpData } = useQuery({ + queryKey: ['totpEnabled'], + queryFn: () => gqlClient.request<{ totpEnabled: boolean }>(TOTP_ENABLED_QUERY), + }); + const totpEnabled = totpData?.totpEnabled ?? false; + const [totpSetup, setTotpSetup] = useState(null); + const [backupCodes, setBackupCodes] = useState(null); + + const totpBeginForm = useForm({ resolver: zodResolver(totpBeginSchema) }); + const onBeginTotpSetup = async (data: TotpBeginForm) => { + try { + const res = await gqlClient.request<{ beginTotpSetup: TotpSetup }>(BEGIN_TOTP_SETUP, data); + setTotpSetup(res.beginTotpSetup); + totpBeginForm.reset(); + } catch (err) { + totpBeginForm.setError('root', { + message: extractGqlError(err) ?? 'Failed to start two-factor setup.', + }); + } + }; + + const totpConfirmForm = useForm({ resolver: zodResolver(totpConfirmSchema) }); + const onConfirmTotpSetup = async (data: TotpConfirmForm) => { + try { + const res = await gqlClient.request<{ confirmTotpSetup: { backupCodes: string[] } }>( + CONFIRM_TOTP_SETUP, + data, + ); + setTotpSetup(null); + setBackupCodes(res.confirmTotpSetup.backupCodes); + totpConfirmForm.reset(); + await qc.invalidateQueries({ queryKey: ['totpEnabled'] }); + } catch (err) { + totpConfirmForm.setError('root', { + message: extractGqlError(err) ?? 'Invalid code. Please try again.', + }); + } + }; + + const totpDisableForm = useForm({ resolver: zodResolver(totpDisableSchema) }); + const onDisableTotp = async (data: TotpDisableForm) => { + try { + await gqlClient.request(DISABLE_TOTP, data); + totpDisableForm.reset(); + setBackupCodes(null); + await qc.invalidateQueries({ queryKey: ['totpEnabled'] }); + } catch (err) { + totpDisableForm.setError('root', { + message: extractGqlError(err) ?? 'Failed to disable two-factor authentication.', + }); + } + }; + // Delete form const deleteForm = useForm({ resolver: zodResolver(deleteSchema) }); const onDeleteAccount = async (data: DeleteForm) => { @@ -592,6 +693,144 @@ export function AccountPage() {
+ {/* ── Two-factor authentication ── */} +
+
+

+ Two-factor authentication +

+

+ Require a code from an authenticator app in addition to your password. +

+
+ + {backupCodes ? ( +
+

Two-factor authentication is enabled.

+

+ Save these backup codes somewhere safe. Each one can be used once to sign in if you + lose access to your authenticator app — they won't be shown again. +

+
    + {backupCodes.map((code) => ( +
  • {code}
  • + ))} +
+ +
+ ) : totpEnabled ? ( +
+

Two-factor authentication is enabled.

+
+ + + {totpDisableForm.formState.errors.password && ( +

+ {totpDisableForm.formState.errors.password.message} +

+ )} +
+ {totpDisableForm.formState.errors.root && ( +

+ {totpDisableForm.formState.errors.root.message} +

+ )} + +
+ ) : totpSetup ? ( +
+

+ Scan this QR code with your authenticator app, or enter the key manually. +

+ Two-factor authentication QR code +

+ {totpSetup.secret} +

+
+
+ + + {totpConfirmForm.formState.errors.code && ( +

+ {totpConfirmForm.formState.errors.code.message} +

+ )} +
+ {totpConfirmForm.formState.errors.root && ( +

+ {totpConfirmForm.formState.errors.root.message} +

+ )} + +
+
+ ) : ( +
+
+ + + {totpBeginForm.formState.errors.password && ( +

+ {totpBeginForm.formState.errors.password.message} +

+ )} +
+ {totpBeginForm.formState.errors.root && ( +

+ {totpBeginForm.formState.errors.root.message} +

+ )} + +
+ )} +
+ +
+ {/* ── Active sessions ── */}
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 9bf73b27..d32d6bbc 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -12,9 +13,26 @@ const schema = z.object({ }); type FormValues = z.infer; +const totpSchema = z.object({ + code: z + .string() + .min(6, 'Enter your 6-digit code or a backup code') + .max(20, 'Enter your 6-digit code or a backup code'), +}); +type TotpFormValues = z.infer; + const LOGIN_MUTATION = ` mutation Login($email: String!, $password: String!) { - login(email: $email, password: $password) + login(email: $email, password: $password) { + success + totpRequired + } + } +`; + +const LOGIN_WITH_TOTP_MUTATION = ` + mutation LoginWithTotp($email: String!, $password: String!, $code: String!) { + loginWithTotp(email: $email, password: $password, code: $code) } `; @@ -28,6 +46,8 @@ export const Route = createFileRoute('/login')({ export function LoginPage() { const navigate = useNavigate(); + const [pendingCredentials, setPendingCredentials] = useState(null); + const { register, handleSubmit, @@ -39,7 +59,13 @@ export function LoginPage() { const onSubmit = async (data: FormValues) => { try { - await gqlClient.request(LOGIN_MUTATION, data); + const res = await gqlClient.request<{ + login: { success: boolean; totpRequired: boolean }; + }>(LOGIN_MUTATION, data); + if (res.login.totpRequired) { + setPendingCredentials(data); + return; + } await queryClient.resetQueries(); await navigate({ to: '/dashboard' }); } catch (err: unknown) { @@ -48,6 +74,10 @@ export function LoginPage() { } }; + if (pendingCredentials) { + return setPendingCredentials(null)} />; + } + return (
@@ -114,6 +144,78 @@ export function LoginPage() { ); } +function TotpStep({ credentials, onBack }: { credentials: FormValues; onBack: () => void }) { + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + } = useForm({ + resolver: zodResolver(totpSchema), + }); + + const onSubmit = async (data: TotpFormValues) => { + try { + await gqlClient.request(LOGIN_WITH_TOTP_MUTATION, { ...credentials, code: data.code }); + await queryClient.resetQueries(); + await navigate({ to: '/dashboard' }); + } catch (err: unknown) { + const msg = extractGqlError(err) ?? 'Invalid code. Please try again.'; + setError('root', { message: msg }); + } + }; + + return ( +
+
+
+

+ Two-factor authentication +

+

+ Enter the 6-digit code from your authenticator app, or one of your backup codes. +

+
+ +
+
+ + + {errors.code &&

{errors.code.message}

} +
+ + {errors.root && ( +

+ {errors.root.message} +

+ )} + + +
+ + +
+
+ ); +} + function extractGqlError(err: unknown): string | null { if (typeof err === 'object' && err !== null && 'response' in err) { const r = (err as { response?: { errors?: Array<{ message?: string }> } }).response; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4254cf7..cc4ea7ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,12 @@ importers: nanoid: specifier: ^5.0.9 version: 5.1.16 + otplib: + specifier: ^13.4.1 + version: 13.4.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 zod: specifier: ^3.23.8 version: 3.25.76 @@ -111,6 +117,9 @@ importers: '@types/node': specifier: ^22.10.10 version: 22.20.1 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) @@ -134,7 +143,7 @@ importers: version: 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^4.1.5 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) apps/cli: dependencies: @@ -320,7 +329,7 @@ importers: version: 7.1.1(eslint@9.39.5(jiti@2.7.0)) jsdom: specifier: ^28.1.0 - version: 28.1.0 + version: 28.1.0(@noble/hashes@2.2.0) typescript: specifier: ^6.0.2 version: 6.0.3 @@ -332,7 +341,7 @@ importers: version: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.5 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages: @@ -1467,6 +1476,10 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -2147,6 +2160,24 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@otplib/core@13.4.1': + resolution: {integrity: sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==} + + '@otplib/hotp@13.4.1': + resolution: {integrity: sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==} + + '@otplib/plugin-base32-scure@13.4.1': + resolution: {integrity: sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==} + + '@otplib/plugin-crypto-noble@13.4.1': + resolution: {integrity: sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==} + + '@otplib/totp@13.4.1': + resolution: {integrity: sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==} + + '@otplib/uri@13.4.1': + resolution: {integrity: sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==} + '@oxc-parser/binding-android-arm-eabi@0.120.0': resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2586,6 +2617,9 @@ packages: cpu: [x64] os: [win32] + '@scure/base@2.2.0': + resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==} + '@solid-primitives/event-listener@2.4.6': resolution: {integrity: sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==} peerDependencies: @@ -3141,6 +3175,9 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3552,6 +3589,10 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001805: resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} @@ -3620,6 +3661,9 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3810,6 +3854,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} @@ -3872,6 +3920,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -4201,6 +4252,10 @@ packages: resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} engines: {node: '>=20'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4932,6 +4987,10 @@ packages: enquirer: optional: true + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5210,6 +5269,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + otplib@13.4.1: + resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -5218,10 +5280,18 @@ packages: resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} engines: {node: ^20.19.0 || >=22.12.0} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5230,6 +5300,10 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5346,6 +5420,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5428,6 +5506,11 @@ packages: resolution: {integrity: sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==} engines: {node: '>= 16'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5550,6 +5633,9 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -5677,6 +5763,9 @@ packages: resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==} engines: {node: '>=10'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -6294,6 +6383,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -6358,6 +6450,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6373,10 +6468,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -6857,7 +6960,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.1': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 '@fastify/accept-negotiator@2.0.1': {} @@ -7704,6 +7809,8 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@noble/hashes@2.2.0': {} + '@nodable/entities@3.0.0': {} '@nodelib/fs.scandir@2.1.5': @@ -8664,6 +8771,33 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@otplib/core@13.4.1': {} + + '@otplib/hotp@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@otplib/uri': 13.4.1 + + '@otplib/plugin-base32-scure@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@scure/base': 2.2.0 + + '@otplib/plugin-crypto-noble@13.4.1': + dependencies: + '@noble/hashes': 2.2.0 + '@otplib/core': 13.4.1 + + '@otplib/totp@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@otplib/hotp': 13.4.1 + '@otplib/uri': 13.4.1 + + '@otplib/uri@13.4.1': + dependencies: + '@otplib/core': 13.4.1 + '@oxc-parser/binding-android-arm-eabi@0.120.0': optional: true @@ -8956,6 +9090,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@scure/base@2.2.0': {} + '@solid-primitives/event-listener@2.4.6(solid-js@1.9.14)': dependencies: '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) @@ -9621,6 +9757,10 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.20.1 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -9847,7 +9987,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -10200,6 +10340,8 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001805: {} capital-case@1.0.4: @@ -10284,6 +10426,12 @@ snapshots: cli-width@3.0.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10419,10 +10567,10 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@2.2.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' @@ -10454,6 +10602,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} decimal.js@10.6.0: {} @@ -10498,6 +10648,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -11011,6 +11163,11 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.1.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -11326,9 +11483,9 @@ snapshots: dependencies: hermes-estree: 0.25.1 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' @@ -11653,16 +11810,16 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@28.1.0: + jsdom@28.1.0(@noble/hashes@2.2.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 '@bramus/specificity': 2.4.2 - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) cssstyle: 6.2.0 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@2.2.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -11674,7 +11831,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -11837,6 +11994,10 @@ snapshots: through: 2.3.8 wrap-ansi: 7.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -12111,6 +12272,15 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + otplib@13.4.1: + dependencies: + '@otplib/core': 13.4.1 + '@otplib/hotp': 13.4.1 + '@otplib/plugin-base32-scure': 13.4.1 + '@otplib/plugin-crypto-noble': 13.4.1 + '@otplib/totp': 13.4.1 + '@otplib/uri': 13.4.1 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -12145,10 +12315,18 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -12157,6 +12335,8 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} param-case@3.0.4: @@ -12282,6 +12462,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@6.0.10: @@ -12358,6 +12540,12 @@ snapshots: qlobber@8.0.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -12485,6 +12673,8 @@ snapshots: transitivePeerDependencies: - supports-color + require-main-filename@2.0.0: {} + reselect@5.2.0: {} resolve-from@4.0.0: {} @@ -12645,6 +12835,8 @@ snapshots: seroval@1.5.5: {} + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -13162,7 +13354,7 @@ snapshots: optionalDependencies: vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -13188,7 +13380,7 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.1 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - jsdom: 28.1.0 + jsdom: 28.1.0(@noble/hashes@2.2.0) transitivePeerDependencies: - msw @@ -13212,9 +13404,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@2.2.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -13256,6 +13448,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 @@ -13314,6 +13508,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13322,8 +13518,27 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.3: dependencies: cliui: 8.0.1