Im Betrieb aufgetreten: Die Anwendung lief hinter einem Reverse Proxy unter einer Domain, in der .env stand aber noch die interne Adresse aus der Einrichtung. Der Anmeldeknopf setzt den Cookie mit dem laufenden Vorgang auf dem Host, ueber den der Browser da ist — die Rueckkehradresse fuer den Anbieter kommt dagegen aus BASE_URL. Der Anbieter schickte den Browser also auf den anderen Host, der Cookie blieb zurueck, und am Ende stand „Der Anmeldevorgang ist abgelaufen". Eine Meldung, die die Ursache gut versteckt. Jetzt prueft der Start beide Adressen und bricht sofort mit einer Erklaerung ab. Auf dem Bildschirm steht nur die aufgerufene Adresse — die interne gehoert ins Protokoll, nicht auf eine Seite, die jeder sieht. Aus demselben Grund faellt der Hinweis „Die Anmeldung mit Passwort ist abgeschaltet (OIDC_ONLY=true)" weg. Wie die Anmeldung eingerichtet ist, geht Fremde nichts an; wer hierher gehoert, sieht den einen Knopf und weiss, was zu tun ist. Beim allerersten Aufruf bleibt der Hinweis, dass der Erste Administrator wird — der ist dann nuetzlich. Dazu die Anleitungen: Der Reverse-Proxy-Abschnitt im README nennt jetzt die drei Kopfzeilen, die die Anwendung vom Proxy erwartet, und was ohne sie passiert; dazu client_max_body_size fuer Uploads, ein Weg zum Nachpruefen und die Frage, fuer wen die Codes erreichbar sein sollen. In authentik.md steht, warum BASE_URL und die Adresse im Browser dieselbe sein muessen.
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { cookies } from "next/headers";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
import { isAuthEnabled } from "@/lib/auth/actor";
|
|
import {
|
|
assertHostMatchesBaseUrl,
|
|
discover,
|
|
getOidcConfig,
|
|
isOidcEnabled,
|
|
OIDC_FLOW_COOKIE,
|
|
OIDC_FLOW_MINUTES,
|
|
redirectUri,
|
|
} from "@/lib/auth/oidc";
|
|
import { errorResponse } from "@/lib/http";
|
|
import { QrRequestError } from "@/lib/qr/type-module";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function base64url(buffer: Buffer): string {
|
|
return buffer.toString("base64url");
|
|
}
|
|
|
|
/**
|
|
* Schickt den Browser zum Anbieter.
|
|
*
|
|
* Mit PKCE (RFC 7636): Der Code allein nützt niemandem, weil beim Einlösen
|
|
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
|
|
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
|
|
*/
|
|
export async function GET(request: NextRequest): Promise<Response> {
|
|
try {
|
|
if (!isAuthEnabled()) {
|
|
throw new QrRequestError(
|
|
"Die Anmeldung ist ausgeschaltet (AUTH_ENABLED=false).",
|
|
400,
|
|
);
|
|
}
|
|
if (!isOidcEnabled()) {
|
|
throw new QrRequestError(
|
|
"Die Anmeldung über einen externen Anbieter ist ausgeschaltet (OIDC_ENABLED=false).",
|
|
400,
|
|
);
|
|
}
|
|
|
|
// Vor allem anderen: Kommt der Browser über die Adresse, auf die
|
|
// BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren.
|
|
assertHostMatchesBaseUrl(request.headers);
|
|
|
|
const config = getOidcConfig();
|
|
const meta = await discover(config.issuer);
|
|
|
|
const state = base64url(randomBytes(32));
|
|
const nonce = base64url(randomBytes(32));
|
|
const codeVerifier = base64url(randomBytes(32));
|
|
const codeChallenge = base64url(
|
|
createHash("sha256").update(codeVerifier).digest(),
|
|
);
|
|
|
|
const store = await cookies();
|
|
store.set(OIDC_FLOW_COOKIE, JSON.stringify({ state, nonce, codeVerifier }), {
|
|
httpOnly: true,
|
|
// `lax` genügt: Der Anbieter schickt per GET zurück, dabei wird der
|
|
// Cookie mitgeschickt.
|
|
sameSite: "lax",
|
|
path: "/",
|
|
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
|
|
maxAge: OIDC_FLOW_MINUTES * 60,
|
|
});
|
|
|
|
const ziel = new URL(meta.authorization_endpoint);
|
|
ziel.searchParams.set("response_type", "code");
|
|
ziel.searchParams.set("client_id", config.clientId);
|
|
ziel.searchParams.set("redirect_uri", redirectUri());
|
|
ziel.searchParams.set("scope", config.scopes);
|
|
ziel.searchParams.set("state", state);
|
|
ziel.searchParams.set("nonce", nonce);
|
|
ziel.searchParams.set("code_challenge", codeChallenge);
|
|
ziel.searchParams.set("code_challenge_method", "S256");
|
|
|
|
return Response.redirect(ziel.toString(), 302);
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|