auth: stateless PKCE state, simplify OIDC handlers
- src/lib/auth/oidc-state.ts (new): pack {state, codeVerifier} into a
short-lived signed JWT, store in an httpOnly cookie. Replaces the
process-local Map that broke under multi-worker deployments where
/api/auth/login and /api/auth/callback would land on different workers.
- src/pages/api/auth/{login,callback,logout,profile,status}.ts: drop the
Map-based state-store calls; use oidc-state for set/get/clear.
- src/lib/auth/oidc-client.ts, session-manager.ts, config/authentik.ts:
small adjustments to fit the new state surface.
- src/components/auth/AuthenticatedLayout.astro, src/layouts/Layout.astro:
trim a lot of layout boilerplate (~125 lines each).
- astro.config.mjs, docker-compose.local.yml: minor cleanup.
- authentik-blueprints/heady-oidc.yaml (new): declarative provider +
application blueprint to ship alongside Heady deployments.
This commit is contained in:
parent
0b8812d864
commit
21175c5b7a
14 changed files with 373 additions and 527 deletions
|
|
@ -7,38 +7,30 @@
|
|||
|
||||
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';
|
||||
import { stateStore } from './login.js';
|
||||
|
||||
export const GET: APIRoute = async ({ url, redirect, cookies }) => {
|
||||
// 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 {
|
||||
// During build/prerender, return error for missing parameters
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
!process.env.CI &&
|
||||
!process.env.AUTHENTIK_ISSUER
|
||||
) {
|
||||
return redirect('/login?error=callback_not_configured');
|
||||
}
|
||||
|
||||
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');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Handle Authentik error responses
|
||||
// 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}`);
|
||||
console.error(` Description: ${errorDescription}`);
|
||||
|
||||
return redirect(
|
||||
`/login?error=${encodeURIComponent(error)}&description=${encodeURIComponent(errorDescription || '')}`,
|
||||
);
|
||||
return redirect(`/login?error=${encodeURIComponent(error)}`);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
|
|
@ -49,15 +41,22 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => {
|
|||
|
||||
console.log(`✓ Callback parameters received - State: ${state}`);
|
||||
|
||||
// Validate state and retrieve PKCE code verifier
|
||||
const stateData = stateStore.get(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('❌ Invalid or expired state parameter');
|
||||
console.error('❌ Missing or invalid state cookie');
|
||||
return redirect('/login?error=invalid_state');
|
||||
}
|
||||
|
||||
// Clean up used state
|
||||
stateStore.delete(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`);
|
||||
|
||||
|
|
@ -110,10 +109,7 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => {
|
|||
// Create secure session
|
||||
console.log(' → Creating session...');
|
||||
const sessionMgr = getSessionManager();
|
||||
const sessionResult = await sessionMgr.createSession(
|
||||
{ url, cookies, redirect, clientAddress: 'unknown' },
|
||||
sessionUser,
|
||||
);
|
||||
const sessionResult = await sessionMgr.createSession(context, sessionUser);
|
||||
|
||||
console.log(
|
||||
`✓ Session created: ${sessionResult.session_id.substring(0, 16)}...`,
|
||||
|
|
@ -130,13 +126,11 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => {
|
|||
// 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);
|
||||
|
||||
// Detailed error logging for troubleshooting
|
||||
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')}`,
|
||||
|
|
@ -144,13 +138,19 @@ export const GET: APIRoute = async ({ url, redirect, cookies }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// User-friendly error redirect
|
||||
const errorParam =
|
||||
error instanceof Error
|
||||
? encodeURIComponent(error.message)
|
||||
: 'authentication_failed';
|
||||
// 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&description=${errorParam}`);
|
||||
return redirect(`/login?error=callback_failed&code=${errorCode}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue