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'; /** Enough to explain a read-only account, not enough to bloat the cookie. */ const MAX_REPORTED_GROUPS = 20; declare module 'next-auth' { interface Session { user: { email?: string | null; name?: string | null; image?: string | null; role: Role; /** The groups the identity provider sent, so the UI can explain itself. */ groups: string[]; /** The group that would grant write access. */ adminGroup: string; }; } } declare module '@auth/core/jwt' { interface JWT { role?: Role; groups?: string[]; } } 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) { const groups = groupsFromClaim(profile.groups); token.groups = groups.slice(0, MAX_REPORTED_GROUPS); token.role = roleFromGroups(groups, ADMIN_GROUP); if (token.role !== 'admin') { // The two failure modes look identical from the outside and are // fixed in completely different places, so say which one it is. // Group names are not secrets. console.info( `[auth] ${token.email ?? 'inconnu'} est en lecture seule. ` + `Groupes reçus : ${groups.length > 0 ? groups.join(', ') : '(aucun — le claim « groups » est absent)'}. ` + `Groupe attendu : ${ADMIN_GROUP}.`, ); } } return token; }, session({ session, token }) { session.user.role = token.role ?? 'viewer'; session.user.groups = token.groups ?? []; session.user.adminGroup = ADMIN_GROUP; return session; }, }, }); export const { auth, signIn, signOut } = nextAuth; export const { GET, POST } = nextAuth.handlers;