103 lines
2.6 KiB
TypeScript
103 lines
2.6 KiB
TypeScript
import type { NextResponse } from "next/server"
|
|
|
|
/** 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
|
|
}
|
|
|
|
export function sessionCookieOptions() {
|
|
return {
|
|
httpOnly: true,
|
|
sameSite: "lax" as const,
|
|
path: "/",
|
|
maxAge: SESSION_MAX_AGE_SEC,
|
|
secure: process.env.NODE_ENV === "production",
|
|
}
|
|
}
|
|
|
|
export function computeExpiresAt(expiresIn: number): number {
|
|
return Date.now() + expiresIn * 1000
|
|
}
|
|
|
|
export function isAccessTokenValid(
|
|
accessToken: string | undefined,
|
|
expiresAtRaw: string | undefined
|
|
): boolean {
|
|
if (!accessToken || !expiresAtRaw) return false
|
|
const expiresAt = Number(expiresAtRaw)
|
|
return Number.isFinite(expiresAt) && Date.now() < expiresAt
|
|
}
|
|
|
|
type OidcTokenConfig = {
|
|
tokenEndpoint: string
|
|
clientId: string
|
|
clientSecret: string
|
|
}
|
|
|
|
export async function exchangeRefreshToken(
|
|
refreshToken: string,
|
|
cfg: OidcTokenConfig
|
|
): Promise<TokenResponse> {
|
|
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" },
|
|
body,
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`refresh_failed:${res.status}`)
|
|
}
|
|
return (await res.json()) as TokenResponse
|
|
}
|
|
|
|
export function resolveBearerToken(tokens: TokenResponse): string {
|
|
const bearer = tokens.id_token ?? tokens.access_token
|
|
if (!bearer) {
|
|
throw new Error("no_token_in_response")
|
|
}
|
|
return bearer
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|