File size: 2,515 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import crypto from 'crypto';

const TTL: Record<string, number> = {
  ws: 30_000,
  download: 60_000,
};

const MAX_STORE_SIZE = 10_000;

interface TokenEntry {
  userId: number;
  purpose: string;
  expiresAt: number;
  /**
   * Snapshot of the user's `password_version` at mint time, used for the
   * defence-in-depth session gate on WebSocket connects. `undefined` for
   * tokens minted without a version (legacy/other purposes), which callers
   * treat as version 0 — mirroring the JWT `pv` claim semantics.
   */
  pv?: number;
}

export interface EphemeralTokenMeta {
  /** Bind the token to the user's current password_version (session gate). */
  pv?: number;
}

const store = new Map<string, TokenEntry>();

export function createEphemeralToken(
  userId: number,
  purpose: string,
  meta?: EphemeralTokenMeta,
): string | null {
  if (store.size >= MAX_STORE_SIZE) return null;
  const token = crypto.randomBytes(32).toString('hex');
  const ttl = TTL[purpose] ?? 60_000;
  store.set(token, { userId, purpose, expiresAt: Date.now() + ttl, pv: meta?.pv });
  return token;
}

export function consumeEphemeralToken(token: string, purpose: string): number | null {
  const entry = store.get(token);
  if (!entry) return null;
  store.delete(token);
  if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null;
  return entry.userId;
}

/**
 * Like `consumeEphemeralToken`, but also returns the `password_version` the
 * token was minted with. Used by the WebSocket handshake so a token issued
 * before a password change can be rejected even within its short TTL.
 */
export function consumeEphemeralTokenWithMeta(
  token: string,
  purpose: string,
): { userId: number; pv?: number } | null {
  const entry = store.get(token);
  if (!entry) return null;
  store.delete(token);
  if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null;
  return { userId: entry.userId, pv: entry.pv };
}

let cleanupInterval: ReturnType<typeof setInterval> | null = null;

export function startTokenCleanup(): void {
  if (cleanupInterval) return;
  cleanupInterval = setInterval(() => {
    const now = Date.now();
    for (const [token, entry] of store) {
      if (now > entry.expiresAt) store.delete(token);
    }
  }, 60_000);
  // Allow process to exit even if interval is active
  if (cleanupInterval.unref) cleanupInterval.unref();
}

export function stopTokenCleanup(): void {
  if (cleanupInterval) {
    clearInterval(cleanupInterval);
    cleanupInterval = null;
  }
}