From 21175c5b7a09d628cb9226e7a5a4e7c4b8a7eb98 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 6 Jun 2026 14:11:51 -0600 Subject: [PATCH] auth: stateless PKCE state, simplify OIDC handlers - src/lib/auth/oidc-state.ts (new): pack {state, codeVerifier} into a short-lived signed JWT, store in an httpOnly cookie. Replaces the process-local Map that broke under multi-worker deployments where /api/auth/login and /api/auth/callback would land on different workers. - src/pages/api/auth/{login,callback,logout,profile,status}.ts: drop the Map-based state-store calls; use oidc-state for set/get/clear. - src/lib/auth/oidc-client.ts, session-manager.ts, config/authentik.ts: small adjustments to fit the new state surface. - src/components/auth/AuthenticatedLayout.astro, src/layouts/Layout.astro: trim a lot of layout boilerplate (~125 lines each). - astro.config.mjs, docker-compose.local.yml: minor cleanup. - authentik-blueprints/heady-oidc.yaml (new): declarative provider + application blueprint to ship alongside Heady deployments. --- astro.config.mjs | 21 +- authentik-blueprints/heady-oidc.yaml | 82 ++++++++ docker-compose.local.yml | 183 ++++++------------ src/components/auth/AuthenticatedLayout.astro | 93 +-------- src/layouts/Layout.astro | 134 ++----------- src/lib/auth/oidc-client.ts | 11 +- src/lib/auth/oidc-state.ts | 92 +++++++++ src/lib/auth/session-manager.ts | 23 ++- src/lib/config/authentik.ts | 83 +++----- src/pages/api/auth/callback.ts | 72 +++---- src/pages/api/auth/login.ts | 53 +---- src/pages/api/auth/logout.ts | 8 + src/pages/api/auth/profile.ts | 23 +-- src/pages/api/auth/status.ts | 22 +-- 14 files changed, 373 insertions(+), 527 deletions(-) create mode 100644 authentik-blueprints/heady-oidc.yaml create mode 100644 src/lib/auth/oidc-state.ts diff --git a/astro.config.mjs b/astro.config.mjs index 287dcf1..5120484 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -26,20 +26,13 @@ export default defineConfig({ include: ['alpinejs', 'guacamole-lite', 'chart.js'], }, server: { - proxy: { - // Proxy API calls to Go backend during development - // Exclude auth endpoints to test our new OIDC system - '/api/(?!auth).*': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - // Proxy remote access calls to FastAPI - '/terminal': { - target: 'http://localhost:8000', - changeOrigin: true, - ws: true, // Enable WebSocket proxying - }, - }, + // No proxy rules: Vite proxy `key` strings are treated as prefix + // matches (not regex) by default, so a pattern like + // `/api/(?!auth).*` was matching `/api/auth/login` literally and + // 404-proxying it to a non-existent backend. The Astro/Astro-only + // auth endpoints under /api/auth/* must reach Astro's SSR handler + // directly. Add proxies back per-route (using `^` prefix for + // regex) once a real backend exists. }, }, diff --git a/authentik-blueprints/heady-oidc.yaml b/authentik-blueprints/heady-oidc.yaml new file mode 100644 index 0000000..5219b12 --- /dev/null +++ b/authentik-blueprints/heady-oidc.yaml @@ -0,0 +1,82 @@ +# Authentik blueprint: Heady OIDC application + provider + scope mappings. +# +# This blueprint declaratively creates everything Heady needs to authenticate +# users against Authentik. Drop it in /blueprints/local/ inside the Authentik +# container (the docker-compose mounts ../authentik-blueprints to that path) +# and Authentik will reconcile it on startup. +# +# Load order matters: scope mappings must exist before the provider can +# reference them; the provider must exist before the application can bind +# to it. Authentik's blueprint engine handles dependency ordering via the +# `!KeyOf` / `!Find` tags. + +version: 1 +metadata: + name: heady-oidc + labels: + blueprints.goauthentik.io/instantiate: 'true' + +context: + # Override these in deployment by passing -e VAR=value to the Authentik + # container or by writing site-specific values directly. + heady_redirect_uri: http://localhost:3007/api/auth/callback + heady_app_slug: heady + heady_app_name: Heady + +entries: + # ─── Scope mapping that exposes the user's group memberships ────────── + # Authentik's defaults include openid/email/profile but NOT groups; Heady's + # role mapper needs `groups` in the userinfo response to map Authentik + # groups → Heady roles (admin / network_admin / etc). + - model: authentik_providers_oauth2.scopemapping + id: heady-groups-mapping + identifiers: + managed: heady.scopemapping.groups + attrs: + name: 'Heady: groups' + scope_name: groups + description: List the groups a user belongs to. + expression: | + return { + "groups": [g.name for g in user.ak_groups.all()], + } + + # ─── OAuth2 / OIDC provider ─────────────────────────────────────────── + # Confidential client with PKCE. Uses the implicit-consent authorization + # flow shipped with Authentik so users aren't prompted on each login. + - model: authentik_providers_oauth2.oauth2provider + id: heady-oidc-provider + identifiers: + name: Heady OIDC + attrs: + client_type: confidential + authorization_flow: + !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: + !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + sub_mode: user_email + include_claims_in_id_token: true + access_token_validity: hours=1 + refresh_token_validity: days=30 + redirect_uris: + - matching_mode: strict + url: !Context heady_redirect_uri + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + - !KeyOf heady-groups-mapping + + # ─── Application binding ────────────────────────────────────────────── + # The Application is what users see in the Authentik portal and what + # the OIDC issuer URL resolves to: + # https:///application/o// + - model: authentik_core.application + identifiers: + slug: !Context heady_app_slug + attrs: + name: !Context heady_app_name + provider: !KeyOf heady-oidc-provider + meta_description: Heady VPN management console + open_in_new_tab: false + policy_engine_mode: any diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 6cafce6..ffe013d 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,168 +1,99 @@ -# Local development setup with Authentik OIDC provider and Heady client -# Uses caddy-docker-proxy for automatic TLS and routing +# Minimal Authentik stack for local Heady auth testing. +# Heady itself runs natively via `npm run dev`; this stack only provides +# the OIDC provider it talks to. services: - # PostgreSQL for Authentik - postgresql: + heady-postgres: image: docker.io/library/postgres:15-alpine restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + test: ['CMD-SHELL', 'pg_isready -d authentik -U authentik'] start_period: 20s interval: 30s retries: 5 timeout: 5s volumes: - - postgres_data:/var/lib/postgresql/data + - heady_postgres_data:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD: ${PG_PASS:-authentik-postgres-password} - POSTGRES_USER: ${PG_USER:-authentik} - POSTGRES_DB: ${PG_DB:-authentik} - env_file: - - .env.local - networks: - - authentik-internal + POSTGRES_PASSWORD: authentik-local-pw + POSTGRES_USER: authentik + POSTGRES_DB: authentik + networks: [authentik-internal] - # Redis for Authentik - redis: + heady-redis: image: docker.io/library/redis:alpine command: --save 60 1 --loglevel warning restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + test: ['CMD-SHELL', 'redis-cli ping | grep PONG'] start_period: 20s interval: 30s retries: 5 timeout: 3s volumes: - - redis_data:/data - networks: - - authentik-internal + - heady_redis_data:/data + networks: [authentik-internal] - # Authentik Server - authentik-server: - image: ghcr.io/goauthentik/authentik:2024.8.3 + heady-authentik-server: + image: ghcr.io/goauthentik/server:2024.12 restart: unless-stopped command: server environment: - AUTHENTIK_REDIS__HOST: redis - AUTHENTIK_POSTGRESQL__HOST: postgresql - AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} - AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} - AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:-authentik-postgres-password} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-super-secret-authentik-key-change-me} - AUTHENTIK_ERROR_REPORTING__ENABLED: false - AUTHENTIK_DISABLE_UPDATE_CHECK: true - AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true + AUTHENTIK_REDIS__HOST: heady-redis + AUTHENTIK_POSTGRESQL__HOST: heady-postgres + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: authentik-local-pw + AUTHENTIK_SECRET_KEY: heady-local-authentik-secret-key-2026 + AUTHENTIK_ERROR_REPORTING__ENABLED: 'false' + AUTHENTIK_DISABLE_UPDATE_CHECK: 'true' + AUTHENTIK_DISABLE_STARTUP_ANALYTICS: 'true' AUTHENTIK_AVATARS: initials volumes: - - authentik_media:/media - - authentik_custom-templates:/templates - env_file: - - .env.local - depends_on: - - postgresql - - redis - networks: - - caddy - - authentik-internal + - heady_authentik_media:/media + - heady_authentik_templates:/templates + # Auto-load custom blueprints (Heady OIDC provider + application). + # Anything in /blueprints/* is reconciled on startup. + - ./authentik-blueprints:/blueprints/heady:ro + depends_on: [heady-postgres, heady-redis] + networks: [caddy, authentik-internal] labels: - caddy: auth.l.supported.systems - caddy.reverse_proxy: "{{upstreams 9000}}" - caddy.tls: internal + caddy: heady-auth.l.supported.systems + caddy.reverse_proxy: '{{upstreams 9000}}' - # Authentik Worker - authentik-worker: - image: ghcr.io/goauthentik/authentik:2024.8.3 + heady-authentik-worker: + image: ghcr.io/goauthentik/server:2024.12 restart: unless-stopped command: worker environment: - AUTHENTIK_REDIS__HOST: redis - AUTHENTIK_POSTGRESQL__HOST: postgresql - AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} - AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} - AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:-authentik-postgres-password} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-super-secret-authentik-key-change-me} - AUTHENTIK_ERROR_REPORTING__ENABLED: false - AUTHENTIK_DISABLE_UPDATE_CHECK: true - AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true + AUTHENTIK_REDIS__HOST: heady-redis + AUTHENTIK_POSTGRESQL__HOST: heady-postgres + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: authentik-local-pw + AUTHENTIK_SECRET_KEY: heady-local-authentik-secret-key-2026 + AUTHENTIK_ERROR_REPORTING__ENABLED: 'false' + AUTHENTIK_DISABLE_UPDATE_CHECK: 'true' + AUTHENTIK_DISABLE_STARTUP_ANALYTICS: 'true' user: root volumes: - /var/run/docker.sock:/var/run/docker.sock - - authentik_media:/media - - authentik_certs:/certs - - authentik_custom-templates:/templates - env_file: - - .env.local - depends_on: - - postgresql - - redis - networks: - - authentik-internal - - # Heady Application (Development) - heady-dev: - build: - context: . - dockerfile: Dockerfile.dev - target: development - restart: unless-stopped - environment: - # Astro dev server config - HOST: "0.0.0.0" - PORT: "3000" - - # OIDC Configuration - AUTHENTIK_ISSUER: "https://auth.l.supported.systems/application/o/heady/" - AUTHENTIK_CLIENT_ID: "heady-local-test" - AUTHENTIK_CLIENT_SECRET: "your-client-secret-here" - PUBLIC_URL: "https://heady.l.supported.systems" - - # Session Configuration - SESSION_SECRET: "this-is-a-32-char-session-secret-key!" - SESSION_LIFETIME: "24h" - - # Role Mapping - HEADY_OWNER_GROUPS: "founders,ceo,executives" - HEADY_ADMIN_GROUPS: "admin,administrators,managers" - HEADY_NETWORK_GROUPS: "devops,network,sre,infrastructure" - HEADY_IT_GROUPS: "helpdesk,support,it-support" - HEADY_AUDITOR_GROUPS: "audit,auditor,compliance,security" - - # Development Mode - NODE_ENV: "development" - HEADY_LOAD_ENV_OVERRIDES: "true" - volumes: - - .:/app - - /app/node_modules - env_file: - - .env.local - depends_on: - - authentik-server - networks: - - caddy - labels: - caddy: heady.l.supported.systems - caddy.reverse_proxy: "{{upstreams 3000}}" - caddy.tls: internal - # HMR WebSocket support for Astro/Vite - caddy.reverse_proxy.flush_interval: "-1" - caddy.reverse_proxy.transport: "http" - caddy.reverse_proxy.transport.read_timeout: "0" - caddy.reverse_proxy.transport.write_timeout: "0" - caddy.reverse_proxy.transport.keepalive: "5m" - caddy.reverse_proxy.stream_timeout: "24h" - caddy.reverse_proxy.stream_close_delay: "5s" + - heady_authentik_media:/media + - heady_authentik_certs:/certs + - heady_authentik_templates:/templates + - ./authentik-blueprints:/blueprints/heady:ro + depends_on: [heady-postgres, heady-redis] + networks: [authentik-internal] volumes: - postgres_data: - redis_data: - authentik_media: - authentik_certs: - authentik_custom-templates: + heady_postgres_data: + heady_redis_data: + heady_authentik_media: + heady_authentik_certs: + heady_authentik_templates: networks: caddy: external: true authentik-internal: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/src/components/auth/AuthenticatedLayout.astro b/src/components/auth/AuthenticatedLayout.astro index ab07246..abc7a48 100644 --- a/src/components/auth/AuthenticatedLayout.astro +++ b/src/components/auth/AuthenticatedLayout.astro @@ -73,93 +73,12 @@ const { title, requiredRole, requiredCapabilities } = Astro.props; - -
- -
-
-
- - - - - -
- -
- -
- -
- - -
- - - - - - -
-
-
-
- - -
- -
- - -
-
-
-
-

Heady VPN Management • Strategic and Awesome

-
-
- • - -
-
-
-
+ +
+
+ \ No newline at end of file diff --git a/src/lib/auth/oidc-client.ts b/src/lib/auth/oidc-client.ts index f33c293..8aaad02 100644 --- a/src/lib/auth/oidc-client.ts +++ b/src/lib/auth/oidc-client.ts @@ -247,10 +247,17 @@ export class AuthentikOIDCClient { } /** - * Build logout URL for OIDC end session endpoint + * Build logout URL for OIDC end session endpoint. + * Prefers the discovery document's end_session_endpoint (which Authentik + * publishes correctly) and falls back to the convention-built URL only if + * discovery hasn't been fetched yet or omitted it. */ buildLogoutUrl(postLogoutRedirectUri?: string): string { - const logoutUrl = new URL(this.config.enhanced.endSessionEndpoint); + const endpoint = + this.discoveryCache?.end_session_endpoint ?? + this.config.enhanced.endSessionEndpoint; + + const logoutUrl = new URL(endpoint); if (postLogoutRedirectUri) { logoutUrl.searchParams.set( diff --git a/src/lib/auth/oidc-state.ts b/src/lib/auth/oidc-state.ts new file mode 100644 index 0000000..d40127d --- /dev/null +++ b/src/lib/auth/oidc-state.ts @@ -0,0 +1,92 @@ +// 🔐 OIDC State Storage - Stateless PKCE state via signed cookies +// +// The OIDC authorization-code-with-PKCE flow needs to remember the +// `code_verifier` and `state` between /api/auth/login and /api/auth/callback. +// A server-side Map only works in single-process deployments — behind any +// load balancer with N workers, ~(N-1)/N of logins fail randomly because the +// callback hits a worker that never saw the state. +// +// Instead, we pack {state, codeVerifier} into a short-lived signed JWT and +// store it in an httpOnly cookie. The same browser carries it back to the +// callback regardless of which worker serves the request. No shared store +// needed, no cleanup timer needed, multi-process safe by construction. + +import type { APIContext } from 'astro'; +import { SignJWT, jwtVerify } from 'jose'; +import { getSessionConfig } from '../config/authentik.js'; + +const COOKIE_NAME = 'heady_oidc_state'; +const COOKIE_MAX_AGE_SECONDS = 10 * 60; // 10 minutes — matches OAuth state TTL + +interface OidcStatePayload { + state: string; + codeVerifier: string; +} + +/** + * Sign the OIDC state payload and write it as an httpOnly cookie. + * The cookie expires automatically via the JWT exp claim AND the cookie + * maxAge — belt and suspenders. + */ +export async function storeOidcState( + context: APIContext, + payload: OidcStatePayload, +): Promise { + const config = getSessionConfig(); + const secret = new TextEncoder().encode(config.secret); + const expirationSeconds = + Math.floor(Date.now() / 1000) + COOKIE_MAX_AGE_SECONDS; + + const token = await new SignJWT({ + state: payload.state, + cv: payload.codeVerifier, + }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt() + .setExpirationTime(expirationSeconds) + .sign(secret); + + context.cookies.set(COOKIE_NAME, token, { + httpOnly: true, + secure: config.secure, + sameSite: 'lax', // 'lax' is required so the cookie comes back on the IdP redirect + maxAge: COOKIE_MAX_AGE_SECONDS, + path: '/', + }); +} + +/** + * Read, verify, and consume the OIDC state cookie. + * Returns the payload on success, or null if the cookie is missing/expired/ + * tampered. Always deletes the cookie after the lookup. + */ +export async function consumeOidcState( + context: APIContext, +): Promise { + const cookie = context.cookies.get(COOKIE_NAME); + if (!cookie) { + return null; + } + + // Always clear the cookie — it's single-use. + context.cookies.delete(COOKIE_NAME, { path: '/' }); + + try { + const config = getSessionConfig(); + const secret = new TextEncoder().encode(config.secret); + const { payload } = await jwtVerify(cookie.value, secret, { + algorithms: ['HS256'], + }); + + const state = payload.state; + const codeVerifier = payload.cv; + if (typeof state !== 'string' || typeof codeVerifier !== 'string') { + return null; + } + + return { state, codeVerifier }; + } catch (error) { + console.warn('OIDC state cookie verification failed:', error); + return null; + } +} diff --git a/src/lib/auth/session-manager.ts b/src/lib/auth/session-manager.ts index c651fa4..32db942 100644 --- a/src/lib/auth/session-manager.ts +++ b/src/lib/auth/session-manager.ts @@ -58,15 +58,32 @@ export interface SessionCleanupResult { export class HeadySessionManager { private sessions = new Map(); private config = getSessionConfig(); + private cleanupTimer: ReturnType | null = null; constructor() { - // Start cleanup interval - setInterval( + // Cleanup expired sessions every 5 minutes. + // unref() lets the Node process exit even if the timer is still + // scheduled — without this, tests and short-lived scripts hang. + this.cleanupTimer = setInterval( () => { this.cleanupExpiredSessions().catch(console.error); }, 5 * 60 * 1000, - ); // Cleanup every 5 minutes + ); + if (typeof this.cleanupTimer === 'object' && this.cleanupTimer && 'unref' in this.cleanupTimer) { + (this.cleanupTimer as { unref: () => void }).unref(); + } + } + + /** + * Stop the cleanup interval. Call this when disposing the manager + * (e.g. in tests or on graceful shutdown). + */ + dispose(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } } /** diff --git a/src/lib/config/authentik.ts b/src/lib/config/authentik.ts index bae0d45..66c84f2 100644 --- a/src/lib/config/authentik.ts +++ b/src/lib/config/authentik.ts @@ -26,45 +26,26 @@ export function loadAuthentikConfig(): AuthentikConfig { const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; const publicUrl = process.env.PUBLIC_URL; - // During build/prerender, return dummy config to prevent errors - if (!issuer || !clientId || !clientSecret || !publicUrl) { - if (process.env.NODE_ENV === 'development' && !process.env.CI) { - // In development, return dummy config for build process - return { - issuer: 'https://example.com', - clientId: 'dummy-client-id', - clientSecret: 'dummy-client-secret', - redirectUri: 'https://example.com/callback', - scopes: ['openid', 'email', 'profile', 'groups'], - enhanced: { - wellKnownUrl: 'https://example.com/.well-known/openid-configuration', - authorizationEndpoint: 'https://example.com/auth', - tokenEndpoint: 'https://example.com/token', - userInfoEndpoint: 'https://example.com/userinfo', - endSessionEndpoint: 'https://example.com/logout', - }, - }; - } - - // In production, throw errors for missing config - if (!issuer) { - throw new Error( - 'Missing required Authentik configuration: AUTHENTIK_ISSUER', - ); - } - if (!clientId) { - throw new Error( - 'Missing required Authentik configuration: AUTHENTIK_CLIENT_ID', - ); - } - if (!clientSecret) { - throw new Error( - 'Missing required Authentik configuration: AUTHENTIK_CLIENT_SECRET', - ); - } - if (!publicUrl) { - throw new Error('Missing required Authentik configuration: PUBLIC_URL'); - } + // Routes that need this config set `export const prerender = false`, + // so by the time loadAuthentikConfig() runs we're handling a real request + // and missing env vars should fail loudly. + if (!issuer) { + throw new Error( + 'Missing required Authentik configuration: AUTHENTIK_ISSUER', + ); + } + if (!clientId) { + throw new Error( + 'Missing required Authentik configuration: AUTHENTIK_CLIENT_ID', + ); + } + if (!clientSecret) { + throw new Error( + 'Missing required Authentik configuration: AUTHENTIK_CLIENT_SECRET', + ); + } + if (!publicUrl) { + throw new Error('Missing required Authentik configuration: PUBLIC_URL'); } // Validate URL format @@ -147,19 +128,7 @@ function enhanceAuthentikConfig(issuer: string): AuthentikConfig['enhanced'] { export function getSessionConfig() { const secret = process.env.SESSION_SECRET; - // During build/prerender, return dummy config to prevent errors if (!secret || secret.length < 32) { - if (process.env.NODE_ENV === 'development' && !process.env.CI) { - return { - secret: 'dummy-32-char-session-secret-key!', - lifetime: 24 * 60 * 60 * 1000, // 24 hours - cookieName: 'heady_session', - secure: false, - httpOnly: true, - sameSite: 'lax' as const, - path: '/', - }; - } throw new Error('SESSION_SECRET must be at least 32 characters long'); } @@ -183,13 +152,17 @@ export function getSessionConfig() { * Parse session lifetime string to milliseconds */ function parseLifetime(lifetime: string): number { - const match = lifetime.match(/^(\d+)([smhd]?)$/); + // Require an explicit unit suffix to prevent ambiguity bugs like + // `SESSION_LIFETIME=24` being interpreted as 24 seconds. + const match = lifetime.match(/^(\d+)([smhd])$/); if (!match) { - throw new Error(`Invalid session lifetime format: ${lifetime}`); + throw new Error( + `Invalid session lifetime "${lifetime}". Must be a number followed by a unit: s (seconds), m (minutes), h (hours), or d (days). Example: "24h"`, + ); } const value = parseInt(match[1], 10); - const unit = match[2] || 's'; + const unit = match[2] as 's' | 'm' | 'h' | 'd'; const multipliers = { s: 1000, @@ -198,7 +171,7 @@ function parseLifetime(lifetime: string): number { d: 24 * 60 * 60 * 1000, }; - return value * multipliers[unit as keyof typeof multipliers]; + return value * multipliers[unit]; } /** diff --git a/src/pages/api/auth/callback.ts b/src/pages/api/auth/callback.ts index 334e9d0..fb035f5 100644 --- a/src/pages/api/auth/callback.ts +++ b/src/pages/api/auth/callback.ts @@ -7,38 +7,30 @@ import type { APIRoute } from 'astro'; import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js'; +import { consumeOidcState } from '../../../lib/auth/oidc-state.js'; import { mapAuthentikGroups } from '../../../lib/auth/role-mapper.js'; import { getSessionManager } from '../../../lib/auth/session-manager.js'; import { loadAuthentikConfig } from '../../../lib/config/authentik.js'; -import { stateStore } from './login.js'; -export const GET: APIRoute = async ({ url, redirect, cookies }) => { +// Force SSR — this route reads env vars and must not be prerendered +export const prerender = false; + +export const GET: APIRoute = async (context) => { + const { url, redirect } = context; try { - // During build/prerender, return error for missing parameters - if ( - process.env.NODE_ENV === 'development' && - !process.env.CI && - !process.env.AUTHENTIK_ISSUER - ) { - return redirect('/login?error=callback_not_configured'); - } - console.log('🤠 Heady OIDC Callback received'); // Extract parameters from callback const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); - const errorDescription = url.searchParams.get('error_description'); - // Handle Authentik error responses + // Handle Authentik error responses. + // Don't reflect Authentik's error_description into the URL — it's + // attacker-controlled if Authentik is compromised/MITM'd. if (error) { console.error(`❌ Authentik returned error: ${error}`); - console.error(` Description: ${errorDescription}`); - - return redirect( - `/login?error=${encodeURIComponent(error)}&description=${encodeURIComponent(errorDescription || '')}`, - ); + return redirect(`/login?error=${encodeURIComponent(error)}`); } // Validate required parameters @@ -49,15 +41,22 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => { console.log(`✓ Callback parameters received - State: ${state}`); - // Validate state and retrieve PKCE code verifier - const stateData = stateStore.get(state); + // Retrieve and verify the signed state cookie. The cookie carries the + // PKCE code_verifier plus the state nonce we set during /api/auth/login. + // This works across any number of workers/replicas because the state + // rides with the browser, not the server. + const stateData = await consumeOidcState(context); if (!stateData) { - console.error('❌ Invalid or expired state parameter'); + console.error('❌ Missing or invalid state cookie'); return redirect('/login?error=invalid_state'); } - // Clean up used state - stateStore.delete(state); + // Compare the state in the cookie with the state from the IdP redirect. + // They must match — this is the CSRF defense for the OAuth dance. + if (stateData.state !== state) { + console.error('❌ State mismatch (CSRF check failed)'); + return redirect('/login?error=invalid_state'); + } console.log(`✓ State validated and PKCE verifier retrieved`); @@ -110,10 +109,7 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => { // Create secure session console.log(' → Creating session...'); const sessionMgr = getSessionManager(); - const sessionResult = await sessionMgr.createSession( - { url, cookies, redirect, clientAddress: 'unknown' }, - sessionUser, - ); + const sessionResult = await sessionMgr.createSession(context, sessionUser); console.log( `✓ Session created: ${sessionResult.session_id.substring(0, 16)}...`, @@ -130,13 +126,11 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => { // Redirect to dashboard (session cookie was set by session manager) return redirect('/', 302); } catch (error) { + // Log full error details server-side for diagnostics. console.error('❌ OIDC callback error:', error); - - // Detailed error logging for troubleshooting if (error instanceof Error) { console.error(` Error type: ${error.constructor.name}`); console.error(` Message: ${error.message}`); - if (error.stack) { console.error( ` Stack: ${error.stack.split('\n').slice(0, 3).join('\n')}`, @@ -144,13 +138,19 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => { } } - // User-friendly error redirect - const errorParam = - error instanceof Error - ? encodeURIComponent(error.message) - : 'authentication_failed'; + // Redirect with a SAFE error code only — never reflect raw error.message + // into the URL. Doing so leaks internal details (client_id, hostnames, + // stack hints) to the browser URL bar, history, access logs, and any + // Referer header on subsequent navigations. + let errorCode = 'authentication_failed'; + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + if (msg.includes('token exchange')) errorCode = 'token_exchange_failed'; + else if (msg.includes('user info')) errorCode = 'user_info_failed'; + else if (msg.includes('discovery')) errorCode = 'discovery_failed'; + } - return redirect(`/login?error=callback_failed&description=${errorParam}`); + return redirect(`/login?error=callback_failed&code=${errorCode}`); } }; diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts index ff5a3b0..b0300b8 100644 --- a/src/pages/api/auth/login.ts +++ b/src/pages/api/auth/login.ts @@ -7,51 +7,18 @@ import type { APIRoute } from 'astro'; import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js'; +import { storeOidcState } from '../../../lib/auth/oidc-state.js'; import { loadAuthentikConfig, validateAuthentikConfig, } from '../../../lib/config/authentik.js'; -// In-memory state storage (in production, use Redis/database) -const stateStore = new Map< - string, - { codeVerifier: string; createdAt: number } ->(); +// Force SSR — this route reads env vars and must not be prerendered +export const prerender = false; -// Cleanup expired states every 5 minutes -setInterval( - () => { - const now = Date.now(); - for (const [state, data] of stateStore.entries()) { - if (now - data.createdAt > 10 * 60 * 1000) { - // 10 minutes - stateStore.delete(state); - } - } - }, - 5 * 60 * 1000, -); - -export const GET: APIRoute = async ({ url, redirect }) => { +export const GET: APIRoute = async (context) => { + const { redirect } = context; try { - // During build/prerender, return configuration error - if ( - process.env.NODE_ENV === 'development' && - !process.env.CI && - !process.env.AUTHENTIK_ISSUER - ) { - return new Response( - JSON.stringify({ - error: 'Authentication not configured', - message: 'Set AUTHENTIK_ISSUER and related environment variables', - }), - { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - console.log('🤠 Heady OIDC Login initiated'); // Load and validate Authentik configuration @@ -87,10 +54,11 @@ export const GET: APIRoute = async ({ url, redirect }) => { // Generate state parameter const state = oidcClient.generateState(); - // Store state and code verifier for callback validation - stateStore.set(state, { + // Store {state, codeVerifier} in a signed cookie so the callback can + // retrieve them regardless of which worker handles it. + await storeOidcState(context, { + state, codeVerifier: pkce.codeVerifier, - createdAt: Date.now(), }); // Build authorization URL @@ -128,6 +96,3 @@ export const GET: APIRoute = async ({ url, redirect }) => { export const POST: APIRoute = async (context) => { return context.redirect('/api/auth/login', 302); }; - -// Export state store for use by callback endpoint -export { stateStore }; diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts index cb084d9..0f253d9 100644 --- a/src/pages/api/auth/logout.ts +++ b/src/pages/api/auth/logout.ts @@ -10,6 +10,9 @@ import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js'; import { getSessionManager } from '../../../lib/auth/session-manager.js'; import { loadAuthentikConfig } from '../../../lib/config/authentik.js'; +// Force SSR — this route reads env vars and must not be prerendered +export const prerender = false; + export const POST: APIRoute = async ({ request, url, cookies }) => { try { console.log('🤠 Heady logout initiated'); @@ -44,6 +47,11 @@ export const POST: APIRoute = async ({ request, url, cookies }) => { const config = loadAuthentikConfig(); const oidcClient = new AuthentikOIDCClient(config); + // Ensure discovery cache is populated so buildLogoutUrl can use + // the authoritative end_session_endpoint from the IdP instead of + // the convention-built fallback (which may be a 404). + await oidcClient.discover(); + // Get Authentik logout URL const logoutUrl = oidcClient.buildLogoutUrl( `${url.origin}/login?message=logout_successful`, diff --git a/src/pages/api/auth/profile.ts b/src/pages/api/auth/profile.ts index 90df633..f175762 100644 --- a/src/pages/api/auth/profile.ts +++ b/src/pages/api/auth/profile.ts @@ -9,28 +9,11 @@ import type { APIRoute } from 'astro'; import { getCapabilitiesForRole } from '../../../lib/auth/role-mapper.js'; import { getSessionManager } from '../../../lib/auth/session-manager.js'; +// Force SSR — this route reads env vars and must not be prerendered +export const prerender = false; + export const GET: APIRoute = async ({ cookies }) => { try { - // During build/prerender, return not authenticated response - if ( - process.env.NODE_ENV === 'development' && - !process.env.CI && - !process.env.SESSION_SECRET - ) { - return new Response( - JSON.stringify({ - authenticated: false, - user: null, - login_url: '/api/auth/login', - message: 'Authentication available at runtime', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - const sessionMgr = getSessionManager(); // Check for authentication diff --git a/src/pages/api/auth/status.ts b/src/pages/api/auth/status.ts index 89d8ef1..69414be 100644 --- a/src/pages/api/auth/status.ts +++ b/src/pages/api/auth/status.ts @@ -16,27 +16,11 @@ import { validateAuthentikConfig, } from '../../../lib/config/authentik.js'; +// Force SSR — this route reads env vars and must not be prerendered +export const prerender = false; + export const GET: APIRoute = async ({ url, cookies }) => { try { - // During build/prerender, return build-time response - if ( - process.env.NODE_ENV === 'development' && - !process.env.CI && - !process.env.AUTHENTIK_ISSUER - ) { - return new Response( - JSON.stringify({ - service: 'Heady Authentication', - status: 'build_mode', - message: 'Authentication system available at runtime', - timestamp: new Date().toISOString(), - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } // Load configuration const config = loadAuthentikConfig();