feat: keycloak oidc login + sessions
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user