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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2e71dfee78
commit
579244ded1
@@ -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
|
DIRECT_DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@db:5432/afis
|
||||||
|
|
||||||
# --- Anwendung ----------------------------------------------------------------
|
# --- 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
|
||||||
|
# <APP_URL>/api/auth/callback/discord
|
||||||
APP_URL=https://afis.example.de
|
APP_URL=https://afis.example.de
|
||||||
# 32+ Zeichen. Erzeugen mit: openssl rand -base64 48
|
# 32+ Zeichen. Erzeugen mit: openssl rand -base64 48
|
||||||
AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen
|
AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen
|
||||||
|
|||||||
+20
-10
@@ -3,21 +3,31 @@ import path from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prisma 7 liest die .env nicht mehr von selbst, und der Seed läuft außerhalb
|
* Prisma 7 liest die .env nicht mehr von selbst, und Seed und Tests laufen
|
||||||
* von Next.js. Im Betrieb kommen die Werte aus dem .env-Reiter von Dockge und
|
* ausserhalb von Next.js. Im Betrieb kommen die Werte aus dem .env-Reiter von
|
||||||
* stehen längst in der Umgebung, dann passiert hier nichts.
|
* Dockge und stehen längst in der Umgebung, dann passiert hier nichts.
|
||||||
* Node 22 bringt loadEnvFile mit, dafür braucht es kein Paket.
|
*
|
||||||
|
* 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 {
|
export function loadEnvFromRepoRoot(): void {
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
for (const candidate of [
|
const candidates = [
|
||||||
path.resolve(here, '../../../.env'),
|
path.resolve(here, '../../../.env'),
|
||||||
path.resolve(here, '../../.env'),
|
path.resolve(here, '../../.env'),
|
||||||
path.resolve(process.cwd(), '.env'),
|
path.resolve(process.cwd(), '.env'),
|
||||||
]) {
|
]
|
||||||
if (existsSync(candidate)) {
|
|
||||||
process.loadEnvFile(candidate)
|
const file = candidates.find((candidate) => existsSync(candidate))
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -9,6 +9,20 @@ import { decideSignIn, SIGN_IN_ERRORS } from '@/lib/auth/signInPolicy'
|
|||||||
|
|
||||||
export { SIGN_IN_ERRORS }
|
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 = {
|
type DiscordProfile = {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
@@ -46,3 +46,34 @@ test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => {
|
|||||||
'Du bist nicht auf dem AfiS-Discord',
|
'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?:$/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export default defineConfig({
|
|||||||
url: `${baseURL}/api/health`,
|
url: `${baseURL}/api/health`,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 180_000,
|
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 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user