Auth.js übernimmt nur den OAuth-Ablauf mit state und PKCE, Scope identify. Das Zugangstoken des Nutzers wird danach verworfen: die Rollen holt der Bot mit seinem eigenen Token. Die Sitzung steht in der Datenbank, im Cookie liegt nur userId und sessionId. Ohne die Zeile in Session ist das Cookie wertlos, und nur so lässt sich eine Sitzung zurückziehen. Wer sich anmelden darf, steckt in lib/auth/signInPolicy.ts statt im Callback: das ist die Regel, an der alles hängt, und sie gehört einzeln prüfbar. Kein Guild-Mitglied, kein Zugang. Keine Rolle mit grantsMembership, kein Zugang. Notfall-Admins umgehen die Rolle, aber nicht die Guild-Mitgliedschaft. RoleMapping trägt jetzt grantsMembership, category und isPresent. Der Worker spiegelt jede Rolle der Guild hierher, damit im Adminbereich nichts von Hand eingetragen werden muss. Gelöschte Rollen werden nicht entfernt, sondern auf isPresent = false gesetzt: beim Umzug auf den echten Server bleiben sie als Vorlage stehen. Der Worker gleicht über Gateway-Ereignisse und zusätzlich alle fünf Minuten voll ab. Verliert jemand seine AfiS-Rolle, beendet er dessen Sitzungen sofort und setzt isActive auf false - der Datensatz bleibt, damit Autorenangaben in alten Akten gültig bleiben. Das passiert im Worker und nicht in der Web-App, weil es dort erst beim nächsten Request des Betroffenen griffe. Zwei Widersprüche der Dokumente aufgelöst, festgehalten in docs/13-entscheidungen.md: der Ausschluss schlägt auch den Admin (das Codebeispiel in 03-auth-und-rechte.md übergeht ihn, die Reihenfolge eine Zeile darüber nicht), und versiegelte Akten fallen im Where-Fragment heraus statt in einer Prüfung danach. 45 Tests, davon die Negativfälle aus docs/03 gegen eine echte Datenbank: Stufe 2 sieht die Stufe-4-Akte nicht und findet sie auch nicht in der Trefferzahl, ein Ausgeschlossener mit Stufe 5 kommt nicht heran, ein Rollenentzug beendet die Sitzungen, und ein Ausfall der Discord-API sieht nicht aus wie eine verweigerte Mitgliedschaft.
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
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,
|
|
})
|
|
},
|
|
},
|
|
}
|
|
})
|