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.
This commit is contained in:
parent
0b8812d864
commit
21175c5b7a
@ -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.
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
82
authentik-blueprints/heady-oidc.yaml
Normal file
82
authentik-blueprints/heady-oidc.yaml
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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>
|
||||
@ -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(
|
||||
|
||||
92
src/lib/auth/oidc-state.ts
Normal file
92
src/lib/auth/oidc-state.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user