A viewer account has exactly two causes, fixed in completely different places: the identity provider sent no groups at all, or it sent groups that do not include the one granting write access. From the outside the two look identical, so the dashboard now says which it is and what to do about it, and the sign-in logs the same thing server-side. The groups are carried in the session for that purpose, capped so the cookie cannot grow with someone's group membership. Group names are not secrets, and a support conversation that starts with the actual claim is a thirty-second fix rather than a guessing game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
87 lines
3.0 KiB
TypeScript
87 lines
3.0 KiB
TypeScript
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;
|