diff --git a/frontend/.env.example b/frontend/.env.example index c77a5dd..fc2ee33 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,8 +7,14 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret AUTH_REDIRECT_URI=http://localhost:5173/auth/callback AUTH_POST_LOGOUT_URI=http://localhost:5173/ -# Secret used to sign the session cookie (any long random string) +# Secret used to derive the session cookie encryption key (any long random string) SESSION_SECRET=change-me-to-a-long-random-string +# Optional session tuning (defaults shown) +# OIDC_SCOPE=openid profile email offline_access +# SESSION_TTL_SECONDS=604800 +# SESSION_REFRESH_THRESHOLD_SECONDS=30 +# AUTH_FLOW_TTL_SECONDS=600 + # Backend API base URL (used from later parts) API_BASE_URL=http://localhost:5111 diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 27c25bf..ca76255 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -6,13 +6,12 @@ import { sessionCookieOptions, SESSION_COOKIE } from '$lib/server/auth'; - -const REFRESH_THRESHOLD_SECONDS = 30; +import { authConfig } from '$lib/server/config'; export const handle: Handle = async ({ event, resolve }) => { let session = await decodeSession(event.cookies.get(SESSION_COOKIE)); - if (session && session.expiresAt - REFRESH_THRESHOLD_SECONDS <= Math.floor(Date.now() / 1000)) { + if (session && session.expiresAt - authConfig.refreshThresholdSeconds <= Math.floor(Date.now() / 1000)) { session = await refreshSession(session); if (session) { event.cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(event.url)); diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 594ba81..0dafe1a 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,6 +1,6 @@ import * as client from 'openid-client'; import { EncryptJWT, jwtDecrypt } from 'jose'; -import { env } from '$env/dynamic/private'; +import { authConfig } from '$lib/server/config'; import type { SessionUser } from '$lib/types'; export const SESSION_COOKIE = 'mf_session'; @@ -16,40 +16,27 @@ export interface Session extends SessionUser { expiresAt: number; } -export const OIDC_SCOPE = 'openid profile email offline_access'; - let configPromise: Promise | null = null; export function getOidcConfig(): Promise { configPromise ??= (async () => { - const issuer = new URL(env.ZITADEL_ISSUER); + const issuer = new URL(authConfig.issuer); const options = issuer.protocol === 'http:' ? { execute: [client.allowInsecureRequests] } : undefined; - const secret = env.ZITADEL_CLIENT_SECRET; - const hasSecret = !!secret && !secret.startsWith('REPLACE_'); - - return hasSecret - ? client.discovery(issuer, env.ZITADEL_CLIENT_ID, secret, undefined, options) - : client.discovery(issuer, env.ZITADEL_CLIENT_ID, undefined, client.None(), options); + const secret = authConfig.clientSecret; + return secret + ? client.discovery(issuer, authConfig.clientId, secret, undefined, options) + : client.discovery(issuer, authConfig.clientId, undefined, client.None(), options); })(); return configPromise; } -export const authConfig = { - get redirectUri() { - return env.AUTH_REDIRECT_URI; - }, - get postLogoutUri() { - return env.AUTH_POST_LOGOUT_URI; - } -}; - let keyPromise: Promise | null = null; function sessionKey(): Promise { keyPromise ??= crypto.subtle - .digest('SHA-256', new TextEncoder().encode(env.SESSION_SECRET)) + .digest('SHA-256', new TextEncoder().encode(authConfig.sessionSecret)) .then((digest) => new Uint8Array(digest)); return keyPromise; } @@ -60,15 +47,16 @@ export function sessionCookieOptions(url: URL) { sameSite: 'lax' as const, secure: url.protocol === 'https:', path: '/', - maxAge: 60 * 60 * 24 * 7 + maxAge: authConfig.sessionTtlSeconds }; } export async function encodeSession(session: Session): Promise { + const now = Math.floor(Date.now() / 1000); return new EncryptJWT({ ...session }) .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) - .setIssuedAt() - .setExpirationTime('7d') + .setIssuedAt(now) + .setExpirationTime(now + authConfig.sessionTtlSeconds) .encrypt(await sessionKey()); } diff --git a/frontend/src/lib/server/config.ts b/frontend/src/lib/server/config.ts new file mode 100644 index 0000000..3be521d --- /dev/null +++ b/frontend/src/lib/server/config.ts @@ -0,0 +1,44 @@ +import { env } from '$env/dynamic/private'; + +const DAY_SECONDS = 60 * 60 * 24; +const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access'; + +function positiveInt(value: string | undefined, fallback: number): number { + if (value === undefined || value === '') return fallback; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export const authConfig = { + get issuer() { + return env.ZITADEL_ISSUER; + }, + get clientId() { + return env.ZITADEL_CLIENT_ID; + }, + get clientSecret(): string | undefined { + const secret = env.ZITADEL_CLIENT_SECRET; + return secret && !secret.startsWith('REPLACE_') ? secret : undefined; + }, + get scope() { + return env.OIDC_SCOPE || DEFAULT_OIDC_SCOPE; + }, + get redirectUri() { + return env.AUTH_REDIRECT_URI; + }, + get postLogoutUri() { + return env.AUTH_POST_LOGOUT_URI; + }, + get sessionSecret() { + return env.SESSION_SECRET; + }, + get sessionTtlSeconds() { + return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS); + }, + get refreshThresholdSeconds() { + return positiveInt(env.SESSION_REFRESH_THRESHOLD_SECONDS, 30); + }, + get flowTtlSeconds() { + return positiveInt(env.AUTH_FLOW_TTL_SECONDS, 600); + } +}; diff --git a/frontend/src/routes/login/+server.ts b/frontend/src/routes/login/+server.ts index 050f39f..45632f5 100644 --- a/frontend/src/routes/login/+server.ts +++ b/frontend/src/routes/login/+server.ts @@ -3,13 +3,12 @@ import * as client from 'openid-client'; import type { RequestHandler } from './$types'; import { getOidcConfig, - authConfig, - OIDC_SCOPE, STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE } from '$lib/server/auth'; +import { authConfig } from '$lib/server/config'; export const GET: RequestHandler = async ({ url, cookies }) => { const config = await getOidcConfig(); @@ -24,7 +23,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const parameters: Record = { redirect_uri: authConfig.redirectUri, - scope: OIDC_SCOPE, + scope: authConfig.scope, code_challenge: codeChallenge, code_challenge_method: 'S256', state, @@ -39,7 +38,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { sameSite: 'lax' as const, secure: url.protocol === 'https:', path: '/', - maxAge: 600 + maxAge: authConfig.flowTtlSeconds }; cookies.set(VERIFIER_COOKIE, codeVerifier, cookieOptions); cookies.set(STATE_COOKIE, state, cookieOptions); diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 659863d..4ebf70c 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,7 +1,8 @@ import { redirect } from '@sveltejs/kit'; import * as client from 'openid-client'; import type { RequestHandler } from './$types'; -import { getOidcConfig, authConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth'; +import { getOidcConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth'; +import { authConfig } from '$lib/server/config'; const handleLogout: RequestHandler = async ({ cookies }) => { const session = await decodeSession(cookies.get(SESSION_COOKIE));