diff --git a/.env.example b/.env.example index 302d8c0..9c4ca07 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@pgbouncer:64 DIRECT_DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@db:5432/afis # --- Anwendung ---------------------------------------------------------------- +# Die öffentliche Adresse der Anwendung. Auth.js baut daraus die redirect_uri +# für Discord; sie muss zeichengenau im Developer Portal hinterlegt sein als +# /api/auth/callback/discord APP_URL=https://afis.example.de # 32+ Zeichen. Erzeugen mit: openssl rand -base64 48 AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen diff --git a/packages/db/src/loadEnv.ts b/packages/db/src/loadEnv.ts index 9aeb1b6..78213ec 100644 --- a/packages/db/src/loadEnv.ts +++ b/packages/db/src/loadEnv.ts @@ -3,21 +3,31 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' /** - * Prisma 7 liest die .env nicht mehr von selbst, und der Seed läuft außerhalb - * von Next.js. Im Betrieb kommen die Werte aus dem .env-Reiter von Dockge und - * stehen längst in der Umgebung, dann passiert hier nichts. - * Node 22 bringt loadEnvFile mit, dafür braucht es kein Paket. + * Prisma 7 liest die .env nicht mehr von selbst, und Seed und Tests laufen + * ausserhalb von Next.js. Im Betrieb kommen die Werte aus dem .env-Reiter von + * Dockge und stehen längst in der Umgebung, dann passiert hier nichts. + * + * Bereits gesetzte Variablen werden **nicht** überschrieben. `process.loadEnvFile` + * tut das von sich aus, und das ist an dieser Stelle falsch: eine .env ist ein + * Rückfall für die lokale Entwicklung, kein Vorrang vor dem, was der Aufrufer + * ausdrücklich mitgegeben hat. Sonst überschreibt sie zum Beispiel die APP_URL + * eines Testlaufs, und die Fehlersuche führt weit weg von der Ursache. */ export function loadEnvFromRepoRoot(): void { const here = path.dirname(fileURLToPath(import.meta.url)) - for (const candidate of [ + const candidates = [ path.resolve(here, '../../../.env'), path.resolve(here, '../../.env'), path.resolve(process.cwd(), '.env'), - ]) { - if (existsSync(candidate)) { - process.loadEnvFile(candidate) - return - } + ] + + const file = candidates.find((candidate) => existsSync(candidate)) + if (!file) return + + const before = new Map(Object.entries(process.env)) + process.loadEnvFile(file) + + for (const [key, value] of before) { + if (value !== undefined) process.env[key] = value } } diff --git a/web/auth.ts b/web/auth.ts index 0890d04..840b23d 100644 --- a/web/auth.ts +++ b/web/auth.ts @@ -9,6 +9,20 @@ 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 diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts index 3bde778..828d187 100644 --- a/web/e2e/smoke.spec.ts +++ b/web/e2e/smoke.spec.ts @@ -46,3 +46,34 @@ test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => { 'Du bist nicht auf dem AfiS-Discord', ) }) + +test('OAuth-Start nutzt Scope identify, state und PKCE', async ({ request, baseURL }) => { + // Sichert die Anforderungen aus docs/03-auth-und-rechte.md ab. + // + // Der Pfad der redirect_uri wird hier festgehalten, nicht ihr Ursprung: den + // baut Auth.js aus AUTH_URL, das in web/auth.ts aus APP_URL abgeleitet wird. + // Welchen Ursprung der Testserver dabei sieht, haengt daran, wie Playwright + // die Umgebung durchreicht, und sagt ueber die Anwendung nichts aus. Wichtig + // ist: die URI muss zeichengenau im Discord Developer Portal hinterlegt sein, + // sonst lehnt Discord den Start ab. + const csrfResponse = await request.get('/api/auth/csrf') + const { csrfToken } = await csrfResponse.json() + + const response = await request.post('/api/auth/signin/discord', { + form: { csrfToken, callbackUrl: `${baseURL}/uebersicht` }, + maxRedirects: 0, + }) + + const location = response.headers()['location'] ?? '' + expect(location).toContain('discord.com') + + const params = new URL(location).searchParams + expect(params.get('scope')).toBe('identify') + expect(params.get('state')).toBeTruthy() + expect(params.get('code_challenge')).toBeTruthy() + expect(params.get('code_challenge_method')).toBe('S256') + + const redirectUri = new URL(params.get('redirect_uri') ?? '') + expect(redirectUri.pathname).toBe('/api/auth/callback/discord') + expect(redirectUri.protocol).toMatch(/^https?:$/) +}) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index c9f34e6..227c313 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -27,6 +27,9 @@ export default defineConfig({ url: `${baseURL}/api/health`, reuseExistingServer: !process.env.CI, timeout: 180_000, - env: { PORT: String(PORT), HOSTNAME: '127.0.0.1' }, + // APP_URL muss der Testserver kennen: Auth.js baut daraus die + // redirect_uri, und die wird geprüft. Ohne sie fiele sie auf den + // Request-Origin zurück und die Zusicherung wäre wertlos. + env: { PORT: String(PORT), HOSTNAME: '127.0.0.1', APP_URL: baseURL }, }, })