import { randomBytes } from 'node:crypto' import { decodeJwtPayload, refreshTokens } from './keycloak' import type { H3Event } from 'h3' const COOKIE_NAME = 'portal_sid' const REFRESH_GUARD_MS = 30_000 // renova se restam <30s export interface UserInfo { sub: string name?: string preferred_username?: string email?: string realm_roles?: string[] [key: string]: unknown } export interface SessionData { accessToken: string refreshToken: string idToken: string accessTokenExpiresAt: number userInfo: UserInfo createdAt: number } function genSid(): string { return randomBytes(32).toString('base64url') } // ─── Operações de store ─────────────────────────────────────────────────────── export async function createSession(data: Omit): Promise { const sid = genSid() const ttl = useRuntimeConfig().sessionTtlSeconds const payload: SessionData = { ...data, createdAt: Date.now() } await useRedis().set(`sess:${sid}`, JSON.stringify(payload), 'EX', ttl) return sid } export async function readSession(sid: string | undefined): Promise { if (!sid) return null const raw = await useRedis().get(`sess:${sid}`) if (!raw) return null // sliding TTL — qualquer leitura estende a sessão const ttl = useRuntimeConfig().sessionTtlSeconds await useRedis().expire(`sess:${sid}`, ttl) try { return JSON.parse(raw) as SessionData } catch { return null } } export async function updateSession(sid: string, patch: Partial): Promise { const current = await readSession(sid) if (!current) return const next: SessionData = { ...current, ...patch } const ttl = useRuntimeConfig().sessionTtlSeconds await useRedis().set(`sess:${sid}`, JSON.stringify(next), 'EX', ttl) } export async function deleteSession(sid: string | undefined): Promise { if (!sid) return await useRedis().del(`sess:${sid}`) } // ─── Cookie helpers ─────────────────────────────────────────────────────────── export function setSessionCookie(event: H3Event, sid: string): void { setCookie(event, COOKIE_NAME, sid, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: useRuntimeConfig().sessionTtlSeconds, }) } export function readSessionCookie(event: H3Event): string | undefined { return getCookie(event, COOKIE_NAME) } export function clearSessionCookie(event: H3Event): void { deleteCookie(event, COOKIE_NAME, { path: '/' }) } // ─── Orquestração: token válido + refresh transparente ──────────────────────── export async function getValidAccessToken(sid: string): Promise { const session = await readSession(sid) if (!session) return null const now = Date.now() if (session.accessTokenExpiresAt - now > REFRESH_GUARD_MS) { return session.accessToken } try { const tokens = await refreshTokens(session.refreshToken) await updateSession(sid, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, idToken: tokens.id_token ?? session.idToken, accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000, }) return tokens.access_token } catch { await deleteSession(sid) return null } } // ─── Helper: monta UserInfo a partir do access_token ───────────────────────── export function userInfoFromAccessToken(accessToken: string): UserInfo { const claims = decodeJwtPayload(accessToken) const realmAccess = claims.realm_access as { roles?: string[] } | undefined return { sub: String(claims.sub ?? ''), name: claims.name as string | undefined, preferred_username: claims.preferred_username as string | undefined, email: claims.email as string | undefined, realm_roles: realmAccess?.roles, } }