File
Blob: src/worker/auth/sessions.ts
| 1 | import { eg, type TypeFromCodec } from "@cloudflare/util-en-garde"; |
| 2 | |
| 3 | import { IsoDateTime, UserId } from "@/contracts"; |
| 4 | import { generateOpaqueToken } from "@/worker/services"; |
| 5 | |
| 6 | const SessionRecord = eg.exactStrict( |
| 7 | eg.object({ |
| 8 | userId: UserId, |
| 9 | issuedAt: IsoDateTime, |
| 10 | expiresAt: IsoDateTime, |
| 11 | version: eg.number, |
| 12 | }), |
| 13 | ); |
| 14 | |
| 15 | export type SessionRecord = TypeFromCodec<typeof SessionRecord>; |
| 16 | |
| 17 | const sessionKey = (sessionId: string) => `sess:${sessionId}`; |
| 18 | |
| 19 | export 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 | |
| 41 | export 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 | |
| 66 | export 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 | |
| 100 | export const deleteSession = async (env: Env, sessionId: string): Promise<void> => { |
| 101 | await env.SESSIONS.delete(sessionKey(sessionId)); |
| 102 | }; |