Files
flashcards/packages/backend/src/services/auth/tokens.ts

83 lines
2.5 KiB
TypeScript

import { randomBytes, createHash } from 'node:crypto';
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { Db } from '../../db/client.js';
import { authTokens } from '../../db/schema.js';
const TOKEN_BYTES = 32;
export type Purpose = 'verify_email' | 'password_reset' | 'invite' | 'change_email';
export function generateToken(): string {
return randomBytes(TOKEN_BYTES).toString('base64url');
}
function hashToken(plaintext: string): string {
return createHash('sha256').update(plaintext).digest('hex');
}
export interface CreatedToken {
plaintext: string;
hash: string;
expiresAt: number;
}
export async function createAuthToken(
db: Db,
userId: number,
purpose: Purpose,
ttlSeconds: number,
payload?: string | null
): Promise<CreatedToken> {
const plaintext = generateToken();
const hash = hashToken(plaintext);
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds;
db.insert(authTokens).values({
userId,
tokenHash: hash,
purpose,
payload: payload ?? null,
expiresAt,
}).run();
return { plaintext, hash, expiresAt };
}
export async function findValidAuthToken(
db: Db,
plaintext: string,
purpose: Purpose
): Promise<{ id: number; userId: number; payload: string | null } | null> {
const now = Math.floor(Date.now() / 1000);
const hash = hashToken(plaintext);
const row = db.select().from(authTokens).where(
and(
eq(authTokens.tokenHash, hash),
eq(authTokens.purpose, purpose),
isNull(authTokens.usedAt),
sql`${authTokens.expiresAt} > ${now}`
)
).get();
if (!row) return null;
return { id: row.id, userId: row.userId, payload: row.payload ?? null };
}
export async function consumeAuthToken(
db: Db,
plaintext: string,
purpose: Purpose
): Promise<{ userId: number; payload: string | null } | null> {
const now = Math.floor(Date.now() / 1000);
const row = await findValidAuthToken(db, plaintext, purpose);
if (!row) return null;
const r = db.update(authTokens).set({ usedAt: now })
.where(and(eq(authTokens.id, row.id), isNull(authTokens.usedAt)))
.run();
if (r.changes === 0) return null;
return { userId: row.userId, payload: row.payload };
}
export async function invalidateTokensForUser(db: Db, userId: number, purpose: Purpose): Promise<void> {
const now = Math.floor(Date.now() / 1000);
db.update(authTokens).set({ usedAt: now })
.where(and(eq(authTokens.userId, userId), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt)))
.run();
}