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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
5.5 KiB
TypeScript
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,
|
|
})
|
|
},
|
|
},
|
|
}
|
|
})
|