feat(frontend): add Authelia OIDC session flow
This commit is contained in:
parent
803bc3b4cd
commit
cd8e39939a
6 changed files with 784 additions and 3 deletions
8
frontend/src/app.d.ts
vendored
8
frontend/src/app.d.ts
vendored
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
};
|
||||
|
|
|
|||
695
frontend/src/lib/server/auth.ts
Normal file
695
frontend/src/lib/server/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
frontend/src/routes/auth/callback/+server.ts
Normal file
30
frontend/src/routes/auth/callback/+server.ts
Normal 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);
|
||||
};
|
||||
8
frontend/src/routes/auth/login/+server.ts
Normal file
8
frontend/src/routes/auth/login/+server.ts
Normal 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());
|
||||
};
|
||||
10
frontend/src/routes/auth/logout/+server.ts
Normal file
10
frontend/src/routes/auth/logout/+server.ts
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue