diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 82f2fb6..0529d66 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -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)}`; --- diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 0000000..0b52bb6 --- /dev/null +++ b/src/lib/oidc.ts @@ -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 { + 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 { + 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; +} diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts new file mode 100644 index 0000000..e9a4cc7 --- /dev/null +++ b/src/lib/session.test.ts @@ -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(); + }); +}); diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..d72fe5e --- /dev/null +++ b/src/lib/session.ts @@ -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, 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; } +} diff --git a/src/pages/auth/callback.ts b/src/pages/auth/callback.ts new file mode 100644 index 0000000..7880dd4 --- /dev/null +++ b/src/pages/auth/callback.ts @@ -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); + } +}; diff --git a/src/pages/connexion-erreur.astro b/src/pages/connexion-erreur.astro new file mode 100644 index 0000000..4623443 --- /dev/null +++ b/src/pages/connexion-erreur.astro @@ -0,0 +1,12 @@ +--- +import Base from '../layouts/Base.astro'; +import Nav from '../components/Nav.astro'; +--- + +