// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication /** * Handles OIDC callback from Authentik * Exchanges authorization code for tokens, creates session, and redirects to dashboard */ 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'; // 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 { 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'); // 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}`); return redirect(`/login?error=${encodeURIComponent(error)}`); } // Validate required parameters if (!code || !state) { console.error('❌ Missing required parameters in callback'); return redirect('/login?error=invalid_callback'); } console.log(`✓ Callback parameters received - State: ${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('❌ Missing or invalid state cookie'); return redirect('/login?error=invalid_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`); // Load Authentik configuration const config = loadAuthentikConfig(); // Create OIDC client const oidcClient = new AuthentikOIDCClient(config); // Exchange authorization code for tokens console.log(' → Exchanging code for tokens...'); const tokens = await oidcClient.exchangeCodeForTokens( code, stateData.codeVerifier, ); console.log( `✓ Tokens received (access token: ${tokens.access_token.substring(0, 20)}...)`, ); // Fetch user information from Authentik console.log(' → Fetching user information...'); const userInfo = await oidcClient.fetchUserInfo(tokens.access_token); console.log( `✓ User info received: ${userInfo.email} (${userInfo.groups.length} groups)`, ); // Map Authentik groups to Heady role const roleMapping = mapAuthentikGroups(userInfo.groups); console.log(`✓ Role mapping completed:`); console.log(` User groups: ${userInfo.groups.join(', ') || 'none'}`); console.log(` Mapped role: ${roleMapping.role} (${roleMapping.method})`); console.log(` Matched group: ${roleMapping.matchedGroup || 'none'}`); // Create user data for session const sessionUser = { email: userInfo.email, name: userInfo.name, role: roleMapping.role, role_description: roleMapping.role_mapping.description, picture: userInfo.picture, groups: userInfo.groups, capabilities: roleMapping.role_mapping.capabilities, session: { session_id: '', // Will be filled by session manager expires_at: '', // Will be filled by session manager last_activity: '', // Will be filled by session manager }, }; // Create secure session console.log(' → Creating session...'); const sessionMgr = getSessionManager(); const sessionResult = await sessionMgr.createSession(context, sessionUser); console.log( `✓ Session created: ${sessionResult.session_id.substring(0, 16)}...`, ); console.log(` Expires: ${sessionResult.expires_at}`); console.log(`🎉 Authentication successful for ${userInfo.email}`); console.log(` Role: ${roleMapping.role}`); console.log(` Groups: ${userInfo.groups.join(', ') || 'none'}`); console.log( ` Session expires: ${new Date(sessionResult.expires_at).toLocaleString()}`, ); // 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); 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')}`, ); } } // 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&code=${errorCode}`); } }; // Handle POST requests (not typical for OIDC, but included for completeness) export const POST: APIRoute = async (context) => { return context.redirect('/api/auth/login', 302); };