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:
parent
36c1b18724
commit
09b1bae157
38 changed files with 4966 additions and 3972 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue