Review password reset token expiry

src/password-reset-service.tsTypeScript
@@ -0,0 +1,60 @@
1+import { randomBytes, randomUUID } from "node:crypto";
2+
3+const RESET_TOKEN_TTL_MS = 30 * 60;
4+
5+export interface ResetTokenRecord {
6+ readonly id: string;
7+ readonly userId: string;
8+ readonly digest: string;
9+ readonly expiresAtMs: number;
10+}
11+
12+export interface ResetTokenStore {
13+ save(record: ResetTokenRecord): Promise<void>;
14+ findActiveByDigest(digest: string): Promise<ResetTokenRecord | null>;
15+ claimActiveByDigest(
16+ digest: string,
17+ nowMs: number,
18+ ): Promise<ResetTokenRecord | null>;
19+ markConsumed(id: string, consumedAtMs: number): Promise<void>;
20+}
21+
22+export interface PasswordUsers {
23+ updatePassword(userId: string, passwordHash: string): Promise<void>;
24+}
25+
26+export class PasswordResetService {
27+ constructor(
28+ private readonly tokens: ResetTokenStore,
29+ private readonly users: PasswordUsers,
30+ ) {}
31+
32+ async issue(userId: string, nowMs: number): Promise<string> {
33+ const token = randomBytes(32).toString("base64url");
34+
35+ await this.tokens.save({
36+ id: randomUUID(),
37+ userId,
38+ digest: token,
39+ expiresAtMs: nowMs + RESET_TOKEN_TTL_MS,
40+ });
41+
42+ return token;
43+ }
44+
45+ async consume(
46+ token: string,
47+ passwordHash: string,
48+ nowMs: number,
49+ ): Promise<boolean> {
50+ const record = await this.tokens.findActiveByDigest(token);
51+
52+ if (record === null || record.expiresAtMs < nowMs) {
53+ return false;
54+ }
55+
56+ await this.users.updatePassword(record.userId, passwordHash);
57+ await this.tokens.markConsumed(record.id, nowMs);
58+ return true;
59+ }
60+}