import type { NextResponse } from "next/server" import { decodeJwtPayload } from "@/lib/auth/jwt-claims" import { oidcServerFetchHeaders } from "@/lib/auth/oidc-config" /** Ultimail session lifetime — independent of short-lived OIDC access tokens. */ export const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 365 export const SESSION_COOKIE_NAMES = { session: "ulti_session", accessToken: "ulti_access_token", refreshToken: "ulti_refresh_token", expiresAt: "ulti_expires_at", } as const export type TokenResponse = { access_token: string refresh_token?: string expires_in?: number id_token?: string token_type?: string } function sessionCookieSecure(): boolean { if (process.env.COOKIE_SECURE === "true") return true if (process.env.COOKIE_SECURE === "false") return false const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "" try { return new URL(appUrl).protocol === "https:" } catch { return false } } export function sessionCookieOptions() { return { httpOnly: true, sameSite: "lax" as const, path: "/", maxAge: SESSION_MAX_AGE_SEC, secure: sessionCookieSecure(), } } export function computeExpiresAt(expiresIn: number): number { return Date.now() + expiresIn * 1000 } export function isIdTokenJwtValid(accessToken: string | undefined): boolean { if (!accessToken) return false const claims = decodeJwtPayload(accessToken) const exp = claims?.exp if (typeof exp !== "number") return false return Date.now() < exp * 1000 } export function isAccessTokenValid( accessToken: string | undefined, expiresAtRaw: string | undefined ): boolean { if (!accessToken) return false const expiresAt = Number(expiresAtRaw) if (Number.isFinite(expiresAt) && Date.now() < expiresAt) return true return isIdTokenJwtValid(accessToken) } type OidcTokenConfig = { tokenEndpoint: string clientId: string clientSecret: string } export async function exchangeRefreshToken( refreshToken: string, cfg: OidcTokenConfig ): Promise { const body = new URLSearchParams({ grant_type: "refresh_token", client_id: cfg.clientId, client_secret: cfg.clientSecret, refresh_token: refreshToken, }) const res = await fetch(cfg.tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", ...oidcServerFetchHeaders(), }, body, }) if (!res.ok) { throw new Error(`refresh_failed:${res.status}`) } return (await res.json()) as TokenResponse } export function resolveBearerToken(tokens: TokenResponse): string { if (!tokens.id_token) { throw new Error("no_id_token_in_response") } return tokens.id_token } export function applySessionCookies( response: NextResponse, tokens: TokenResponse, bearer?: string ) { const token = bearer ?? resolveBearerToken(tokens) const expiresIn = tokens.expires_in ?? 3600 const opts = sessionCookieOptions() response.cookies.set(SESSION_COOKIE_NAMES.session, "1", opts) response.cookies.set(SESSION_COOKIE_NAMES.accessToken, token, opts) if (tokens.refresh_token) { response.cookies.set( SESSION_COOKIE_NAMES.refreshToken, tokens.refresh_token, opts ) } response.cookies.set( SESSION_COOKIE_NAMES.expiresAt, String(computeExpiresAt(expiresIn)), opts ) }