Files
gridpilot.gg/adapters/identity/session/CookieIdentitySessionAdapter.ts
2025-12-26 23:53:47 +01:00

145 lines
4.2 KiB
TypeScript

import { randomUUID } from 'node:crypto';
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/application';
import { tryGetHttpRequestContext } from '@adapters/http/RequestContext';
const COOKIE_NAME = 'gp_session';
const SESSION_TTL_MS = 3600 * 1000;
type StoredSession = AuthSession;
function readCookieHeader(headerValue: string | undefined): Record<string, string> {
if (!headerValue) return {};
const pairs = headerValue.split(';');
const record: Record<string, string> = {};
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<string, StoredSession>();
constructor(private readonly logger: Logger) {
this.logger.info('[CookieIdentitySessionAdapter] initialized (in-memory cookie sessions).');
}
async getCurrentSession(): Promise<AuthSession | null> {
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): Promise<AuthSession> {
const issuedAt = Date.now();
const expiresAt = issuedAt + SESSION_TTL_MS;
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(SESSION_TTL_MS / 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<void> {
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();
}
}