From cd8e39939a5491d91118b9080b240bdaf3a79fc2 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 12 Mar 2026 15:40:55 +0100 Subject: [PATCH] feat(frontend): add Authelia OIDC session flow --- frontend/src/app.d.ts | 8 +- frontend/src/hooks.server.ts | 36 +- frontend/src/lib/server/auth.ts | 695 +++++++++++++++++++ frontend/src/routes/auth/callback/+server.ts | 30 + frontend/src/routes/auth/login/+server.ts | 8 + frontend/src/routes/auth/logout/+server.ts | 10 + 6 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/server/auth.ts create mode 100644 frontend/src/routes/auth/callback/+server.ts create mode 100644 frontend/src/routes/auth/login/+server.ts create mode 100644 frontend/src/routes/auth/logout/+server.ts diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 520c421..33ab713 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,9 +1,15 @@ +import type { AppSession } from "$lib/server/auth"; +import type { AuthUserPublic } from "$lib/api/generated/types.gen"; + // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + session: AppSession | null; + user: AuthUserPublic | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index cb5906d..1983535 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,6 +1,38 @@ import { paraglideMiddleware } from "$lib/paraglide/server.js"; -import type { Handle } from "@sveltejs/kit"; +import { + buildLoginPath, + getRequestSession, + isBackendRequest, + isProtectedPath, + loadAuthenticatedSession, +} from "$lib/server/auth"; +import type { Handle, HandleFetch } from "@sveltejs/kit"; +import { redirect } from "@sveltejs/kit"; export const handle: Handle = async ({ event, resolve }) => { - return paraglideMiddleware(event.request, () => resolve(event)); + return paraglideMiddleware(event.request, async () => { + const session = await loadAuthenticatedSession(event); + event.locals.session = session; + event.locals.user = session?.user ?? null; + + if (!session && isProtectedPath(event.url.pathname)) { + throw redirect(303, buildLoginPath(event.url)); + } + + return resolve(event); + }); +}; + +export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { + const session = getRequestSession(event); + if (!session || !isBackendRequest(new URL(request.url), event)) { + return fetch(request); + } + + const headers = new Headers(request.headers); + if (!headers.has("authorization")) { + headers.set("authorization", `Bearer ${session.accessToken}`); + } + + return fetch(new Request(request, { headers })); }; diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts new file mode 100644 index 0000000..41f4154 --- /dev/null +++ b/frontend/src/lib/server/auth.ts @@ -0,0 +1,695 @@ +import { dev } from "$app/environment"; +import { env as privateEnv } from "$env/dynamic/private"; +import { env as publicEnv } from "$env/dynamic/public"; +import type { Cookies, RequestEvent } from "@sveltejs/kit"; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + timingSafeEqual, +} from "node:crypto"; +import type { + AuthIdentityPublic, + AuthProfilePublic, + AuthSessionResponse, + AuthUserPublic, +} from "$lib/api/generated/types.gen"; + +const AUTH_COOKIE_NAME = "innercontext_session"; +const LOGIN_COOKIE_NAME = "innercontext_login"; +const DISCOVERY_PATH = "/.well-known/openid-configuration"; +const AUTH_FLOW_TTL_SECONDS = 600; +const SESSION_REFRESH_WINDOW_SECONDS = 60; +const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; +const DEFAULT_API_BASE = "http://localhost:8000"; +const DEFAULT_SCOPES = "openid profile email groups offline_access"; +const CIPHER_ALGORITHM = "aes-256-gcm"; +const CURRENT_VERSION = 1; + +type SameSite = "lax" | "strict" | "none"; + +interface AuthConfig { + issuer: string; + clientId: string; + clientSecret: string | null; + discoveryUrl: string; + scopes: string; + sessionSecret: string; +} + +interface DiscoveryDocument { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + end_session_endpoint?: string; +} + +interface TokenResponse { + access_token: string; + expires_in?: number; + refresh_token?: string; + refresh_token_expires_in?: number; + refresh_expires_in?: number; + token_type?: string; + scope?: string; + id_token?: string; +} + +interface UserInfoClaims { + iss?: string; + sub: string; + email?: string | null; + name?: string | null; + preferred_username?: string | null; + groups?: string[]; +} + +interface LoginFlowState { + state: string; + codeVerifier: string; + returnTo: string; +} + +export interface AppSession { + accessToken: string; + refreshToken: string | null; + tokenType: string; + scope: string | null; + expiresAt: number; + refreshExpiresAt: number | null; + user: AuthUserPublic; + identity: AuthIdentityPublic; + profile: AuthProfilePublic | null; +} + +interface SerializedPayload { + v: number; + data: T; +} + +let cachedDiscovery: Promise | null = null; + +function getAuthConfig(): AuthConfig { + const issuer = requiredEnv("OIDC_ISSUER"); + + return { + issuer, + clientId: requiredEnv("OIDC_CLIENT_ID"), + clientSecret: optionalEnv("OIDC_CLIENT_SECRET"), + discoveryUrl: + optionalEnv("OIDC_DISCOVERY_URL") ?? + `${issuer.replace(/\/+$/, "")}${DISCOVERY_PATH}`, + scopes: optionalEnv("OIDC_SCOPES") ?? DEFAULT_SCOPES, + sessionSecret: requiredEnv("SESSION_SECRET"), + }; +} + +function requiredEnv(name: string): string { + const value = privateEnv[name]?.trim(); + if (!value) { + throw new Error(`Missing required auth environment variable: ${name}`); + } + return value; +} + +function optionalEnv(name: string): string | null { + const value = privateEnv[name]?.trim(); + return value ? value : null; +} + +function getApiBase(): string { + return publicEnv.PUBLIC_API_BASE?.trim() || DEFAULT_API_BASE; +} + +function cookieOptions(maxAge: number) { + return { + path: "/", + httpOnly: true, + sameSite: "lax" as SameSite, + secure: !dev, + maxAge, + }; +} + +function getSecretKey(): Buffer { + const configured = getAuthConfig().sessionSecret; + + if (/^[0-9a-fA-F]{64}$/.test(configured)) { + return Buffer.from(configured, "hex"); + } + + if (/^[A-Za-z0-9_-]{43,44}$/.test(configured)) { + const decoded = Buffer.from(configured, "base64url"); + if (decoded.length === 32) { + return decoded; + } + } + + const utf8 = Buffer.from(configured, "utf8"); + if (utf8.length >= 32) { + return createHash("sha256").update(utf8).digest(); + } + + throw new Error( + "SESSION_SECRET must contain at least 32 bytes or encode exactly 32 bytes", + ); +} + +function encryptValue(value: T): string { + const key = getSecretKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(CIPHER_ALGORITHM, key, iv); + const plaintext = Buffer.from( + JSON.stringify({ + v: CURRENT_VERSION, + data: value, + } satisfies SerializedPayload), + "utf8", + ); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([ + Buffer.from([CURRENT_VERSION]), + iv, + authTag, + ciphertext, + ]).toString("base64url"); +} + +function decryptValue(value: string): T | null { + try { + const payload = Buffer.from(value, "base64url"); + if (payload.length < 29 || payload[0] !== CURRENT_VERSION) { + return null; + } + + const iv = payload.subarray(1, 13); + const authTag = payload.subarray(13, 29); + const ciphertext = payload.subarray(29); + const decipher = createDecipheriv(CIPHER_ALGORITHM, getSecretKey(), iv); + decipher.setAuthTag(authTag); + + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString("utf8"); + const parsed = JSON.parse(plaintext) as SerializedPayload; + if (parsed.v !== CURRENT_VERSION) { + return null; + } + return parsed.data; + } catch { + return null; + } +} + +function nowInSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function getSessionCookieMaxAge(session: AppSession): number { + const expiresAt = + session.refreshExpiresAt ?? + (session.refreshToken + ? nowInSeconds() + DEFAULT_SESSION_MAX_AGE_SECONDS + : session.expiresAt); + return Math.max(expiresAt - nowInSeconds(), 0); +} + +function sanitizeReturnTo(value: string | null | undefined): string { + if (!value) { + return "/"; + } + + if (!value.startsWith("/") || value.startsWith("//")) { + return "/"; + } + + if (value.startsWith("/auth")) { + return "/"; + } + + return value; +} + +function normalizeGroups(value: unknown): string[] | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.map((entry) => String(entry)); + } + return [String(value)]; +} + +function decodeJwtExpiry(token: string): number | null { + try { + const [, payload] = token.split("."); + if (!payload) { + return null; + } + const decoded = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as { + exp?: unknown; + }; + return typeof decoded.exp === "number" ? decoded.exp : null; + } catch { + return null; + } +} + +function sessionNeedsRefresh(session: AppSession): boolean { + return session.expiresAt - nowInSeconds() <= SESSION_REFRESH_WINDOW_SECONDS; +} + +function stateMatches(expected: string, actual: string): boolean { + const expectedBuffer = Buffer.from(expected, "utf8"); + const actualBuffer = Buffer.from(actual, "utf8"); + if (expectedBuffer.length !== actualBuffer.length) { + return false; + } + return timingSafeEqual(expectedBuffer, actualBuffer); +} + +function getRedirectUri(url: URL): string { + return new URL("/auth/callback", url.origin).toString(); +} + +function getLogoutReturnUri(url: URL): string { + return new URL("/", url.origin).toString(); +} + +async function parseJsonResponse(response: Response): Promise { + const text = await response.text(); + if (!text) { + return null; + } + return JSON.parse(text) as unknown; +} + +async function requestJson(input: string, init?: RequestInit): Promise { + const response = await fetch(input, init); + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(text || response.statusText); + } + return (await parseJsonResponse(response)) as T; +} + +async function getDiscoveryDocument(): Promise { + cachedDiscovery ??= requestJson( + getAuthConfig().discoveryUrl, + ).catch((error) => { + cachedDiscovery = null; + throw error; + }); + return cachedDiscovery; +} + +function buildAuthorizationUrl( + url: URL, + state: string, + codeChallenge: string, +): Promise { + return getDiscoveryDocument().then((discovery) => { + const authUrl = new URL(discovery.authorization_endpoint); + const config = getAuthConfig(); + authUrl.searchParams.set("client_id", config.clientId); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("redirect_uri", getRedirectUri(url)); + authUrl.searchParams.set("scope", config.scopes); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + return authUrl; + }); +} + +function createPkceVerifier(): string { + return randomBytes(32).toString("base64url"); +} + +function createPkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function createState(): string { + return randomBytes(24).toString("base64url"); +} + +function buildTokenRequestBody( + values: Record, +): URLSearchParams { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + body.set(key, value); + } + + const config = getAuthConfig(); + body.set("client_id", config.clientId); + if (config.clientSecret) { + body.set("client_secret", config.clientSecret); + } + + return body; +} + +async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string, +): Promise { + const discovery = await getDiscoveryDocument(); + return requestJson(discovery.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: buildTokenRequestBody({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); +} + +async function refreshToken(refreshToken: string): Promise { + const discovery = await getDiscoveryDocument(); + return requestJson(discovery.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: buildTokenRequestBody({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); +} + +async function fetchUserInfo(accessToken: string): Promise { + const discovery = await getDiscoveryDocument(); + const payload = await requestJson>( + discovery.userinfo_endpoint, + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + + const subject = payload.sub; + if (typeof subject !== "string" || !subject) { + throw new Error("OIDC userinfo payload is missing sub"); + } + + return { + iss: typeof payload.iss === "string" ? payload.iss : undefined, + sub: subject, + email: typeof payload.email === "string" ? payload.email : null, + name: typeof payload.name === "string" ? payload.name : null, + preferred_username: + typeof payload.preferred_username === "string" + ? payload.preferred_username + : null, + groups: normalizeGroups(payload.groups), + }; +} + +async function syncBackendSession( + accessToken: string, + claims: UserInfoClaims, +): Promise { + return requestJson(`${getApiBase()}/auth/session/sync`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + iss: claims.iss ?? getAuthConfig().issuer, + sub: claims.sub, + email: claims.email, + name: claims.name, + preferred_username: claims.preferred_username, + groups: claims.groups, + }), + }); +} + +function buildAppSession( + tokenResponse: TokenResponse, + backendSession: AuthSessionResponse, + previousSession?: AppSession, +): AppSession { + const currentTime = nowInSeconds(); + const accessToken = tokenResponse.access_token; + const accessTokenExpiry = + typeof tokenResponse.expires_in === "number" + ? currentTime + tokenResponse.expires_in + : decodeJwtExpiry(accessToken); + + if (!accessTokenExpiry) { + throw new Error( + "OIDC token response is missing access token expiry information", + ); + } + + const refreshExpiresIn = + typeof tokenResponse.refresh_token_expires_in === "number" + ? tokenResponse.refresh_token_expires_in + : typeof tokenResponse.refresh_expires_in === "number" + ? tokenResponse.refresh_expires_in + : null; + + return { + accessToken, + refreshToken: + tokenResponse.refresh_token ?? previousSession?.refreshToken ?? null, + tokenType: + tokenResponse.token_type ?? previousSession?.tokenType ?? "Bearer", + scope: tokenResponse.scope ?? previousSession?.scope ?? null, + expiresAt: accessTokenExpiry, + refreshExpiresAt: + refreshExpiresIn !== null + ? currentTime + refreshExpiresIn + : (previousSession?.refreshExpiresAt ?? null), + user: backendSession.user, + identity: backendSession.identity, + profile: backendSession.profile ?? null, + }; +} + +export function loadSession(cookies: Cookies): AppSession | null { + const raw = cookies.get(AUTH_COOKIE_NAME); + if (!raw) { + return null; + } + return decryptValue(raw); +} + +export function setSessionCookie(cookies: Cookies, session: AppSession): void { + cookies.set( + AUTH_COOKIE_NAME, + encryptValue(session), + cookieOptions(Math.max(getSessionCookieMaxAge(session), 1)), + ); +} + +export function clearSessionCookie(cookies: Cookies): void { + cookies.delete(AUTH_COOKIE_NAME, { path: "/" }); +} + +function loadLoginFlow(cookies: Cookies): LoginFlowState | null { + const raw = cookies.get(LOGIN_COOKIE_NAME); + if (!raw) { + return null; + } + return decryptValue(raw); +} + +function setLoginFlowCookie(cookies: Cookies, flow: LoginFlowState): void { + cookies.set( + LOGIN_COOKIE_NAME, + encryptValue(flow), + cookieOptions(AUTH_FLOW_TTL_SECONDS), + ); +} + +export function clearLoginFlowCookie(cookies: Cookies): void { + cookies.delete(LOGIN_COOKIE_NAME, { path: "/" }); +} + +export function clearAuthCookies(cookies: Cookies): void { + clearSessionCookie(cookies); + clearLoginFlowCookie(cookies); +} + +export async function createLoginRedirect(event: RequestEvent): Promise { + const returnTo = sanitizeReturnTo(event.url.searchParams.get("returnTo")); + const codeVerifier = createPkceVerifier(); + const state = createState(); + + setLoginFlowCookie(event.cookies, { + state, + codeVerifier, + returnTo, + }); + + return buildAuthorizationUrl( + event.url, + state, + createPkceChallenge(codeVerifier), + ); +} + +export async function finishLogin(event: RequestEvent): Promise { + const flow = loadLoginFlow(event.cookies); + clearLoginFlowCookie(event.cookies); + + if (!flow) { + throw new Error("Missing or invalid login flow state"); + } + + const state = event.url.searchParams.get("state"); + const code = event.url.searchParams.get("code"); + + if (!state || !stateMatches(flow.state, state)) { + throw new Error("OIDC callback state check failed"); + } + + if (!code) { + throw new Error("OIDC callback is missing authorization code"); + } + + const tokenResponse = await exchangeCodeForTokens( + code, + flow.codeVerifier, + getRedirectUri(event.url), + ); + const userInfo = await fetchUserInfo(tokenResponse.access_token); + const backendSession = await syncBackendSession( + tokenResponse.access_token, + userInfo, + ); + const session = buildAppSession(tokenResponse, backendSession); + setSessionCookie(event.cookies, session); + + return flow.returnTo; +} + +export async function refreshSession(session: AppSession): Promise { + if (!session.refreshToken) { + throw new Error("App session cannot be refreshed without a refresh token"); + } + + if ( + session.refreshExpiresAt !== null && + session.refreshExpiresAt <= nowInSeconds() + ) { + throw new Error("Refresh token has expired"); + } + + const tokenResponse = await refreshToken(session.refreshToken); + const userInfo = await fetchUserInfo(tokenResponse.access_token); + const backendSession = await syncBackendSession( + tokenResponse.access_token, + userInfo, + ); + return buildAppSession(tokenResponse, backendSession, session); +} + +export async function loadAuthenticatedSession( + event: RequestEvent, +): Promise { + const session = loadSession(event.cookies); + if (!session && event.cookies.get(AUTH_COOKIE_NAME)) { + clearAuthCookies(event.cookies); + return null; + } + + if (!session) { + return null; + } + + if (!sessionNeedsRefresh(session)) { + return session; + } + + try { + const refreshed = await refreshSession(session); + setSessionCookie(event.cookies, refreshed); + return refreshed; + } catch { + clearAuthCookies(event.cookies); + return null; + } +} + +export function isProtectedPath(pathname: string): boolean { + if (pathname.startsWith("/auth")) { + return false; + } + if (pathname.startsWith("/_app") || pathname.startsWith("/api")) { + return false; + } + if ( + pathname === "/favicon.ico" || + pathname === "/robots.txt" || + pathname === "/manifest.webmanifest" + ) { + return false; + } + return !/\.[a-zA-Z0-9]+$/.test(pathname); +} + +export function buildLoginPath(url: URL): string { + const loginUrl = new URL("/auth/login", url.origin); + const returnTo = sanitizeReturnTo(`${url.pathname}${url.search}`); + if (returnTo !== "/") { + loginUrl.searchParams.set("returnTo", returnTo); + } + return `${loginUrl.pathname}${loginUrl.search}`; +} + +export async function buildLogoutUrl(url: URL): Promise { + let discovery: DiscoveryDocument; + try { + discovery = await getDiscoveryDocument(); + } catch { + return "/auth/login"; + } + + if (!discovery.end_session_endpoint) { + return "/auth/login"; + } + + const logoutUrl = new URL(discovery.end_session_endpoint); + logoutUrl.searchParams.set("client_id", getAuthConfig().clientId); + logoutUrl.searchParams.set( + "post_logout_redirect_uri", + getLogoutReturnUri(url), + ); + return logoutUrl.toString(); +} + +export function getRequestSession(event: RequestEvent): AppSession | null { + return event.locals.session ?? null; +} + +export function isBackendRequest(url: URL, event: RequestEvent): boolean { + if (url.origin === event.url.origin && url.pathname.startsWith("/api")) { + return true; + } + + const apiBase = getApiBase(); + try { + const backendUrl = new URL(apiBase); + return ( + url.origin === backendUrl.origin && + url.pathname.startsWith(backendUrl.pathname) + ); + } catch { + return false; + } +} diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..88261c7 --- /dev/null +++ b/frontend/src/routes/auth/callback/+server.ts @@ -0,0 +1,30 @@ +import { clearLoginFlowCookie, finishLogin } from "$lib/server/auth"; +import { error, redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async (event) => { + const providerError = event.url.searchParams.get("error"); + if (providerError) { + clearLoginFlowCookie(event.cookies); + const providerErrorDescription = + event.url.searchParams.get("error_description"); + throw error( + 400, + providerErrorDescription + ? `${providerError}: ${providerErrorDescription}` + : `OIDC callback failed: ${providerError}`, + ); + } + + let returnTo: string; + try { + returnTo = await finishLogin(event); + } catch (cause) { + throw error( + 400, + cause instanceof Error ? cause.message : "OIDC callback failed", + ); + } + + throw redirect(303, returnTo); +}; diff --git a/frontend/src/routes/auth/login/+server.ts b/frontend/src/routes/auth/login/+server.ts new file mode 100644 index 0000000..604db3d --- /dev/null +++ b/frontend/src/routes/auth/login/+server.ts @@ -0,0 +1,8 @@ +import { createLoginRedirect } from "$lib/server/auth"; +import { redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async (event) => { + const loginUrl = await createLoginRedirect(event); + throw redirect(303, loginUrl.toString()); +}; diff --git a/frontend/src/routes/auth/logout/+server.ts b/frontend/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..94540b7 --- /dev/null +++ b/frontend/src/routes/auth/logout/+server.ts @@ -0,0 +1,10 @@ +import { buildLogoutUrl, clearAuthCookies } from "$lib/server/auth"; +import { redirect } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async (event) => { + clearAuthCookies(event.cookies); + + const logoutUrl = await buildLogoutUrl(event.url); + throw redirect(303, logoutUrl); +};