From 1a05428ecd37983c8ed80d369101a0de0d1a324a Mon Sep 17 00:00:00 2001 From: Djordje Kuzmanovic Date: Wed, 25 Mar 2026 14:35:23 +0100 Subject: [PATCH] fix: use cryptographically secure tokens for one-time links --- src/package.json | 1 - src/pnpm-lock.yaml | 3 - .../repositories/oneTimeLink/service.ts | 6 +- .../repositories/oneTimeLink/token.ts | 9 +++ src/test/unit/oneTimeLink.spec.ts | 19 ++++++ src/test/unit/oneTimeLinkService.spec.ts | 58 +++++++++++++++++++ 6 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 src/server/database/repositories/oneTimeLink/token.ts create mode 100644 src/test/unit/oneTimeLink.spec.ts create mode 100644 src/test/unit/oneTimeLinkService.spec.ts diff --git a/src/package.json b/src/package.json index b932c5df..d5585dac 100644 --- a/src/package.json +++ b/src/package.json @@ -36,7 +36,6 @@ "cidr-tools": "^11.2.1", "citty": "^0.2.1", "consola": "^3.4.2", - "crc-32": "^1.2.2", "debug": "^4.4.3", "drizzle-orm": "^0.45.1", "ip-bigint": "^8.2.12", diff --git a/src/pnpm-lock.yaml b/src/pnpm-lock.yaml index 8bf49343..c5c35412 100644 --- a/src/pnpm-lock.yaml +++ b/src/pnpm-lock.yaml @@ -53,9 +53,6 @@ importers: consola: specifier: ^3.4.2 version: 3.4.2 - crc-32: - specifier: ^1.2.2 - version: 1.2.2 debug: specifier: ^4.4.3 version: 4.4.3 diff --git a/src/server/database/repositories/oneTimeLink/service.ts b/src/server/database/repositories/oneTimeLink/service.ts index 49162ac0..00c4d4f5 100644 --- a/src/server/database/repositories/oneTimeLink/service.ts +++ b/src/server/database/repositories/oneTimeLink/service.ts @@ -1,6 +1,6 @@ import { eq, sql } from 'drizzle-orm'; -import CRC32 from 'crc-32'; import { oneTimeLink } from './schema'; +import { generateOtlToken } from './token'; import type { DBType } from '#db/sqlite'; function createPreparedStatement(db: DBType) { @@ -19,6 +19,7 @@ function createPreparedStatement(db: DBType) { .onConflictDoUpdate({ target: oneTimeLink.id, set: { + oneTimeLink: sql.placeholder('oneTimeLink') as never as string, expiresAt: sql.placeholder('expiresAt') as never as string, }, }) @@ -52,8 +53,7 @@ export class OneTimeLinkService { } generate(id: ID) { - const key = `${id}-${Math.floor(Math.random() * 1000)}`; - const oneTimeLink = Math.abs(CRC32.str(key)).toString(16); + const oneTimeLink = generateOtlToken(); const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); return this.#statements.create.execute({ id, oneTimeLink, expiresAt }); diff --git a/src/server/database/repositories/oneTimeLink/token.ts b/src/server/database/repositories/oneTimeLink/token.ts new file mode 100644 index 00000000..1eed75e7 --- /dev/null +++ b/src/server/database/repositories/oneTimeLink/token.ts @@ -0,0 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +/** + * Generates a cryptographically secure one-time link token. + * Returns a 64-character lowercase hex string (256 bits of entropy). + */ +export function generateOtlToken(): string { + return randomBytes(32).toString('hex'); +} diff --git a/src/test/unit/oneTimeLink.spec.ts b/src/test/unit/oneTimeLink.spec.ts new file mode 100644 index 00000000..354c95b6 --- /dev/null +++ b/src/test/unit/oneTimeLink.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; +import { generateOtlToken } from '../../server/database/repositories/oneTimeLink/token'; + +describe('OTL token generation', () => { + test('generates a non-empty string', () => { + expect(typeof generateOtlToken()).toBe('string'); + expect(generateOtlToken().length).toBeGreaterThan(0); + }); + + test('token is a 64-character lowercase hex string', () => { + const token = generateOtlToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + test('consecutive tokens are unique', () => { + const tokens = new Set(Array.from({ length: 10 }, generateOtlToken)); + expect(tokens.size).toBe(10); + }); +}); diff --git a/src/test/unit/oneTimeLinkService.spec.ts b/src/test/unit/oneTimeLinkService.spec.ts new file mode 100644 index 00000000..6f9c904a --- /dev/null +++ b/src/test/unit/oneTimeLinkService.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, test, beforeEach } from 'vitest'; +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { sql } from 'drizzle-orm'; +import * as schema from '../../server/database/schema'; +import { OneTimeLinkService } from '../../server/database/repositories/oneTimeLink/service'; +import { oneTimeLink as otlTable } from '../../server/database/repositories/oneTimeLink/schema'; + +async function createTestDb() { + const client = createClient({ url: ':memory:' }); + const db = drizzle({ client, schema }); + // SQLite does not enforce FK constraints by default, so we only need this table. + await db.run(sql` + CREATE TABLE one_time_links_table ( + id INTEGER PRIMARY KEY, + one_time_link TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ) + `); + return db; +} + +describe('OneTimeLinkService upsert', () => { + let service: OneTimeLinkService; + let db: Awaited>; + + beforeEach(async () => { + db = await createTestDb(); + service = new OneTimeLinkService(db); + }); + + test('generates and stores a token for a new client', async () => { + await service.generate(1); + const rows = await db.select().from(otlTable); + expect(rows).toHaveLength(1); + expect(rows[0].oneTimeLink).toMatch(/^[0-9a-f]{64}$/); + }); + + test('regenerating for the same client updates both token and expiry', async () => { + await service.generate(1); + const [first] = await db.select().from(otlTable); + + // Small delay so the expiry timestamps are observably different. + await new Promise((r) => setTimeout(r, 5)); + + await service.generate(1); + const rows = await db.select().from(otlTable); + + // Still exactly one row — no duplicate inserted. + expect(rows).toHaveLength(1); + + const [second] = rows; + expect(second.oneTimeLink).not.toBe(first.oneTimeLink); + expect(second.expiresAt > first.expiresAt).toBe(true); + }); +});