feat: authenticate against Authentik with admin and viewer roles

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 18:26:47 +02:00
co-authored by Claude Opus 5
parent 4557ee4f53
commit ac6be97d9b
13 changed files with 630 additions and 4 deletions
+59
View File
@@ -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 (
<div className="min-h-dvh">
<header className="border-b border-[var(--line)] bg-[var(--surface)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-3 px-4 py-3">
<Link href="/admin" className="flex items-center" aria-label="Tableau de bord">
<Image
src="/brand/logo.png"
alt="ITA ITO"
width={406}
height={194}
className="h-auto w-24"
/>
</Link>
<div className="ml-auto flex items-center gap-3 text-sm">
{!isAdmin ? (
<span className="rounded-[var(--radius-sm)] bg-[var(--surface-muted)] px-2 py-1 text-xs text-[var(--ink-muted)]">
Lecture seule
</span>
) : null}
<span className="hidden text-[var(--ink-muted)] sm:inline">{email}</span>
<form
action={async () => {
'use server';
await signOut({ redirectTo: '/login' });
}}
>
<button
type="submit"
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-3 py-1.5 text-sm transition-colors hover:bg-[var(--surface-muted)]"
>
Se déconnecter
</button>
</form>
</div>
</div>
</header>
{children}
</div>
);
}
+13 -4
View File
@@ -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 (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl">ITA ITO — Horaires</h1>
<p className="mt-2 text-[var(--ink-muted)]">Tableau de bord à venir.</p>
<main className="mx-auto max-w-5xl px-4 py-10">
<h1 className="text-2xl">Tableau de bord</h1>
<p className="mt-2 text-[var(--ink-muted)]">
Connecté en tant que {session?.user?.email} (
{session?.user?.role === 'admin' ? 'administrateur' : 'lecture seule'}).
</p>
</main>
);
}