Files
AfiS-Aktensystem/web/auth.ts
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

164 lines
5.5 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 }
/**
* Auth.js leitet die Callback-URL **nicht** aus dem Host-Header ab, sondern aus
* `AUTH_URL`. Fehlt die Variable, bleibt die `redirect_uri` fest bei
* `http://localhost:3000` - hinter dem Cloudflare Tunnel wäre die Anmeldung
* damit kaputt, und zwar mit einer Fehlermeldung von Discord, die nicht auf die
* Ursache zeigt.
*
* `APP_URL` ist ohnehin die kanonische öffentliche Adresse, deshalb wird sie
* hier durchgereicht. Bewusst über `process.env` statt über `getEnv()`: dieses
* Modul wird auch beim Bauen des Images geladen, wo noch keine vollständige
* Konfiguration vorliegt.
*/
process.env.AUTH_URL ??= process.env.APP_URL
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,
})
},
},
}
})