feat(frontend): add Authelia OIDC session flow

This commit is contained in:
Piotr Oleszczyk 2026-03-12 15:40:55 +01:00
parent 803bc3b4cd
commit cd8e39939a
6 changed files with 784 additions and 3 deletions

View file

@ -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 {}

View file

@ -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 }));
};

View file

@ -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<T> {
v: number;
data: T;
}
let cachedDiscovery: Promise<DiscoveryDocument> | 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<T>(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<T>),
"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<T>(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<T>;
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<unknown> {
const text = await response.text();
if (!text) {
return null;
}
return JSON.parse(text) as unknown;
}
async function requestJson<T>(input: string, init?: RequestInit): Promise<T> {
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<DiscoveryDocument> {
cachedDiscovery ??= requestJson<DiscoveryDocument>(
getAuthConfig().discoveryUrl,
).catch((error) => {
cachedDiscovery = null;
throw error;
});
return cachedDiscovery;
}
function buildAuthorizationUrl(
url: URL,
state: string,
codeChallenge: string,
): Promise<URL> {
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<string, string>,
): 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<TokenResponse> {
const discovery = await getDiscoveryDocument();
return requestJson<TokenResponse>(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<TokenResponse> {
const discovery = await getDiscoveryDocument();
return requestJson<TokenResponse>(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<UserInfoClaims> {
const discovery = await getDiscoveryDocument();
const payload = await requestJson<Record<string, unknown>>(
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<AuthSessionResponse> {
return requestJson<AuthSessionResponse>(`${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<AppSession>(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<LoginFlowState>(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<URL> {
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<string> {
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<AppSession> {
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<AppSession | null> {
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<string> {
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;
}
}

View file

@ -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);
};

View file

@ -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());
};

View file

@ -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);
};