Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Updated login and signup components to utilize AuthCard for better user experience during redirection. - Introduced AuthentikEmbedDialog for seamless integration of Authentik's identity portal within the application. - Enhanced password recovery and signup flows with dynamic theme handling and improved loading states. - Refactored existing components to streamline authentication processes and improve maintainability.
141 lines
3.6 KiB
TypeScript
141 lines
3.6 KiB
TypeScript
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 resolveSessionExpiresAt(
|
|
accessToken: string | undefined,
|
|
expiresAtRaw: string | undefined
|
|
): number {
|
|
const expiresAt = Number(expiresAtRaw)
|
|
if (Number.isFinite(expiresAt)) return expiresAt
|
|
|
|
const claims = accessToken ? decodeJwtPayload(accessToken) : null
|
|
const exp = claims?.exp
|
|
if (typeof exp === "number") return exp * 1000
|
|
|
|
return computeExpiresAt(3600)
|
|
}
|
|
|
|
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<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",
|
|
...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
|
|
)
|
|
}
|