import NextAuth from 'next-auth' import Discord from 'next-auth/providers/discord' import { db } from '@/lib/db' import { getEnv } from '@/lib/env' import { fetchGuildMember } from '@/lib/auth/discord' import { createSession, SESSION_MAX_AGE_MS } from '@/lib/auth/session' import { invalidateUser } from '@/lib/auth/effective' import { decideSignIn, SIGN_IN_ERRORS } from '@/lib/auth/signInPolicy' export { SIGN_IN_ERRORS } type DiscordProfile = { id: string username: string global_name?: string | null avatar?: string | null } /** * OAuth klärt, wer jemand ist. Der Bot klärt, was er darf. * * Auth.js übernimmt hier ausschliesslich den OAuth2-Ablauf mit `state` und * PKCE. Das Zugangstoken des Nutzers wird danach verworfen und nie wieder * benutzt: die Rollen holt der Bot mit seinem eigenen Token. * * Die Sitzung selbst steht in der Datenbank. Im Cookie liegt nur ein * signiertes Wertepaar aus `userId` und `sessionId` — ohne die Zeile in * `Session` ist es wertlos. Nur so lässt sich eine Sitzung zurückziehen, * wenn jemand seine Rolle verliert. Siehe docs/03-auth-und-rechte.md. */ export const { handlers, auth, signIn, signOut } = NextAuth(() => { const env = getEnv() return { trustHost: true, secret: env.AUTH_SECRET, providers: [ Discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET, // Nur `identify`. Kein `email`, kein `guilds`: was der Nutzer auf // anderen Servern tut, geht die Anwendung nichts an. authorization: { params: { scope: 'identify' } }, checks: ['pkce', 'state'], }), ], session: { strategy: 'jwt', maxAge: SESSION_MAX_AGE_MS / 1000 }, cookies: { sessionToken: { name: 'afis.sitzung', options: { httpOnly: true, sameSite: 'lax', path: '/', secure: process.env.NODE_ENV === 'production', }, }, }, pages: { signIn: '/anmelden', error: '/anmelden/fehler', }, callbacks: { async signIn({ profile }) { const discord = profile as DiscordProfile | undefined if (!discord?.id) return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.discordUnreachable}` let member try { member = await fetchGuildMember(discord.id) } catch (error) { console.error('[auth] Mitgliedschaft nicht prüfbar', error) return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.discordUnreachable}` } const isBootstrapAdmin = env.BOOTSTRAP_ADMIN_IDS.includes(discord.id) const grantingRoleCount = member ? await db.roleMapping.count({ where: { discordRoleId: { in: member.roleIds }, grantsMembership: true }, }) : 0 const decision = decideSignIn({ member, isBootstrapAdmin, grantingRoleCount }) // Abgelehnt heisst: kein Konto anlegen, keine Spur hinterlassen. if (!decision.ok) return `/anmelden/fehler?grund=${decision.reason}` if (!member) return `/anmelden/fehler?grund=${SIGN_IN_ERRORS.notInGuild}` const displayName = member.nickname ?? discord.global_name ?? discord.username await db.$transaction([ db.user.upsert({ where: { id: discord.id }, create: { id: discord.id, username: discord.username, displayName, avatarHash: discord.avatar ?? null, isActive: true, }, update: { username: discord.username, displayName, avatarHash: discord.avatar ?? null, isActive: true, lastSeenAt: new Date(), }, }), db.userRole.deleteMany({ where: { userId: discord.id, discordRoleId: { notIn: member.roleIds } }, }), ...member.roleIds.map((discordRoleId) => db.userRole.upsert({ where: { userId_discordRoleId: { userId: discord.id, discordRoleId } }, create: { userId: discord.id, discordRoleId }, update: {}, }), ), ]) invalidateUser(discord.id) return true }, async jwt({ token, account, profile }) { // Nur bei der ersten Anmeldung: eine Sitzungszeile anlegen. if (account && profile) { const discord = profile as DiscordProfile token.userId = discord.id token.sessionId = await createSession(discord.id) } return token }, session({ session, token }) { // In der Sitzung steht nur userId und sessionId. Rechte werden pro // Request aufgelöst, nie im Token mitgeführt. return Object.assign(session, { userId: token.userId as string | undefined, sessionId: token.sessionId as string | undefined, }) }, }, } })