From ac6be97d9b10c129c7a1d17c424514e47d756109 Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 18:26:47 +0200 Subject: [PATCH] feat: authenticate against Authentik with admin and viewer roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-in goes through Authentik over OIDC with PKCE. Verified against the live provider: the discovery issuer matches the configured one exactly, and the authorize redirect carries code_challenge_method=S256. Sessions are JWTs with no database adapter, which keeps the module usable from edge middleware and makes a sign-in cost no query. The trade-off is stated in the code: the role travels in the token, so removing someone from the admin group takes effect at the next sign-in or when the eight-hour session expires, not instantly. Immediate revocation would mean asking Authentik on every request, which is what api_llm_loxi does and what this application deliberately does not — it drives a shop window, not a fleet. Group matching is trimmed and case-insensitive. Authentik group names are case-sensitive, but a capitalisation mismatch between the group and the environment variable locks the shop owner out silently, and that is the worse of the two failures. An empty variable never promotes anyone. Authorisation is enforced twice. The middleware covers every /admin page and /api/admin route at the edge; a guard inside the handlers repeats the check, because a matcher is a string, strings get edited, and a route falling outside one should not be the same thing as a route with no access control. The rule itself lives in its own framework-free module so it can be tested directly. Unknown HTTP verbs count as writes: new methods arrive locked. Pages get a redirect to the sign-in screen, API routes get a status code — a fetch that receives an HTML login page is a confusing way to learn you are signed out. The device API stays outside the matcher, as the panel cannot sign in and carries its own bearer token; this is covered by a check that /api/display still answers 401 rather than redirecting. The audit diff compares values by their JSON form, so slot arrays and dates compare by value rather than identity, and a save that changed nothing writes no entry. Recording never throws: losing the trail is bad, refusing the user's change because the trail could not be written is worse. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/layout.tsx | 59 ++++++++++++++++++++ app/admin/page.tsx | 17 ++++-- app/api/auth/[...nextauth]/route.ts | 1 + app/login/page.tsx | 71 ++++++++++++++++++++++++ lib/audit.test.ts | 70 +++++++++++++++++++++++ lib/audit.ts | 86 +++++++++++++++++++++++++++++ lib/auth/authorize.test.ts | 49 ++++++++++++++++ lib/auth/authorize.ts | 32 +++++++++++ lib/auth/guard.ts | 40 ++++++++++++++ lib/auth/index.ts | 63 +++++++++++++++++++++ lib/auth/roles.test.ts | 61 ++++++++++++++++++++ lib/auth/roles.ts | 38 +++++++++++++ middleware.ts | 47 ++++++++++++++++ 13 files changed, 630 insertions(+), 4 deletions(-) create mode 100644 app/admin/layout.tsx create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/login/page.tsx create mode 100644 lib/audit.test.ts create mode 100644 lib/audit.ts create mode 100644 lib/auth/authorize.test.ts create mode 100644 lib/auth/authorize.ts create mode 100644 lib/auth/guard.ts create mode 100644 lib/auth/index.ts create mode 100644 lib/auth/roles.test.ts create mode 100644 lib/auth/roles.ts create mode 100644 middleware.ts diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..e6a7d0e --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,59 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +import { auth, signOut } from '@/lib/auth'; + +/** + * The admin shell. + * + * The middleware has already established that there is a session by the time + * this renders; the call here is only to know who it is. Navigation links are + * added as their pages land, so nothing in the bar leads nowhere. + */ +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await auth(); + const email = session?.user?.email ?? ''; + const isAdmin = session?.user?.role === 'admin'; + + return ( +
+
+
+ + ITA ITO + + +
+ {!isAdmin ? ( + + Lecture seule + + ) : null} + {email} +
{ + 'use server'; + await signOut({ redirectTo: '/login' }); + }} + > + +
+
+
+
+ + {children} +
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 0549a5f..fae14d7 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,8 +1,17 @@ -export default function AdminHome() { +import { auth } from '@/lib/auth'; + +export const metadata = { title: 'Tableau de bord — ITA ITO' }; + +export default async function AdminHome() { + const session = await auth(); + return ( -
-

ITA ITO — Horaires

-

Tableau de bord à venir.

+
+

Tableau de bord

+

+ Connecté en tant que {session?.user?.email} ( + {session?.user?.role === 'admin' ? 'administrateur' : 'lecture seule'}). +

); } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..3df0029 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from '@/lib/auth'; diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..f3e6fbf --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,71 @@ +import Image from 'next/image'; +import { redirect } from 'next/navigation'; + +import { auth, signIn } from '@/lib/auth'; + +export const metadata = { title: 'Connexion — ITA ITO' }; + +const ERRORS: Record = { + AccessDenied: "Ce compte n'a pas accès à cette application.", + Configuration: "La configuration de la connexion est incomplète. Prévenez l'administrateur.", + Verification: 'Le lien de connexion a expiré. Réessayez.', +}; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; from?: string }>; +}) { + const { error, from } = await searchParams; + + // Someone who is already signed in has no business on this page. + if (await auth()) { + redirect(from && from.startsWith('/admin') ? from : '/admin'); + } + + const providerName = process.env.AUTHENTIK_DISPLAY_NAME?.trim() || 'Loxi'; + + return ( +
+
+ ITA ITO + +

Horaires de la boutique

+

Réservé à l’équipe ITA ITO.

+ + {error ? ( +

+ {ERRORS[error] ?? 'La connexion a échoué. Réessayez.'} +

+ ) : null} + +
{ + 'use server'; + await signIn('authentik', { + redirectTo: from && from.startsWith('/admin') ? from : '/admin', + }); + }} + > + +
+
+
+ ); +} diff --git a/lib/audit.test.ts b/lib/audit.test.ts new file mode 100644 index 0000000..217f687 --- /dev/null +++ b/lib/audit.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { diffOf } from './audit'; + +describe('diffOf', () => { + it('reports nothing when nothing moved', () => { + // A log full of empty changes is a log nobody reads. + expect(diffOf({ a: 1 }, { a: 1 })).toBeNull(); + expect(diffOf(null, null)).toBeNull(); + expect(diffOf({}, {})).toBeNull(); + }); + + it('reports a changed field', () => { + expect(diffOf({ isClosed: false }, { isClosed: true })).toEqual({ + isClosed: { before: false, after: true }, + }); + }); + + it('reports only the fields that moved', () => { + expect(diffOf({ a: 1, b: 2 }, { a: 1, b: 3 })).toEqual({ b: { before: 2, after: 3 } }); + }); + + it('reports an added and a removed field', () => { + expect(diffOf({ a: 1 }, { a: 1, b: 2 })).toEqual({ b: { before: null, after: 2 } }); + expect(diffOf({ a: 1, b: 2 }, { a: 1 })).toEqual({ b: { before: 2, after: null } }); + }); + + it('treats a missing field and an explicit null as the same absence', () => { + expect(diffOf({ note: null }, {})).toBeNull(); + expect(diffOf({ note: undefined }, { note: null })).toBeNull(); + }); + + it('compares slot arrays by value, not by identity', () => { + const before = { slots: [{ open: '10:00', close: '18:30' }] }; + const after = { slots: [{ open: '10:00', close: '18:30' }] }; + expect(diffOf(before, after)).toBeNull(); + + expect(diffOf(before, { slots: [{ open: '14:00', close: '18:30' }] })).toEqual({ + slots: { + before: [{ open: '10:00', close: '18:30' }], + after: [{ open: '14:00', close: '18:30' }], + }, + }); + }); + + it('compares dates by their instant, not their object', () => { + const before = { startsAt: new Date('2026-09-20T00:00:00Z') }; + expect(diffOf(before, { startsAt: new Date('2026-09-20T00:00:00Z') })).toBeNull(); + + const changed = diffOf(before, { startsAt: new Date('2026-09-21T00:00:00Z') }); + expect(changed?.startsAt).toEqual({ + before: '2026-09-20T00:00:00.000Z', + after: '2026-09-21T00:00:00.000Z', + }); + }); + + it('records a creation and a deletion', () => { + expect(diffOf(null, { textFr: 'Bonjour' })).toEqual({ + textFr: { before: null, after: 'Bonjour' }, + }); + expect(diffOf({ textFr: 'Bonjour' }, null)).toEqual({ + textFr: { before: 'Bonjour', after: null }, + }); + }); + + it('distinguishes values that only look alike', () => { + expect(diffOf({ priority: 1 }, { priority: '1' })).not.toBeNull(); + expect(diffOf({ isActive: false }, { isActive: null })).not.toBeNull(); + }); +}); diff --git a/lib/audit.ts b/lib/audit.ts new file mode 100644 index 0000000..7e8650b --- /dev/null +++ b/lib/audit.ts @@ -0,0 +1,86 @@ +/** + * The audit trail: who changed what, and when. + * + * The diff is computed here rather than by each caller so every entry has the + * same shape, and so a field can never be recorded as "changed" when only its + * representation moved. + */ + +import type { InputJsonValue } from '@/lib/generated/prisma/internal/prismaNamespace'; + +import { prisma } from '@/lib/db'; + +export type FieldChange = { before: unknown; after: unknown }; +export type Diff = Record; + +/** + * Field-by-field difference between two snapshots. + * + * Returns `null` when nothing moved, so a no-op save leaves no entry: a log + * full of empty changes is a log nobody reads. + * + * Values are compared by their JSON form, which is what makes `slots` arrays + * and dates compare sensibly rather than by identity. + */ +export function diffOf( + before: Record | null, + after: Record | null, +): Diff | null { + const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); + const diff: Diff = {}; + + for (const key of keys) { + const previous = before?.[key]; + const next = after?.[key]; + if (!sameValue(previous, next)) { + diff[key] = { before: normalise(previous), after: normalise(next) }; + } + } + + return Object.keys(diff).length > 0 ? diff : null; +} + +function sameValue(a: unknown, b: unknown): boolean { + return JSON.stringify(normalise(a)) === JSON.stringify(normalise(b)); +} + +/** Dates are compared and stored in ISO form; undefined and null are the same absence. */ +function normalise(value: unknown): unknown { + if (value === undefined) { + return null; + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +} + +export type AuditEntry = { + userEmail: string; + action: string; + entity: string; + entityId?: string | null; + diff?: Diff | null; +}; + +/** + * Records an entry. Never throws: losing the trail is bad, but refusing the + * user's change because the trail could not be written is worse. + */ +export async function recordAudit(entry: AuditEntry): Promise { + try { + await prisma.auditLog.create({ + data: { + userEmail: entry.userEmail, + action: entry.action, + entity: entry.entity, + entityId: entry.entityId ?? null, + // Prisma's JSON input type does not accept an arbitrary record; the + // diff is plain JSON by construction, so the assertion is safe. + diff: entry.diff ? (entry.diff as unknown as InputJsonValue) : undefined, + }, + }); + } catch (error) { + console.error('Could not write the audit entry', entry.action, entry.entity, error); + } +} diff --git a/lib/auth/authorize.test.ts b/lib/auth/authorize.test.ts new file mode 100644 index 0000000..08ceb12 --- /dev/null +++ b/lib/auth/authorize.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { authorize } from './authorize'; + +describe('authorize', () => { + it('refuses anyone without a session', () => { + expect(authorize(undefined, 'GET')).toEqual({ + ok: false, + status: 401, + error: 'Non authentifié', + }); + expect(authorize(undefined, 'POST')).toMatchObject({ status: 401 }); + }); + + it('lets any signed-in account read', () => { + for (const method of ['GET', 'HEAD', 'OPTIONS', 'get', 'head']) { + expect(authorize('viewer', method)).toEqual({ ok: true }); + expect(authorize('admin', method)).toEqual({ ok: true }); + } + }); + + it('refuses a write from a read-only account', () => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + expect(authorize('viewer', method)).toEqual({ + ok: false, + status: 403, + error: 'Compte en lecture seule', + }); + } + }); + + it('allows a write from an administrator', () => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'post']) { + expect(authorize('admin', method)).toEqual({ ok: true }); + } + }); + + it('treats an unknown method as a write', () => { + // New verbs should arrive locked, not open. + expect(authorize('viewer', 'PURGE')).toMatchObject({ status: 403 }); + expect(authorize('admin', 'PURGE')).toEqual({ ok: true }); + }); + + it('checks the session before the method', () => { + // A read-only method must not turn a missing session into a 403; the + // caller has to know they are signed out, not under-privileged. + expect(authorize(undefined, 'GET')).toMatchObject({ status: 401 }); + }); +}); diff --git a/lib/auth/authorize.ts b/lib/auth/authorize.ts new file mode 100644 index 0000000..00364a0 --- /dev/null +++ b/lib/auth/authorize.ts @@ -0,0 +1,32 @@ +/** + * The authorisation rule, on its own and free of any framework import. + * + * Splitting it out is what lets it be tested directly: pulling in the Auth.js + * instance would drag half of Next into a unit test for six lines of policy. + */ + +import { canWrite, type Role } from './roles'; + +export type Decision = { ok: true } | { ok: false; status: 401 | 403; error: string }; + +const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Whether a caller may perform `method`. + * + * Anyone the identity provider vouches for may read; only an administrator may + * change anything. An unknown method counts as a write: new verbs should + * arrive locked, not open. + */ +export function authorize(role: Role | undefined, method: string): Decision { + if (!role) { + return { ok: false, status: 401, error: 'Non authentifié' }; + } + if (READ_METHODS.has(method.toUpperCase())) { + return { ok: true }; + } + if (!canWrite(role)) { + return { ok: false, status: 403, error: 'Compte en lecture seule' }; + } + return { ok: true }; +} diff --git a/lib/auth/guard.ts b/lib/auth/guard.ts new file mode 100644 index 0000000..55ed6a4 --- /dev/null +++ b/lib/auth/guard.ts @@ -0,0 +1,40 @@ +/** + * Authorisation enforced inside the handlers. + * + * The middleware is the first line: it covers every /admin page and + * /api/admin route at the edge. This is the second — because a matcher is a + * string, strings get edited, and a route quietly falling outside it should + * not be the same thing as a route with no access control. + */ + +import { authorize } from './authorize'; +import { auth } from './index'; +import type { Role } from './roles'; + +export type Caller = { email: string; role: Role }; + +/** + * Resolves the caller and applies the rule. Returns either the caller or the + * Response to send back, so a handler reads as: + * + * const gate = await guard(request); + * if ('response' in gate) return gate.response; + */ +export async function guard( + request: Request, +): Promise<{ caller: Caller } | { response: Response }> { + const session = await auth(); + const decision = authorize(session?.user?.role, request.method); + + if (!decision.ok) { + return { response: Response.json({ error: decision.error }, { status: decision.status }) }; + } + + return { + caller: { + // The subject is the e-mail address, which is what the audit trail records. + email: session?.user?.email ?? 'inconnu', + role: session?.user?.role ?? 'viewer', + }, + }; +} diff --git a/lib/auth/index.ts b/lib/auth/index.ts new file mode 100644 index 0000000..68b21e2 --- /dev/null +++ b/lib/auth/index.ts @@ -0,0 +1,63 @@ +import NextAuth from 'next-auth'; +import Authentik from 'next-auth/providers/authentik'; + +import { groupsFromClaim, roleFromGroups, type Role } from './roles'; + +/** + * Authentication against Authentik. + * + * Sessions are JWTs with no database adapter, which keeps this module usable + * from the edge middleware and means a sign-in costs no query. + * + * The trade-off is worth stating: the role is read from the token, so removing + * someone from the admin group does not end a session already in flight — it + * takes effect at the next sign-in, or when the eight-hour session expires. + * Immediate revocation would mean asking Authentik's API on every request, + * which is what api_llm_loxi does and what this application deliberately does + * not: it drives a shop window, not a fleet. + */ + +const ADMIN_GROUP = process.env.AUTHENTIK_ADMIN_GROUP?.trim() || 'horaires-admins'; + +declare module 'next-auth' { + interface Session { + user: { + email?: string | null; + name?: string | null; + image?: string | null; + role: Role; + }; + } +} + +declare module '@auth/core/jwt' { + interface JWT { + role?: Role; + } +} + +const nextAuth = NextAuth({ + providers: [Authentik({ name: process.env.AUTHENTIK_DISPLAY_NAME?.trim() || 'Loxi' })], + // The application sits behind a reverse proxy; the forwarded host is the + // real one. + trustHost: true, + session: { strategy: 'jwt', maxAge: 8 * 60 * 60 }, + pages: { signIn: '/login', error: '/login' }, + callbacks: { + jwt({ token, profile }) { + // `profile` is only present on the request that follows a sign-in, so + // the group membership is resolved once and carried in the token. + if (profile) { + token.role = roleFromGroups(groupsFromClaim(profile.groups), ADMIN_GROUP); + } + return token; + }, + session({ session, token }) { + session.user.role = token.role ?? 'viewer'; + return session; + }, + }, +}); + +export const { auth, signIn, signOut } = nextAuth; +export const { GET, POST } = nextAuth.handlers; diff --git a/lib/auth/roles.test.ts b/lib/auth/roles.test.ts new file mode 100644 index 0000000..73d952f --- /dev/null +++ b/lib/auth/roles.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { canWrite, groupsFromClaim, roleFromGroups } from './roles'; + +describe('roleFromGroups', () => { + it('promotes a member of the configured group', () => { + expect(roleFromGroups(['horaires-admins'], 'horaires-admins')).toBe('admin'); + }); + + it('leaves everyone else read-only', () => { + expect(roleFromGroups(['autre-groupe'], 'horaires-admins')).toBe('viewer'); + expect(roleFromGroups([], 'horaires-admins')).toBe('viewer'); + }); + + it('finds the group among several', () => { + expect(roleFromGroups(['a', 'horaires-admins', 'b'], 'horaires-admins')).toBe('admin'); + }); + + it('survives a capitalisation or spacing mismatch', () => { + expect(roleFromGroups(['Horaires-Admins'], 'horaires-admins')).toBe('admin'); + expect(roleFromGroups([' horaires-admins '], 'horaires-admins')).toBe('admin'); + expect(roleFromGroups(['horaires-admins'], ' HORAIRES-ADMINS ')).toBe('admin'); + }); + + it('never promotes when no group is configured', () => { + // An empty variable must not turn every visitor into an administrator. + expect(roleFromGroups(['horaires-admins'], '')).toBe('viewer'); + expect(roleFromGroups([''], ' ')).toBe('viewer'); + }); + + it('does not match a partial group name', () => { + expect(roleFromGroups(['horaires-admins-readonly'], 'horaires-admins')).toBe('viewer'); + expect(roleFromGroups(['admins'], 'horaires-admins')).toBe('viewer'); + }); +}); + +describe('groupsFromClaim', () => { + it('accepts a list of strings', () => { + expect(groupsFromClaim(['a', 'b'])).toEqual(['a', 'b']); + }); + + it('drops anything that is not a string', () => { + // The claim comes from an external system; it is data, not a promise. + expect(groupsFromClaim(['a', 1, null, { name: 'b' }, 'c'])).toEqual(['a', 'c']); + }); + + it('returns nothing for a missing or malformed claim', () => { + expect(groupsFromClaim(undefined)).toEqual([]); + expect(groupsFromClaim(null)).toEqual([]); + expect(groupsFromClaim('horaires-admins')).toEqual([]); + expect(groupsFromClaim({ groups: ['a'] })).toEqual([]); + }); +}); + +describe('canWrite', () => { + it('is true only for an administrator', () => { + expect(canWrite('admin')).toBe(true); + expect(canWrite('viewer')).toBe(false); + expect(canWrite(undefined)).toBe(false); + }); +}); diff --git a/lib/auth/roles.ts b/lib/auth/roles.ts new file mode 100644 index 0000000..698447f --- /dev/null +++ b/lib/auth/roles.ts @@ -0,0 +1,38 @@ +/** + * Mapping an Authentik group membership to what someone may do here. + * + * Kept pure and separate from the Auth.js wiring so the rule can be tested + * exhaustively: this is the only thing standing between a read-only visitor + * and the shop's opening hours. + */ + +export type Role = 'admin' | 'viewer'; + +/** + * Anyone the identity provider vouches for may look; only members of the + * configured group may change anything. + * + * Comparison is trimmed and case-insensitive. Authentik group names are + * case-sensitive, but a capitalisation mismatch between the group and the + * environment variable is a silent lockout of the shop owner, which is the + * worse failure of the two. + */ +export function roleFromGroups(groups: readonly string[], adminGroup: string): Role { + const target = adminGroup.trim().toLowerCase(); + if (!target) { + return 'viewer'; + } + return groups.some((group) => group.trim().toLowerCase() === target) ? 'admin' : 'viewer'; +} + +/** Reads the `groups` claim defensively: it arrives from an external system. */ +export function groupsFromClaim(claim: unknown): string[] { + if (!Array.isArray(claim)) { + return []; + } + return claim.filter((value): value is string => typeof value === 'string'); +} + +export function canWrite(role: Role | undefined): boolean { + return role === 'admin'; +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..564eca2 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; + +import { auth } from '@/lib/auth'; + +/** + * The single choke point for administrative access. + * + * Every /admin page and every /api/admin route passes through here, so no + * individual page can forget to check. Pages get a redirect to the sign-in + * screen; API routes get a status code, because a fetch that receives an HTML + * login page is a confusing way to learn you are signed out. + * + * Write access is enforced here too: a `viewer` may read anything and change + * nothing. Doing it at the edge means a read-only account cannot reach a + * handler that mutates, whatever that handler remembers to check. + * + * The device API (/api/setup, /api/display, /api/log) is deliberately outside + * this matcher: the panel cannot sign in, and carries its own bearer token. + */ +export default auth((request) => { + const { pathname } = request.nextUrl; + const isApi = pathname.startsWith('/api/admin'); + const session = request.auth; + + if (!session?.user) { + if (isApi) { + return NextResponse.json({ error: 'Non authentifié' }, { status: 401 }); + } + const target = new URL('/login', request.nextUrl.origin); + target.searchParams.set('from', pathname); + return NextResponse.redirect(target); + } + + const isRead = request.method === 'GET' || request.method === 'HEAD'; + if (!isRead && session.user.role !== 'admin') { + return NextResponse.json( + { error: 'Compte en lecture seule' }, + { status: 403 }, + ); + } + + return NextResponse.next(); +}); + +export const config = { + matcher: ['/admin/:path*', '/api/admin/:path*'], +};