headplane/src/pages/api/auth/callback.ts

161 lines
5.9 KiB
TypeScript
Raw Normal View History

// 🤠 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);
};