Files
qrcode-generator/src/lib/auth/oidc.ts
T
Florian Wilde c38b27ca85 Anmeldung: Host-Abweichung frueh erkennen, weniger auf der Anmeldeseite verraten
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.
2026-08-17 00:28:31 +02:00

334 lines
10 KiB
TypeScript

import "server-only";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { getBaseUrl } from "@/lib/env";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Anmeldung über einen externen Anbieter (OpenID Connect) — gedacht und
* getestet für Authentik, aber nicht darauf festgelegt: Alles Nötige kommt
* aus dem Discovery-Dokument des Anbieters.
*
* Bewusst begrenzt auf die Frage „wer bist du?“. Wer in welcher Gruppe welche
* Rolle hat, bleibt Sache der Verwaltung dieser Anwendung — die Rollen hier
* bedeuten etwas anderes als die Gruppen im Verzeichnis, und eine automatische
* Übersetzung wäre eine Quelle stiller Überraschungen. Einzige Ausnahme ist
* die Instanz-Administration, die sich über `OIDC_ADMIN_GROUP` an eine Gruppe
* beim Anbieter hängen lässt.
*/
export type OidcConfig = {
issuer: string;
clientId: string;
clientSecret: string;
scopes: string;
/** Anzeigename des Knopfs auf der Anmeldeseite. */
buttonLabel: string;
/** Gruppe beim Anbieter, die zur Instanz-Administration berechtigt. */
adminGroup: string | null;
/** Name des Anspruchs, in dem die Gruppen stehen. */
groupsClaim: string;
/** Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen. */
defaultRole: "ADMIN" | "EDITOR" | "VIEWER";
/** Ist die Anmeldung mit Passwort daneben noch erlaubt? */
allowLocalLogin: boolean;
};
function env(name: string): string {
return process.env[name]?.trim() ?? "";
}
/**
* Cookie, der State, Nonce und PKCE-Geheimnis über die Weiterleitung zum
* Anbieter trägt. Lebt nur für die Dauer des Anmeldevorgangs.
*/
export const OIDC_FLOW_COOKIE = "qrgen_oidc_flow";
export const OIDC_FLOW_MINUTES = 10;
export function isOidcEnabled(): boolean {
return env("OIDC_ENABLED").toLowerCase() === "true";
}
/**
* Liest die Konfiguration. Wirft mit einer Meldung, die sagt, was fehlt —
* eine halb eingerichtete Anmeldung soll nicht als „irgendwas ging schief“
* enden.
*/
export function getOidcConfig(): OidcConfig {
const issuer = env("OIDC_ISSUER").replace(/\/+$/, "");
const clientId = env("OIDC_CLIENT_ID");
const clientSecret = env("OIDC_CLIENT_SECRET");
const fehlend = [
!issuer && "OIDC_ISSUER",
!clientId && "OIDC_CLIENT_ID",
!clientSecret && "OIDC_CLIENT_SECRET",
].filter(Boolean);
if (fehlend.length > 0) {
throw new QrRequestError(
`Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet. Es fehlt: ${fehlend.join(", ")}.`,
500,
);
}
const rolle = env("OIDC_DEFAULT_ROLE").toUpperCase();
return {
issuer,
clientId,
clientSecret,
scopes: env("OIDC_SCOPES") || "openid email profile",
buttonLabel: env("OIDC_BUTTON_LABEL") || "Mit Authentik anmelden",
adminGroup: env("OIDC_ADMIN_GROUP") || null,
groupsClaim: env("OIDC_GROUPS_CLAIM") || "groups",
defaultRole:
rolle === "ADMIN" || rolle === "EDITOR" || rolle === "VIEWER"
? rolle
: "VIEWER",
allowLocalLogin: env("OIDC_ONLY").toLowerCase() !== "true",
};
}
/** Adresse, an die der Anbieter nach der Anmeldung zurückschickt. */
export function redirectUri(): string {
return `${getBaseUrl()}/api/auth/oidc/callback`;
}
/**
* Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt.
*
* Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden
* Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die
* Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die
* beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen
* Host, der Cookie geht dabei verloren, und die Anmeldung endet mit
* „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche
* Ursache gut versteckt.
*
* Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt.
*/
export function assertHostMatchesBaseUrl(headers: Headers): void {
const angekommen = (
headers.get("x-forwarded-host") ??
headers.get("host") ??
""
)
.split(",")[0]
.trim()
.toLowerCase();
if (!angekommen) return;
let erwartet = "";
try {
erwartet = new URL(getBaseUrl()).host.toLowerCase();
} catch {
return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen.
}
if (angekommen === erwartet) return;
// Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server
// bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf
// ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die
// interne Adresse der Installation geht Fremde nichts an.
console.error(
`OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`,
);
throw new QrRequestError(
`Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` +
`In der Konfiguration steht eine andere Adresse, deshalb ginge der ` +
`Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` +
`findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` +
`Protokoll des Containers.`,
400,
);
}
type Discovery = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
end_session_endpoint?: string;
};
/**
* Das Discovery-Dokument ändert sich praktisch nie, der Abruf kostet aber bei
* jeder Anmeldung eine Runde. Deshalb im Speicher halten — eine Stunde ist
* kurz genug, um einen Schlüsselwechsel mitzubekommen.
*/
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
let discoveryCache: { url: string; bis: number; wert: Discovery } | null = null;
export async function discover(issuer: string): Promise<Discovery> {
const url = `${issuer}/.well-known/openid-configuration`;
const jetzt = Date.now();
if (discoveryCache && discoveryCache.url === url && discoveryCache.bis > jetzt) {
return discoveryCache.wert;
}
let response: Response;
try {
response = await fetch(url, { headers: { Accept: "application/json" } });
} catch {
throw new QrRequestError(
`Der Anmelde-Anbieter ist nicht erreichbar (${issuer}). Läuft er, und stimmt OIDC_ISSUER?`,
502,
);
}
if (!response.ok) {
throw new QrRequestError(
`Der Anmelde-Anbieter liefert unter ${url} den Status ${response.status}. Stimmt OIDC_ISSUER?`,
502,
);
}
const wert = (await response.json()) as Discovery;
if (!wert.authorization_endpoint || !wert.token_endpoint || !wert.jwks_uri) {
throw new QrRequestError(
"Der Anmelde-Anbieter liefert ein unvollständiges Discovery-Dokument.",
502,
);
}
discoveryCache = { url, bis: jetzt + DISCOVERY_TTL_MS, wert };
return wert;
}
/** JWKS je Adresse zwischenspeichern — `jose` erneuert bei Bedarf selbst. */
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function jwks(uri: string) {
const vorhanden = jwksCache.get(uri);
if (vorhanden) return vorhanden;
const neu = createRemoteJWKSet(new URL(uri));
jwksCache.set(uri, neu);
return neu;
}
export type OidcIdentity = {
subject: string;
email: string;
emailVerified: boolean;
name: string | null;
groups: string[];
};
function readGroups(payload: JWTPayload, claim: string): string[] {
const wert = payload[claim];
if (Array.isArray(wert)) {
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
}
// Manche Anbieter liefern eine durch Leerzeichen getrennte Liste.
if (typeof wert === "string") return wert.split(/\s+/).filter(Boolean);
return [];
}
/**
* Tauscht den Code gegen Token und prüft das ID-Token.
*
* Geprüft wird die Signatur gegen den JWKS des Anbieters sowie Aussteller,
* Empfänger und die Nonce. Letztere schließt aus, dass ein anderswo
* erbeutetes Token hier eingereicht wird.
*/
export async function exchangeCode(options: {
config: OidcConfig;
code: string;
codeVerifier: string;
nonce: string;
}): Promise<OidcIdentity> {
const { config, code, codeVerifier, nonce } = options;
const meta = await discover(config.issuer);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri(),
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: codeVerifier,
});
const response = await fetch(meta.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body,
});
if (!response.ok) {
// Der Text des Anbieters ist für Betreiber oft hilfreich, gehört aber
// nicht ungefiltert in den Browser.
const text = await response.text().catch(() => "");
console.error("OIDC: Token-Tausch fehlgeschlagen:", response.status, text);
throw new QrRequestError(
"Die Anmeldung beim Anbieter ist fehlgeschlagen. Stimmen Client-ID und Geheimnis?",
502,
);
}
const token = (await response.json()) as { id_token?: string };
if (!token.id_token) {
throw new QrRequestError(
"Der Anbieter hat kein ID-Token geliefert. Ist der Scope „openid“ erlaubt?",
502,
);
}
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(token.id_token, jwks(meta.jwks_uri), {
issuer: meta.issuer || config.issuer,
audience: config.clientId,
}));
} catch (error) {
console.error("OIDC: ID-Token nicht gültig:", error);
throw new QrRequestError("Das Anmelde-Token des Anbieters ist ungültig.", 502);
}
if (payload.nonce !== nonce) {
throw new QrRequestError(
"Die Anmeldung passt nicht zu dieser Sitzung. Bitte versuche es erneut.",
400,
);
}
const subject = typeof payload.sub === "string" ? payload.sub : "";
const email =
typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
if (!subject) {
throw new QrRequestError("Der Anbieter hat keine Kennung geliefert.", 502);
}
if (!email) {
throw new QrRequestError(
"Der Anbieter hat keine E-Mail-Adresse geliefert. Bitte den Scope „email“ freigeben und das Attribut mitgeben.",
502,
);
}
const name =
typeof payload.name === "string" && payload.name.trim().length > 0
? payload.name.trim()
: typeof payload.preferred_username === "string"
? payload.preferred_username.trim()
: null;
return {
subject,
email,
emailVerified: payload.email_verified !== false,
name,
groups: readGroups(payload, config.groupsClaim),
};
}