import { randomUUID } from 'node:crypto'; import { tryGetHttpRequestContext } from '@adapters/http/RequestContext'; import type { AuthenticatedUser } from '@core/identity/application/ports/IdentityProviderPort'; import type { AuthSession, IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; import type { Logger } from '@core/shared/domain/Logger'; const COOKIE_NAME = 'gp_session'; const SESSION_TTL_MS = 3600 * 1000; // 1 hour const REMEMBER_ME_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days type StoredSession = AuthSession; function readCookieHeader(headerValue: string | undefined): Record { if (!headerValue) return {}; const pairs = headerValue.split(';'); const record: Record = {}; for (const pair of pairs) { const trimmed = pair.trim(); if (!trimmed) continue; const eq = trimmed.indexOf('='); if (eq === -1) continue; const name = trimmed.slice(0, eq).trim(); const value = trimmed.slice(eq + 1).trim(); if (!name) continue; record[name] = decodeURIComponent(value); } return record; } function buildSetCookieHeader(options: { name: string; value: string; maxAgeSeconds: number; httpOnly: boolean; sameSite: 'Lax' | 'Strict' | 'None'; secure: boolean; path?: string; }): string { const parts: string[] = []; parts.push(`${options.name}=${encodeURIComponent(options.value)}`); parts.push(`Max-Age=${options.maxAgeSeconds}`); parts.push(`Path=${options.path ?? '/'}`); if (options.httpOnly) parts.push('HttpOnly'); parts.push(`SameSite=${options.sameSite}`); if (options.secure) parts.push('Secure'); return parts.join('; '); } function appendSetCookieHeader(existing: string | string[] | undefined, next: string): string[] { if (!existing) return [next]; if (Array.isArray(existing)) return [...existing, next]; return [existing, next]; } export class CookieIdentitySessionAdapter implements IdentitySessionPort { private readonly sessionsByToken = new Map(); constructor(private readonly logger: Logger) { this.logger.info('[CookieIdentitySessionAdapter] initialized (in-memory cookie sessions).'); } async getCurrentSession(): Promise { const ctx = tryGetHttpRequestContext(); if (!ctx) { // Called outside HTTP request (e.g. some unit tests). Behave as no session. return null; } const cookies = readCookieHeader(ctx.req.headers.cookie); const token = cookies[COOKIE_NAME]; if (!token) return null; const session = this.sessionsByToken.get(token) ?? null; if (!session) return null; const now = Date.now(); if (session.expiresAt <= now) { this.sessionsByToken.delete(token); return null; } return session; } async createSession(user: AuthenticatedUser, options?: { rememberMe?: boolean }): Promise { const issuedAt = Date.now(); const ttlMs = options?.rememberMe ? REMEMBER_ME_TTL_MS : SESSION_TTL_MS; const expiresAt = issuedAt + ttlMs; const token = `gp_${randomUUID()}`; const session: AuthSession = { user, issuedAt, expiresAt, token, }; this.sessionsByToken.set(token, session); const ctx = tryGetHttpRequestContext(); if (ctx) { const setCookie = buildSetCookieHeader({ name: COOKIE_NAME, value: token, maxAgeSeconds: Math.floor(ttlMs / 1000), httpOnly: true, sameSite: 'Lax', secure: false, }); const existing = ctx.res.getHeader('Set-Cookie'); ctx.res.setHeader('Set-Cookie', appendSetCookieHeader(existing as any, setCookie)); } return session; } async clearSession(): Promise { const ctx = tryGetHttpRequestContext(); if (ctx) { const cookies = readCookieHeader(ctx.req.headers.cookie); const token = cookies[COOKIE_NAME]; if (token) { this.sessionsByToken.delete(token); } const setCookie = buildSetCookieHeader({ name: COOKIE_NAME, value: '', maxAgeSeconds: 0, httpOnly: true, sameSite: 'Lax', secure: false, }); const existing = ctx.res.getHeader('Set-Cookie'); ctx.res.setHeader('Set-Cookie', appendSetCookieHeader(existing as any, setCookie)); return; } // No request context: nothing to clear from cookie; just clear all in-memory sessions. this.sessionsByToken.clear(); } }