Browse Source

fix: use cryptographically secure tokens for one-time links

pull/2561/head
Djordje Kuzmanovic 4 months ago
parent
commit
1a05428ecd
  1. 1
      src/package.json
  2. 3
      src/pnpm-lock.yaml
  3. 6
      src/server/database/repositories/oneTimeLink/service.ts
  4. 9
      src/server/database/repositories/oneTimeLink/token.ts
  5. 19
      src/test/unit/oneTimeLink.spec.ts
  6. 58
      src/test/unit/oneTimeLinkService.spec.ts

1
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",

3
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

6
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 });

9
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');
}

19
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);
});
});

58
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<ReturnType<typeof createTestDb>>;
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);
});
});
Loading…
Cancel
Save