auth: stateless PKCE state, simplify OIDC handlers
Some checks are pending
Build / native (push) Waiting to run
Build / nix (push) Waiting to run

- 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.
This commit is contained in:
Ryan Malloy 2026-06-06 14:11:51 -06:00
parent 0b8812d864
commit 21175c5b7a
14 changed files with 373 additions and 527 deletions

View File

@ -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.
},
},

View File

@ -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://<authentik>/application/o/<slug>/
- 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

View File

@ -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
driver: bridge

View File

@ -73,93 +73,12 @@ const { title, requiredRole, requiredCapabilities } = Astro.props;
</div>
</div>
<!-- Authenticated Content -->
<div x-show="authenticated && authorized && !loading" x-cloak class="min-h-screen bg-gray-900">
<!-- Header with User Info -->
<header class="bg-gray-800 border-b border-gray-700 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo and Title -->
<div class="flex items-center">
<a href="/" class="flex items-center">
<span class="text-2xl mr-3">🤠</span>
<span class="text-xl font-bold text-white">Heady</span>
</a>
<span class="hidden sm:block ml-4 text-gray-400">|</span>
<span class="hidden sm:block ml-4 text-gray-300" set:text={title} />
</div>
<!-- User Menu -->
<div class="flex items-center space-x-4">
<!-- User Avatar and Info -->
<div class="flex items-center space-x-3">
<img
x-show="user?.picture"
:src="user?.picture"
:alt="user?.name"
class="w-8 h-8 rounded-full border-2 border-gray-600"
/>
<div x-show="!user?.picture" class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<span class="text-white text-sm font-semibold" x-text="user?.name?.charAt(0) || '?'"></span>
</div>
<div class="hidden md:block">
<p class="text-sm font-medium text-white" x-text="user?.name"></p>
<p class="text-xs text-gray-400" x-text="user?.role"></p>
</div>
</div>
<!-- Role Badge -->
<div class="hidden sm:block">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="{
'bg-purple-100 text-purple-800': user?.role === 'owner',
'bg-red-100 text-red-800': user?.role === 'admin',
'bg-blue-100 text-blue-800': user?.role === 'network_admin',
'bg-green-100 text-green-800': user?.role === 'it_admin',
'bg-yellow-100 text-yellow-800': user?.role === 'auditor',
'bg-gray-100 text-gray-800': user?.role === 'member'
}"
x-text="user?.role_description"
></span>
</div>
<!-- Logout Button -->
<button
@click="logout()"
class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
title="Logout"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<slot />
</main>
<!-- Footer -->
<footer class="bg-gray-800 border-t border-gray-700 mt-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex justify-between items-center">
<div class="text-gray-400 text-sm">
<p>Heady VPN Management • Strategic and Awesome</p>
</div>
<div class="text-gray-500 text-xs">
<span x-text="user?.email"></span> •
<span x-text="new Date(user?.session?.last_activity).toLocaleString()"></span>
</div>
</div>
</div>
</footer>
<!-- Authenticated Content. AuthenticatedLayout is a thin auth GATE — it
only shows the slot when the user is authenticated + authorized. The
page chrome (nav, header, footer) is owned by Layout.astro so we don't
double up on logos, user info, or logout buttons. -->
<div x-show="authenticated && authorized && !loading" x-cloak>
<slot />
</div>
<script define:vars={{ requiredRole, requiredCapabilities }}>

View File

@ -1,5 +1,12 @@
---
// Heady Main Layout - Awesome Alpine.js/Astro Architecture 🤠
// Import Tailwind CSS so Astro bundles + hashes it into the build output.
// Using a raw <link rel="stylesheet" href="/src/styles/global.css"> works in
// dev (Vite serves source files) but breaks in production where the path
// resolves to nothing in dist/client.
import '../styles/global.css';
export interface Props {
title: string;
description?: string;
@ -19,9 +26,8 @@ const {
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title} - Heady</title>
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/src/styles/global.css" />
<!-- Tailwind CSS is imported from the frontmatter so Astro can bundle it. -->
<!-- Define Alpine.js components BEFORE Alpine loads -->
<script is:inline>
@ -418,123 +424,9 @@ const {
</div>
</div>
<!-- Global Alpine.js Scripts -->
<script>
// Global Heady Application State
function headyApp() {
return {
user: null,
loading: false,
loadingMessage: 'Loading...',
toasts: [],
notificationCount: 0,
mobileMenuOpen: false,
async init() {
// Initialize user authentication
await this.loadUser();
// Set up periodic data refresh
setInterval(() => {
this.refreshData();
}, 30000); // Refresh every 30 seconds
},
async loadUser() {
try {
const response = await fetch('/api/auth/profile');
if (response.ok) {
const data = await response.json();
if (data.authenticated && data.user) {
this.user = data.user;
console.log('✓ User authenticated:', this.user.email);
} else {
// Not authenticated - but don't auto-redirect on every page
console.log(' User not authenticated');
this.user = null;
}
} else {
console.warn('Auth profile endpoint returned:', response.status);
this.user = null;
}
} catch (error) {
console.error('Failed to load user:', error);
this.user = null;
}
},
async refreshData() {
// Refresh critical data in background
try {
// Trigger refresh events for active components
this.$dispatch('data-refresh');
} catch (error) {
console.error('Failed to refresh data:', error);
}
},
showToast(type, title, message) {
const id = Date.now();
const toast = {
id,
type,
title,
message,
visible: true
};
this.toasts.push(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
this.removeToast(id);
}, 5000);
},
removeToast(id) {
const index = this.toasts.findIndex(t => t.id === id);
if (index > -1) {
this.toasts[index].visible = false;
setTimeout(() => {
this.toasts.splice(index, 1);
}, 300);
}
},
async signOut() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
} catch (error) {
console.error('Logout failed:', error);
this.showToast('error', 'Logout Failed', 'Failed to sign out');
}
},
toggleNotifications() {
// Toggle notifications panel
this.$dispatch('toggle-notifications');
}
};
}
// Navigation component
function navigation() {
return {
currentPath: window.location.pathname,
init() {
// Update current path on navigation
window.addEventListener('popstate', () => {
this.currentPath = window.location.pathname;
});
}
};
}
// Make functions globally available
window.headyApp = headyApp;
window.navigation = navigation;
</script>
<!-- Alpine.js components are defined in <head> with is:inline so they're
registered before Alpine.js loads. Do not redefine them here — the
non-inline bundled script would overwrite them after Alpine has
already wired up handlers, causing the two copies to drift. -->
</body>
</html>

View File

@ -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(

View File

@ -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<void> {
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<OidcStatePayload | null> {
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;
}
}

View File

@ -58,15 +58,32 @@ export interface SessionCleanupResult {
export class HeadySessionManager {
private sessions = new Map<string, SessionData>();
private config = getSessionConfig();
private cleanupTimer: ReturnType<typeof setInterval> | 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;
}
}
/**

View File

@ -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];
}
/**

View File

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

View File

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

View File

@ -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`,

View File

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

View File

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