feat: keycloak oidc login + sessions

This commit is contained in:
electron-rare
2026-06-10 23:05:32 +02:00
parent 0c8e3fb69a
commit 5da0ee2140
11 changed files with 252 additions and 9 deletions
+23 -6
View File
@@ -1,6 +1,7 @@
---
interface Props { light?: boolean; }
const { light = false } = Astro.props;
interface Props { light?: boolean; user?: { name: string } | null; currentPath?: string; }
const { light = false, user = null, currentPath = '/' } = Astro.props;
const loginHref = `/connexion?next=${encodeURIComponent(currentPath)}`;
---
<nav class:list={['fixed top-0 left-0 right-0 z-50 border-b backdrop-blur-xl',
light ? 'bg-white/80 border-slate-200' : 'bg-night/70 border-white/10']}>
@@ -8,9 +9,25 @@ const { light = false } = Astro.props;
<a href="/" class:list={['font-semibold tracking-tight text-base', light ? 'text-ink' : 'text-white']}>
Formations <span class="text-copper">·</span> L'Electron Rare
</a>
<a href="https://www.lelectronrare.fr" class:list={['text-sm transition-colors',
light ? 'text-slate-500 hover:text-ink' : 'text-slate-400 hover:text-white']}>
lelectronrare.fr →
</a>
<div class="flex items-center gap-5">
{user ? (
<>
<span class:list={['text-sm', light ? 'text-slate-500' : 'text-slate-400']}>{user.name}</span>
<a href="/deconnexion" class:list={['text-sm transition-colors',
light ? 'text-slate-500 hover:text-ink' : 'text-slate-400 hover:text-white']}>
Se déconnecter
</a>
</>
) : (
<a href={loginHref} class:list={['text-sm transition-colors',
light ? 'text-slate-500 hover:text-ink' : 'text-slate-400 hover:text-white']}>
Se connecter
</a>
)}
<a href="https://www.lelectronrare.fr" class:list={['text-sm transition-colors',
light ? 'text-slate-500 hover:text-ink' : 'text-slate-400 hover:text-white']}>
lelectronrare.fr →
</a>
</div>
</div>
</nav>
+65
View File
@@ -0,0 +1,65 @@
import { createHash, randomBytes } from 'node:crypto';
import { swrCache } from './cache';
const ISSUER = () => process.env.OIDC_ISSUER || '';
const CLIENT_ID = () => process.env.OIDC_CLIENT_ID || 'formations-app';
const CLIENT_SECRET = () => process.env.OIDC_CLIENT_SECRET || '';
const APP = () => process.env.APP_BASE_URL || 'https://formations.saillant.cc';
interface Discovery {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
end_session_endpoint?: string;
}
const discovery = swrCache(async () => {
const res = await fetch(`${ISSUER()}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`oidc discovery: HTTP ${res.status}`);
return (await res.json()) as Discovery;
}, 3600_000);
export const redirectUri = () => `${APP()}/auth/callback`;
export function pkcePair() {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
export async function authUrl(state: string, challenge: string): Promise<string> {
const d = await discovery('oidc');
const p = new URLSearchParams({
client_id: CLIENT_ID(), response_type: 'code', scope: 'openid email profile',
redirect_uri: redirectUri(), state, code_challenge: challenge, code_challenge_method: 'S256',
});
return `${d.authorization_endpoint}?${p}`;
}
export async function exchangeCode(code: string, verifier: string): Promise<{ sub: string; email: string; name: string }> {
const d = await discovery('oidc');
const res = await fetch(d.token_endpoint, {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code, redirect_uri: redirectUri(),
client_id: CLIENT_ID(), client_secret: CLIENT_SECRET(), code_verifier: verifier,
}),
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`oidc token: HTTP ${res.status}`);
const tok = (await res.json()) as { access_token: string };
const ui = await fetch(d.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tok.access_token}` },
signal: AbortSignal.timeout(8000),
});
if (!ui.ok) throw new Error(`oidc userinfo: HTTP ${ui.status}`);
const u = (await ui.json()) as { sub: string; email?: string; name?: string; preferred_username?: string };
return { sub: u.sub, email: u.email ?? '', name: u.name ?? u.preferred_username ?? u.email ?? 'Apprenant' };
}
export async function logoutUrl(): Promise<string | null> {
const d = await discovery('oidc');
return d.end_session_endpoint
? `${d.end_session_endpoint}?client_id=${CLIENT_ID()}&post_logout_redirect_uri=${encodeURIComponent(APP())}`
: null;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { signSession, verifySession } from './session';
beforeEach(() => {
process.env.SESSION_SECRET = 'test-secret-0123456789abcdef0123456789abcdef';
});
describe('session', () => {
it('sign → verify roundtrip returns the payload', () => {
const token = signSession({ sub: 'kc-123', email: 'ada@example.org', name: 'Ada' });
const user = verifySession(token);
expect(user).toMatchObject({ sub: 'kc-123', email: 'ada@example.org', name: 'Ada' });
expect(user!.exp).toBeGreaterThan(Date.now() / 1000);
});
it('rejects a tampered cookie', () => {
const token = signSession({ sub: 'kc-123', email: 'ada@example.org', name: 'Ada' });
const mac = token.split('.')[1];
const forged = Buffer.from(JSON.stringify({
sub: 'evil', email: 'evil@example.org', name: 'Evil',
exp: Math.floor(Date.now() / 1000) + 9999,
})).toString('base64url');
expect(verifySession(`${forged}.${mac}`)).toBeNull();
});
it('rejects an expired session', () => {
const token = signSession({ sub: 'kc-123', email: 'ada@example.org', name: 'Ada' }, -10);
expect(verifySession(token)).toBeNull();
});
it('rejects missing or garbage tokens', () => {
expect(verifySession(undefined)).toBeNull();
expect(verifySession('')).toBeNull();
expect(verifySession('garbage')).toBeNull();
expect(verifySession('a.b')).toBeNull();
expect(verifySession('..')).toBeNull();
});
it('rejects a token signed with another secret', () => {
const token = signSession({ sub: 'kc-123', email: 'ada@example.org', name: 'Ada' });
process.env.SESSION_SECRET = 'another-secret-entirely';
expect(verifySession(token)).toBeNull();
});
it('returns null when no secret is configured', () => {
const token = signSession({ sub: 'kc-123', email: 'ada@example.org', name: 'Ada' });
process.env.SESSION_SECRET = '';
expect(verifySession(token)).toBeNull();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export interface SessionUser { sub: string; email: string; name: string; exp: number; }
export const SESSION_COOKIE = 'fsession';
// Read lazily so tests (and late env injection) can set SESSION_SECRET before calls.
const secret = () => process.env.SESSION_SECRET || '';
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
export function signSession(user: Omit<SessionUser, 'exp'>, ttlSec = 7 * 86400): string {
const payload: SessionUser = { ...user, exp: Math.floor(Date.now() / 1000) + ttlSec };
const body = b64u(JSON.stringify(payload));
const mac = createHmac('sha256', secret()).update(body).digest('base64url');
return `${body}.${mac}`;
}
export function verifySession(token: string | undefined): SessionUser | null {
if (!token || !secret()) return null;
const [body, mac] = token.split('.');
if (!body || !mac) return null;
const expect = createHmac('sha256', secret()).update(body).digest();
const got = Buffer.from(mac, 'base64url');
if (got.length !== expect.length || !timingSafeEqual(got, expect)) return null;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as SessionUser;
if (!payload.sub || payload.exp < Date.now() / 1000) return null;
return payload;
} catch { return null; }
}
+29
View File
@@ -0,0 +1,29 @@
import type { APIRoute } from 'astro';
import { exchangeCode } from '../../lib/oidc';
import { signSession, SESSION_COOKIE } from '../../lib/session';
const TEMP_COOKIES = ['oidc_state', 'oidc_verifier', 'oidc_next'] as const;
export const GET: APIRoute = async ({ url, cookies, redirect }) => {
const clearTemp = () => { for (const c of TEMP_COOKIES) cookies.delete(c, { path: '/' }); };
try {
const state = url.searchParams.get('state');
const code = url.searchParams.get('code');
const expected = cookies.get('oidc_state')?.value;
const verifier = cookies.get('oidc_verifier')?.value;
const next = cookies.get('oidc_next')?.value || '/';
if (!state || !code || !expected || !verifier || state !== expected)
throw new Error('state/code mismatch');
const user = await exchangeCode(code, verifier);
cookies.set(SESSION_COOKIE, signSession(user), {
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 7 * 86400,
});
clearTemp();
const dest = next.startsWith('/') && !next.startsWith('//') ? next : '/';
return redirect(dest, 302);
} catch (err) {
console.error('[oidc] callback failed:', (err as Error).message);
clearTemp();
return redirect('/connexion-erreur', 302);
}
};
+12
View File
@@ -0,0 +1,12 @@
---
import Base from '../layouts/Base.astro';
import Nav from '../components/Nav.astro';
---
<Base title="Connexion impossible" description="La connexion a échoué." noindex>
<Nav currentPath="/" />
<main class="pt-40 pb-20 px-6 max-w-content mx-auto text-center">
<h1 class="text-3xl md:text-4xl font-semibold tracking-tight text-white mb-4">La connexion a échoué</h1>
<p class="text-slate-400 mb-10">Réessayez dans quelques instants, ou revenez plus tard.</p>
<a href="/" class="px-5 py-3 rounded-full bg-copper text-white text-sm font-medium hover:bg-copper/90">← Retour aux formations</a>
</main>
</Base>
+22
View File
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { randomBytes } from 'node:crypto';
import { authUrl, pkcePair } from '../lib/oidc';
const sanitizeNext = (raw: string | null): string =>
raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/';
export const GET: APIRoute = async ({ url, cookies, redirect }) => {
const state = randomBytes(16).toString('base64url');
const { verifier, challenge } = pkcePair();
const next = sanitizeNext(url.searchParams.get('next'));
const opts = { httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/', maxAge: 600 };
cookies.set('oidc_state', state, opts);
cookies.set('oidc_verifier', verifier, opts);
cookies.set('oidc_next', next, opts);
try {
return redirect(await authUrl(state, challenge), 302);
} catch (err) {
console.error('[oidc] connexion failed:', (err as Error).message);
return redirect('/connexion-erreur', 302);
}
};
@@ -4,7 +4,9 @@ import Nav from '../../../../components/Nav.astro';
import { bySlug } from '../../../../config/courses';
import { getCourseBooks, getChapterHtml } from '../../../../lib/moodle';
import { sanitizeHtml } from '../../../../lib/sanitize';
import { verifySession, SESSION_COOKIE } from '../../../../lib/session';
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
const course = bySlug(Astro.params.slug ?? '');
const mMatch = /^module-(\d+)$/.exec(Astro.params.module ?? '');
const cMatch = /^chapitre-(\d+)$/.exec(Astro.params.chapitre ?? '');
@@ -30,7 +32,7 @@ const next = ci < mod.chapters.length - 1 ? `/cours/${course.slug}/module-${mi +
: mi < modules.length - 1 ? `/cours/${course.slug}/module-${mi + 2}/chapitre-1` : null;
---
<Base title={`${chapter.title} — ${course.title}`} description={`${mod.title} · ${course.title}`} light>
<Nav light />
<Nav light user={user ? { name: user.name } : null} currentPath={Astro.url.pathname} />
<main class="pt-24 pb-24 px-6">
<div class="max-w-prose mx-auto">
<nav class="text-sm text-slate-500 mb-8 flex flex-wrap gap-2">
+3 -1
View File
@@ -4,7 +4,9 @@ import Nav from '../../../components/Nav.astro';
import Sommaire from '../../../components/Sommaire.astro';
import { bySlug } from '../../../config/courses';
import { getCourseBooks } from '../../../lib/moodle';
import { verifySession, SESSION_COOKIE } from '../../../lib/session';
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
const course = bySlug(Astro.params.slug ?? '');
if (!course || !course.published) return Astro.rewrite('/404');
@@ -23,7 +25,7 @@ const hubData = {
};
---
<Base title={course.title} description={course.blurb}>
<Nav />
<Nav user={user ? { name: user.name } : null} currentPath={Astro.url.pathname} />
<main class="pt-24 pb-20 px-6">
<header class="max-w-content mx-auto mb-10">
<p class="text-sm text-copper uppercase tracking-wider mb-2">Formation</p>
+13
View File
@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro';
import { logoutUrl } from '../lib/oidc';
import { SESSION_COOKIE } from '../lib/session';
export const GET: APIRoute = async ({ cookies, redirect }) => {
cookies.delete(SESSION_COOKIE, { path: '/' });
try {
return redirect((await logoutUrl()) ?? '/', 302);
} catch (err) {
console.error('[oidc] logout url failed:', (err as Error).message);
return redirect('/', 302);
}
};
+3 -1
View File
@@ -2,11 +2,13 @@
import Base from '../layouts/Base.astro';
import Nav from '../components/Nav.astro';
import { COURSES } from '../config/courses';
import { verifySession, SESSION_COOKIE } from '../lib/session';
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
const published = COURSES.filter((c) => c.published);
const upcoming = COURSES.filter((c) => !c.published);
---
<Base title="Catalogue" description="Formations systèmes embarqués et IA — books en lecture libre, quiz et certificats. ESP32, KiCad, FreeRTOS.">
<Nav />
<Nav user={user ? { name: user.name } : null} currentPath={Astro.url.pathname} />
<main class="pt-28 pb-20 px-6 max-w-content mx-auto">
<h1 class="text-4xl md:text-5xl font-semibold tracking-tight text-white mb-3">Formations</h1>
<p class="text-lg text-slate-400 max-w-2xl mb-12">