Files
AfiS-Aktensystem/web/auth.ts
T
Harlekin 3c3b71d175 Callback-URL aus APP_URL ableiten
Auth.js baut die redirect_uri nicht aus dem Host-Header, sondern aus AUTH_URL.
Ohne die Variable bleibt sie fest auf http://localhost:3000 - hinter dem
Cloudflare Tunnel wäre die Anmeldung damit kaputt gewesen, und zwar mit einer
Fehlermeldung von Discord, die nicht auf die Ursache zeigt. APP_URL ist ohnehin
die kanonische öffentliche Adresse und wird jetzt durchgereicht.

Nachweis mit dem gebauten Standalone-Server: mit gesetztem APP_URL kommt
https://afis.example.de/api/auth/callback/discord heraus, ohne fällt sie auf
localhost zurück. Ein E2E-Test hält Scope identify, state, PKCE mit S256 und
den Pfad der Callback-URL fest.

Nebenbefund, eigenständig behoben: loadEnvFromRepoRoot hat gesetzte
Umgebungsvariablen mit den Werten der Entwicklungs-.env überschrieben.
process.loadEnvFile tut das von sich aus. Eine .env ist ein Rückfall für die
lokale Entwicklung, kein Vorrang vor dem, was der Aufrufer ausdrücklich
mitgibt - sonst überschreibt sie etwa die APP_URL eines Testlaufs und die
Fehlersuche führt weit weg von der Ursache.
2026-09-01 19:37:09 +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,
})
},
},
}
})