Auth.js refuses to build a provider with no issuer, and that refusal takes down the whole auth layer — including reading a session that already exists. A missing or misspelled AUTH_AUTHENTIK_ISSUER would therefore lock everyone out of an otherwise healthy application, and explain itself only as a stack trace in the logs. The provider is now registered only when its three settings are present. Sessions stay readable either way, and the sign-in page says which variables are missing instead of offering a button that fails. Found by the first CI run, which has no .env to inherit from: every signed-in test failed at once, looking exactly like a broken cookie. The local suite had been passing on variables Playwright was quietly inheriting from the development environment — so the E2E server is now given explicit placeholders rather than whatever happens to be around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
105 lines
3.7 KiB
TypeScript
105 lines
3.7 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';
|
|
|
|
/**
|
|
* Whether Authentik is configured well enough to sign anyone in.
|
|
*
|
|
* Auth.js refuses to build a provider that has no issuer, and that refusal
|
|
* takes down the whole auth layer — including reading a session that already
|
|
* exists. A missing or misspelled variable would therefore lock everyone out
|
|
* of an application that is otherwise perfectly healthy, and say so only as a
|
|
* stack trace in the logs. Registering the provider only when it can work
|
|
* keeps sessions readable and lets the sign-in page explain itself.
|
|
*/
|
|
export const isAuthentikConfigured = Boolean(
|
|
process.env.AUTH_AUTHENTIK_ID &&
|
|
process.env.AUTH_AUTHENTIK_SECRET &&
|
|
process.env.AUTH_AUTHENTIK_ISSUER,
|
|
);
|
|
|
|
/** 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: isAuthentikConfigured
|
|
? [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;
|