83 lines
2.5 KiB
TypeScript
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();
|
|
}
|