// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication. // // openid-client.authorizationCodeGrant() does all the heavy lifting: // • State validation (CSRF) // • PKCE verification // • ID token signature verification against the IdP's JWKS // • iss / aud / exp / nonce checks // • Returns a TokenSet with verified claims // // We then fetch userinfo (with subject pinning to prevent token substitution) // and create the Heady session. import type { APIRoute } from 'astro'; import { client, getOidcConfig } 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'); // Authentik-side error. const error = url.searchParams.get('error'); if (error) { console.error(`❌ Authentik returned error: ${error}`); return redirect(`/login?error=${encodeURIComponent(error)}`); } // Pull the PKCE verifier + state + nonce we stashed during /login. const stateData = await consumeOidcState(context); if (!stateData) { console.error('❌ Missing or invalid state cookie'); return redirect('/login?error=invalid_state'); } console.log('✓ State cookie retrieved'); const config = loadAuthentikConfig(); const oidcConfig = await getOidcConfig(config); // Hand the full current URL (with code + state query params) to // openid-client. It will: // - Compare query.state to expectedState // - Use pkceCodeVerifier to prove possession of the original request // - Verify the returned ID token's signature against the IdP's JWKS // - Check iss / aud / exp / nonce claims console.log(' → Exchanging code for tokens (with ID token verification)...'); const tokens = await client.authorizationCodeGrant(oidcConfig, url, { pkceCodeVerifier: stateData.codeVerifier, expectedState: stateData.state, ...(stateData.nonce ? { expectedNonce: stateData.nonce } : {}), }); const idTokenClaims = tokens.claims(); if (!idTokenClaims) { throw new Error('Token response missing verified ID token claims'); } console.log( `✓ Tokens received and ID token verified (sub: ${idTokenClaims.sub})`, ); // Fetch userinfo. Pinning expectedSubject prevents token-substitution // (returning userinfo for a different user than the ID token's sub). console.log(' → Fetching userinfo...'); const userInfoRaw = await client.fetchUserInfo( oidcConfig, tokens.access_token, idTokenClaims.sub, ); const userInfo = { sub: userInfoRaw.sub, email: (userInfoRaw.email as string) ?? '', name: (userInfoRaw.name as string) ?? '', picture: userInfoRaw.picture as string | undefined, groups: Array.isArray(userInfoRaw.groups) ? (userInfoRaw.groups as string[]) : [], }; console.log( `✓ User info received: ${userInfo.email} (${userInfo.groups.length} groups)`, ); // Map Authentik groups → 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'}`); 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: '', expires_at: '', last_activity: '' }, }; 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(`🎉 Authentication successful for ${userInfo.email}`); return redirect('/', 302); } catch (error) { 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')}`, ); } } // Map known error shapes to safe codes — never reflect error messages. let errorCode = 'authentication_failed'; if (error instanceof Error) { const msg = error.message.toLowerCase(); if (msg.includes('state')) errorCode = 'invalid_state'; else if (msg.includes('nonce')) errorCode = 'invalid_nonce'; else if (msg.includes('pkce') || msg.includes('code_verifier')) errorCode = 'invalid_pkce'; else if (msg.includes('signature') || msg.includes('jws')) errorCode = 'invalid_id_token'; else if (msg.includes('discovery')) errorCode = 'discovery_failed'; else if (msg.includes('userinfo')) errorCode = 'user_info_failed'; else if (msg.includes('token')) errorCode = 'token_exchange_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); };