From 74888d6a7648b9f2dafdd386093a6c8e28016bca Mon Sep 17 00:00:00 2001 From: Amrit Date: Sat, 13 Jun 2026 15:59:48 +0530 Subject: [PATCH] feat(backend): add local auth foundation --- apps/backend/README.md | 46 ++++ .../migration.sql | 3 + apps/backend/prisma/schema.prisma | 189 +++++++-------- apps/backend/src/__tests__/auth-local.test.ts | 221 ++++++++++++++++++ apps/backend/src/routes/auth.ts | 183 ++++++++++++++- apps/backend/src/utils/password.ts | 29 +++ 6 files changed, 576 insertions(+), 95 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260613090000_add_password_hash_to_users/migration.sql create mode 100644 apps/backend/src/__tests__/auth-local.test.ts create mode 100644 apps/backend/src/utils/password.ts diff --git a/apps/backend/README.md b/apps/backend/README.md index a807f250..0b016343 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,5 +1,51 @@ # DevCard Backend +## Authentication API + +Local credential authentication is available as a backend foundation for future +profile-sharing features. These endpoints do not add any web or mobile UI. + +### POST `/auth/register` + +Creates a local user account, stores the password as a salted scrypt hash, and +returns an access token plus refresh token. + +Request body: + +```json +{ + "email": "ada@example.com", + "username": "ada", + "displayName": "Ada Lovelace", + "password": "correct-horse-battery-staple" +} +``` + +Responses: + +- `201` with `{ "user": { "id", "email", "username", "displayName" }, "accessToken", "refreshToken" }` +- `400` when validation fails +- `409` when the email or username is already registered + +### POST `/auth/login` + +Authenticates an existing local account by email and password. + +Request body: + +```json +{ + "email": "ada@example.com", + "password": "correct-horse-battery-staple" +} +``` + +Responses: + +- `200` with `{ "user": { "id", "email", "username", "displayName" }, "accessToken", "refreshToken" }` +- `400` when validation fails +- `401` when credentials are invalid + ## Follow Engine Architecture DevCard implements a multi-layered Hybrid Follow Engine designed to connect platform professionals seamlessly while maintaining platform policy compliance. diff --git a/apps/backend/prisma/migrations/20260613090000_add_password_hash_to_users/migration.sql b/apps/backend/prisma/migrations/20260613090000_add_password_hash_to_users/migration.sql new file mode 100644 index 00000000..830dc62e --- /dev/null +++ b/apps/backend/prisma/migrations/20260613090000_add_password_hash_to_users/migration.sql @@ -0,0 +1,3 @@ +-- Add nullable password storage for local credential authentication. +-- OAuth-only accounts can continue to exist without a password hash. +ALTER TABLE "users" ADD COLUMN "password_hash" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 38fb91fe..92f158c1 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1,48 +1,50 @@ generator client { provider = "prisma-client-js" } + datasource db { provider = "postgresql" url = env("DATABASE_URL") - } -enum Role{ + +enum Role { SUPERADMIN ADMIN USER - } + model User { - id String @id @default(uuid()) - email String @unique - username String @unique - displayName String @map("display_name") - bio String? - pronouns String? - role String? - authRole Role @default(USER) - company String? - avatarUrl String? @map("avatar_url") - accentColor String @default("#6366f1") @map("accent_color") - emailVerified Boolean @default(false) @map("email_verified") - phoneNumber String? @unique @map("phone_number") - lastSignInAt DateTime? @map("last_sign_in_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - isActive Boolean @default(false) - - identities UserIdentity[] - refreshTokens RefreshToken[] - platformLinks PlatformLink[] - cards Card[] - oauthTokens OAuthToken[] - ownedViews CardView[] @relation("cardOwner") - viewedCards CardView[] @relation("cardViewer") - followLogs FollowLog[] - organizer Event[] - attendedEvents EventAttendee[] - ownedTeams Team[] @relation("TeamOwner") - teamMemberships TeamMember[] @relation("TeamMember") + id String @id @default(uuid()) + email String @unique + username String @unique + displayName String @map("display_name") + bio String? + pronouns String? + role String? + authRole Role @default(USER) + company String? + avatarUrl String? @map("avatar_url") + passwordHash String? @map("password_hash") + accentColor String @default("#6366f1") @map("accent_color") + emailVerified Boolean @default(false) @map("email_verified") + phoneNumber String? @unique @map("phone_number") + lastSignInAt DateTime? @map("last_sign_in_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + isActive Boolean @default(false) + + identities UserIdentity[] + refreshTokens RefreshToken[] + platformLinks PlatformLink[] + cards Card[] + oauthTokens OAuthToken[] + ownedViews CardView[] @relation("cardOwner") + viewedCards CardView[] @relation("cardViewer") + followLogs FollowLog[] + organizer Event[] + attendedEvents EventAttendee[] + ownedTeams Team[] @relation("TeamOwner") + teamMemberships TeamMember[] @relation("TeamMember") @@map("users") } @@ -50,8 +52,8 @@ model User { model UserIdentity { id String @id @default(uuid()) userId String @map("user_id") - provider String // "google.com" | "apple.com" | "firebase" | "phone" - providerId String @map("provider_id") // Google sub / Apple sub / Firebase UID + provider String // "google.com" | "apple.com" | "firebase" | "phone" + providerId String @map("provider_id") // Google sub / Apple sub / Firebase UID createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -61,17 +63,16 @@ model UserIdentity { @@map("user_identities") } - model RefreshToken { id String @id @default(uuid()) userId String @map("user_id") tokenHash String @unique @map("token_hash") //SHA-256 hash - family String // token rotation + family String // token rotation expiresAt DateTime @map("expires_at") - revokedAt DateTime? @map("revoked_at") // null = still valid + revokedAt DateTime? @map("revoked_at") // null = still valid createdAt DateTime @default(now()) @map("created_at") - userAgent String? @map("user_agent") - ip String? //hash + userAgent String? @map("user_agent") + ip String? //hash user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -81,13 +82,13 @@ model RefreshToken { } model PlatformLink { - id String @id @default(uuid()) - userId String @map("user_id") + id String @id @default(uuid()) + userId String @map("user_id") platform String username String url String - displayOrder Int @default(0) @map("display_order") - createdAt DateTime @default(now()) @map("created_at") + displayOrder Int @default(0) @map("display_order") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) cardLinks CardLink[] @@ -96,12 +97,12 @@ model PlatformLink { } model Card { - id String @id @default(uuid()) - userId String @map("user_id") + id String @id @default(uuid()) + userId String @map("user_id") title String - isDefault Boolean @default(false) @map("is_default") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) cardLinks CardLink[] @@ -142,17 +143,17 @@ model OAuthToken { model CardView { id String @id @default(uuid()) - cardId String? @map("card_id") // null = default profile view - ownerId String @map("owner_id") // card/profile owner - viewerId String? @map("viewer_id") // null = anonymous web viewer + cardId String? @map("card_id") // null = default profile view + ownerId String @map("owner_id") // card/profile owner + viewerId String? @map("viewer_id") // null = anonymous web viewer viewerIp String? @map("viewer_ip") viewerAgent String? @map("viewer_agent") - source String @default("qr") // "qr" | "link" | "web" | "app" + source String @default("qr") // "qr" | "link" | "web" | "app" createdAt DateTime @default(now()) @map("created_at") - card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull) - owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade) - viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) + card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull) + owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade) + viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) @@map("card_views") } @@ -162,8 +163,8 @@ model FollowLog { followerId String @map("follower_id") targetUsername String @map("target_username") platform String - status String @default("success") // "success" | "error" - layer String // "api" | "webview" | "link" + status String @default("success") // "success" | "error" + layer String // "api" | "webview" | "link" createdAt DateTime @default(now()) @map("created_at") follower User @relation(fields: [followerId], references: [id], onDelete: Cascade) @@ -172,29 +173,29 @@ model FollowLog { } model Event { - id String @id @default(uuid()) - name String - slug String @unique - location String + id String @id @default(uuid()) + name String + slug String @unique + location String description String? - organizerId String - startDate DateTime - endDate DateTime - isPublic Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - attendees EventAttendee[] + organizerId String + startDate DateTime + endDate DateTime + isPublic Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + attendees EventAttendee[] organizer User @relation(fields: [organizerId], references: [id]) } model EventAttendee { - id String @id @default(uuid()) - userId String - eventId String - joinedAt DateTime + id String @id @default(uuid()) + userId String + eventId String + joinedAt DateTime - event Event @relation(fields: [eventId] , references: [id]) - user User @relation(fields: [userId],references: [id]) + event Event @relation(fields: [eventId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@unique([userId, eventId]) } @@ -205,34 +206,34 @@ enum TeamRole { MEMBER } -model Team{ - id String @id @default(uuid()) - name String - slug String @unique - description String? - avatarUrl String? - ownerId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Team { + id String @id @default(uuid()) + name String + slug String @unique + description String? + avatarUrl String? + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - owner User @relation("TeamOwner", fields: [ownerId], references: [id], onDelete: Restrict) + owner User @relation("TeamOwner", fields: [ownerId], references: [id], onDelete: Restrict) members TeamMember[] @relation("TeamMember") - @@map("teams") @@index([slug]) + @@map("teams") } -model TeamMember{ - id String @id @default(uuid()) - teamId String - userId String - role TeamRole - joinedAt DateTime +model TeamMember { + id String @id @default(uuid()) + teamId String + userId String + role TeamRole + joinedAt DateTime - team Team @relation("TeamMember",fields: [teamId] , references: [id], onDelete: Cascade) - user User @relation("TeamMember",fields: [userId] , references: [id]) + team Team @relation("TeamMember", fields: [teamId], references: [id], onDelete: Cascade) + user User @relation("TeamMember", fields: [userId], references: [id]) @@unique([userId, teamId]) @@index([userId]) @@map("team_members") -} \ No newline at end of file +} diff --git a/apps/backend/src/__tests__/auth-local.test.ts b/apps/backend/src/__tests__/auth-local.test.ts new file mode 100644 index 00000000..b49041b6 --- /dev/null +++ b/apps/backend/src/__tests__/auth-local.test.ts @@ -0,0 +1,221 @@ +import cookiePlugin from '@fastify/cookie'; +import jwtPlugin from '@fastify/jwt'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; +import { hashPassword } from '../utils/password.js'; + +import type { PrismaClient } from '@prisma/client'; + +const TEST_JWT_SECRET = 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + +const mockUser = { + id: 'user-123', + email: 'ada@example.com', + username: 'ada', + displayName: 'Ada Lovelace', +}; + +const mockPrisma = { + user: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + refreshToken: { + create: vi.fn(), + }, +}; + +async function buildTestApp(): Promise { + const app = Fastify({ logger: false }); + + await app.register(cookiePlugin as any); + await app.register(jwtPlugin as any, { + secret: TEST_JWT_SECRET, + cookie: { cookieName: 'access_Token', signed: false }, + }); + + app.decorate('prisma', mockPrisma as unknown as PrismaClient); + app.decorate('authenticate', async (request: any) => { + request.user = { id: mockUser.id, username: mockUser.username }; + }); + + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + + return app; +} + +describe('local auth routes', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + mockPrisma.refreshToken.create.mockResolvedValue({ id: 'refresh-token-123' }); + app = await buildTestApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('POST /auth/register', () => { + it('201 — creates a user with a hashed password and returns auth tokens', async () => { + mockPrisma.user.findFirst.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue(mockUser); + + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { + email: 'ADA@EXAMPLE.COM', + username: 'ada', + displayName: 'Ada Lovelace', + password: 'correct-horse-battery-staple', + }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json()).toEqual({ + user: mockUser, + accessToken: expect.any(String), + refreshToken: expect.any(String), + }); + + expect(mockPrisma.user.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + email: 'ada@example.com', + username: 'ada', + displayName: 'Ada Lovelace', + isActive: true, + passwordHash: expect.stringMatching(/^scrypt:[0-9a-f]+:[0-9a-f]+$/), + }), + select: { + id: true, + email: true, + username: true, + displayName: true, + }, + })); + expect(mockPrisma.user.create.mock.calls[0][0].data.passwordHash).not.toBe('correct-horse-battery-staple'); + expect(mockPrisma.refreshToken.create).toHaveBeenCalledOnce(); + expect(res.headers['set-cookie']).toBeDefined(); + }); + + it('400 — rejects invalid input', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { + email: 'not-an-email', + username: 'a', + displayName: '', + password: 'short', + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Validation failed'); + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); + + it('409 — rejects duplicate email addresses', async () => { + mockPrisma.user.findFirst.mockResolvedValue({ + email: 'ada@example.com', + username: 'someone-else', + }); + + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { + email: 'ada@example.com', + username: 'ada', + displayName: 'Ada Lovelace', + password: 'correct-horse-battery-staple', + }, + }); + + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ error: 'Email already registered' }); + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); + }); + + describe('POST /auth/login', () => { + it('200 — authenticates a user and rotates session state', async () => { + const passwordHash = await hashPassword('correct-horse-battery-staple'); + mockPrisma.user.findUnique.mockResolvedValue({ ...mockUser, passwordHash }); + mockPrisma.user.update.mockResolvedValue({ ...mockUser, lastSignInAt: new Date() }); + + const res = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { + email: 'ADA@EXAMPLE.COM', + password: 'correct-horse-battery-staple', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + user: mockUser, + accessToken: expect.any(String), + refreshToken: expect.any(String), + }); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: 'ada@example.com' }, + select: { + id: true, + email: true, + username: true, + displayName: true, + passwordHash: true, + }, + }); + expect(mockPrisma.user.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: mockUser.id }, + data: expect.objectContaining({ isActive: true }), + })); + expect(mockPrisma.refreshToken.create).toHaveBeenCalledOnce(); + }); + + it('401 — rejects bad credentials', async () => { + const passwordHash = await hashPassword('correct-horse-battery-staple'); + mockPrisma.user.findUnique.mockResolvedValue({ ...mockUser, passwordHash }); + + const res = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { + email: 'ada@example.com', + password: 'wrong-password', + }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: 'Invalid email or password' }); + expect(mockPrisma.user.update).not.toHaveBeenCalled(); + expect(mockPrisma.refreshToken.create).not.toHaveBeenCalled(); + }); + + it('401 — rejects OAuth-only accounts without password hashes', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ ...mockUser, passwordHash: null }); + + const res = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { + email: 'ada@example.com', + password: 'correct-horse-battery-staple', + }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: 'Invalid email or password' }); + }); + }); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 11351267..e1d21db1 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,9 @@ +import { z } from 'zod'; + import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/error.util.js'; import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; +import { hashPassword, verifyPassword } from '../utils/password.js'; import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js'; import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; @@ -51,8 +54,187 @@ interface GitHubUserResponse { avatar_url: string; } +const registerSchema = z.object({ + email: z.string().trim().email().max(254), + username: z + .string() + .trim() + .min(3) + .max(30) + .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens'), + displayName: z.string().trim().min(1).max(80), + password: z.string().min(8).max(128), +}); + +const loginSchema = z.object({ + email: z.string().trim().email().max(254), + password: z.string().min(1).max(128), +}); + +type AuthUser = { + id: string; + email: string; + username: string; + displayName: string; +}; + +function publicUser(user: AuthUser): AuthUser { + return { + id: user.id, + email: user.email, + username: user.username, + displayName: user.displayName, + }; +} + +function validationError(reply: FastifyReply, error: z.ZodError): FastifyReply { + return reply.status(400).send({ + error: 'Validation failed', + details: error.flatten().fieldErrors, + }); +} + +function isUniqueConstraintError(error: unknown): boolean { + return (error as { code?: string }).code === 'P2002'; +} + +async function issueAuthTokens( + app: FastifyInstance, + request: FastifyRequest, + reply: FastifyReply, + user: { id: string; username: string }, +): Promise<{ accessToken: string; refreshToken: string }> { + const accessToken = signAccessToken(app, user); + const refreshToken = generateRefreshToken(); + + await app.prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: hashRefreshToken(refreshToken), + family: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + ip: hashIp(request.ip), + userAgent: request.headers['user-agent'] ?? 'unknown', + }, + }); + + reply.setCookie('access_Token', accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 15 * 60, + }); + + reply.setCookie('refresh_token', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 90 * 24 * 60 * 60, + }); + + return { accessToken, refreshToken }; +} export async function authRoutes(app: FastifyInstance): Promise { + app.post('/register', async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = registerSchema.safeParse(request.body); + if (!parsed.success) { + return validationError(reply, parsed.error); + } + + const { email, username, displayName, password } = parsed.data; + const normalizedEmail = email.toLowerCase(); + + const existingUser = await app.prisma.user.findFirst({ + where: { + OR: [ + { email: normalizedEmail }, + { username }, + ], + }, + select: { email: true, username: true }, + }); + + if (existingUser?.email === normalizedEmail) { + return reply.status(409).send({ error: 'Email already registered' }); + } + + if (existingUser?.username === username) { + return reply.status(409).send({ error: 'Username already taken' }); + } + + try { + const user = await app.prisma.user.create({ + data: { + email: normalizedEmail, + username, + displayName, + passwordHash: await hashPassword(password), + isActive: true, + lastSignInAt: new Date(), + }, + select: { + id: true, + email: true, + username: true, + displayName: true, + }, + }); + const tokens = await issueAuthTokens(app, request, reply, user); + + return reply.status(201).send({ + user: publicUser(user), + ...tokens, + }); + } catch (error) { + if (isUniqueConstraintError(error)) { + return reply.status(409).send({ error: 'Email or username already exists' }); + } + + app.log.error({ error }, 'Registration failed'); + return reply.status(500).send({ error: 'Registration failed' }); + } + }); + + app.post('/login', async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = loginSchema.safeParse(request.body); + if (!parsed.success) { + return validationError(reply, parsed.error); + } + + const user = await app.prisma.user.findUnique({ + where: { email: parsed.data.email.toLowerCase() }, + select: { + id: true, + email: true, + username: true, + displayName: true, + passwordHash: true, + }, + }); + + if (!user?.passwordHash || !(await verifyPassword(parsed.data.password, user.passwordHash))) { + return reply.status(401).send({ error: 'Invalid email or password' }); + } + + await app.prisma.user.update({ + where: { id: user.id }, + data: { + lastSignInAt: new Date(), + isActive: true, + }, + }); + + const tokens = await issueAuthTokens(app, request, reply, user); + + return reply.status(200).send({ + user: publicUser(user), + ...tokens, + }); + }); + // Developer login bypass (development only) if (process.env.NODE_ENV !== 'production') { app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => { @@ -706,4 +888,3 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(200).send({ message: 'Logged out' }); }); } - diff --git a/apps/backend/src/utils/password.ts b/apps/backend/src/utils/password.ts new file mode 100644 index 00000000..78f2fffd --- /dev/null +++ b/apps/backend/src/utils/password.ts @@ -0,0 +1,29 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; + +const scryptAsync = promisify(scrypt); +const KEY_LENGTH = 64; + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const derivedKey = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer; + + return `scrypt:${salt}:${derivedKey.toString('hex')}`; +} + +export async function verifyPassword(password: string, storedHash: string): Promise { + const [algorithm, salt, key] = storedHash.split(':'); + + if (algorithm !== 'scrypt' || !salt || !key) { + return false; + } + + const storedKey = Buffer.from(key, 'hex'); + const derivedKey = (await scryptAsync(password, salt, storedKey.length)) as Buffer; + + if (storedKey.length !== derivedKey.length) { + return false; + } + + return timingSafeEqual(storedKey, derivedKey); +}