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
This commit is contained in:
Ryan Malloy 2026-08-20 10:25:35 -06:00
parent 36c1b18724
commit 09b1bae157
38 changed files with 4966 additions and 3972 deletions

View file

@ -1,12 +1,17 @@
// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication
/**
* Handles OIDC callback from Authentik
* Exchanges authorization code for tokens, creates session, and redirects to dashboard
*/
// 🤠 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 { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
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';
@ -20,77 +25,75 @@ export const GET: APIRoute = async (context) => {
try {
console.log('🤠 Heady OIDC Callback received');
// Extract parameters from callback
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// Authentik-side error.
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.
// 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');
}
// 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 cookie retrieved');
console.log(`✓ State validated and PKCE verifier retrieved`);
// Load Authentik configuration
const config = loadAuthentikConfig();
const oidcConfig = await getOidcConfig(config);
// Create OIDC client
const oidcClient = new AuthentikOIDCClient(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 } : {}),
});
// Exchange authorization code for tokens
console.log(' → Exchanging code for tokens...');
const tokens = await oidcClient.exchangeCodeForTokens(
code,
stateData.codeVerifier,
);
const idTokenClaims = tokens.claims();
if (!idTokenClaims) {
throw new Error('Token response missing verified ID token claims');
}
console.log(
`✓ Tokens received (access token: ${tokens.access_token.substring(0, 20)}...)`,
`✓ Tokens received and ID token verified (sub: ${idTokenClaims.sub})`,
);
// Fetch user information from Authentik
console.log(' → Fetching user information...');
const userInfo = await oidcClient.fetchUserInfo(tokens.access_token);
// 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 to Heady role
// 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'}`);
// Create user data for session
const sessionUser = {
email: userInfo.email,
name: userInfo.name,
@ -99,14 +102,9 @@ export const GET: APIRoute = async (context) => {
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
},
session: { session_id: '', expires_at: '', last_activity: '' },
};
// Create secure session
console.log(' → Creating session...');
const sessionMgr = getSessionManager();
const sessionResult = await sessionMgr.createSession(context, sessionUser);
@ -114,19 +112,10 @@ export const GET: APIRoute = async (context) => {
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}`);
@ -138,16 +127,19 @@ export const GET: APIRoute = async (context) => {
}
}
// 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.
// 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('token exchange')) errorCode = 'token_exchange_failed';
else if (msg.includes('user info')) errorCode = 'user_info_failed';
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}`);