headplane/src/pages/api/auth/callback.ts
Ryan Malloy 09b1bae157 ui: shadcn-ui + React island shells for pages, Redis session store
Rewrite the dashboard, machines, users, dns, and settings pages as thin
Astro shells that mount a matching React island (Dashboard, MachinesPage,
UsersPage, DnsPage, SettingsPage) built on the shadcn-ui component
library. Alpine.js templates in those pages are replaced wholesale;
Alpine still ships as the runtime for the ACL editor's dependencies.

- src/components/ui/*: shadcn primitives (button, card, tabs, select,
  dropdown-menu, avatar, badge, input, switch, separator)
- src/components/{dashboard,dns,machines,settings,users}/*: page-level
  React shells that consume server props from the Astro parent
- src/components/shell/AppShell.tsx: shared chrome (nav, user menu)
- src/lib/utils.ts: shadcn's cn() helper
- src/lib/auth/session-manager.ts: pluggable SessionStore interface with
  Redis (via REDIS_URL) or in-memory backends; both honor absolute
  expiration via TTL. In-memory logs a warning that sessions vanish on
  restart.
- src/lib/auth/oidc-client.ts, oidc-state.ts: shrink OIDC handlers now
  that PKCE + nonce state travels in a signed JWT cookie
- src/pages/api/auth/*: match the simplified handlers
- src/styles/global.css, tailwind.config.mjs, tsconfig.json: shadcn
  design tokens and the '@/*' path alias
- docker-compose.local.yml: local dev tweaks
- package.json, pnpm-lock.yaml: shadcn + Radix + ioredis + zod
2026-08-20 10:25:35 -06:00

153 lines
5.6 KiB
TypeScript

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