Skip to content
File

Blob: src/worker/auth/sessions.ts

typescript103 lines
1import { eg, type TypeFromCodec } from "@cloudflare/util-en-garde";
2 
3import { IsoDateTime, UserId } from "@/contracts";
4import { generateOpaqueToken } from "@/worker/services";
5 
6const SessionRecord = eg.exactStrict(
7 eg.object({
8 userId: UserId,
9 issuedAt: IsoDateTime,
10 expiresAt: IsoDateTime,
11 version: eg.number,
12 }),
13);
14 
15export type SessionRecord = TypeFromCodec<typeof SessionRecord>;
16 
17const sessionKey = (sessionId: string) => `sess:${sessionId}`;
18 
19export const createSession = async (
20 env: Env,
21 userId: string,
22 now = new Date(),
23): Promise<{ sessionId: string; record: SessionRecord }> => {
24 const ttlSeconds = Number(env.AUTH_SESSION_TTL_SECONDS);
25 const sessionId = generateOpaqueToken(32);
26 const expiresAt = new Date(now.getTime() + ttlSeconds * 1000);
27 const record = SessionRecord.assertDecode({
28 userId,
29 issuedAt: now.toISOString(),
30 expiresAt: expiresAt.toISOString(),
31 version: Number(env.SESSION_VERSION),
32 });
33 
34 await env.SESSIONS.put(sessionKey(sessionId), JSON.stringify(record), {
35 expirationTtl: ttlSeconds,
36 });
37 
38 return { sessionId, record };
39};
40 
41export const readSession = async (env: Env, sessionId: string): Promise<SessionRecord | null> => {
42 const rawValue = await env.SESSIONS.get(sessionKey(sessionId));
43 
44 if (!rawValue) {
45 return null;
46 }
47 
48 let record: SessionRecord;
49 
50 try {
51 record = SessionRecord.assertDecode(JSON.parse(rawValue) as unknown);
52 } catch {
53 await env.SESSIONS.delete(sessionKey(sessionId));
54 return null;
55 }
56 
57 const expiresAtMs = Date.parse(record.expiresAt);
58 if (Number.isNaN(expiresAtMs) || expiresAtMs <= Date.now()) {
59 await env.SESSIONS.delete(sessionKey(sessionId));
60 return null;
61 }
62 
63 return record;
64};
65 
66export const maybeRefreshSession = async (
67 env: Env,
68 sessionId: string,
69 record: SessionRecord,
70 now = new Date(),
71): Promise<SessionRecord> => {
72 const refreshThresholdMs = Number(env.AUTH_SESSION_REFRESH_THRESHOLD_SECONDS) * 1000;
73 const expiresAtMs = Date.parse(record.expiresAt);
74 
75 if (Number.isNaN(expiresAtMs)) {
76 throw new Error("Session record has an invalid expiresAt timestamp.");
77 }
78 
79 const remainingMs = expiresAtMs - now.getTime();
80 
81 if (remainingMs >= refreshThresholdMs) {
82 return record;
83 }
84 
85 const ttlSeconds = Number(env.AUTH_SESSION_TTL_SECONDS);
86 const refreshedRecord = SessionRecord.assertDecode({
87 userId: record.userId,
88 issuedAt: record.issuedAt,
89 expiresAt: new Date(now.getTime() + ttlSeconds * 1000).toISOString(),
90 version: record.version + 1,
91 });
92 
93 await env.SESSIONS.put(sessionKey(sessionId), JSON.stringify(refreshedRecord), {
94 expirationTtl: ttlSeconds,
95 });
96 
97 return refreshedRecord;
98};
99 
100export const deleteSession = async (env: Env, sessionId: string): Promise<void> => {
101 await env.SESSIONS.delete(sessionKey(sessionId));
102};