S1 (hoch): Die OIDC-Rueckkehr verknuepft eine Identitaet mit einem bestehenden Konto nur bei bestaetigter E-Mail — geprueft wurde das auch, nur war die Bestaetigung als `email_verified !== false` berechnet. Damit galt auch ein fehlender Claim als bestaetigt, und genau den schicken manche Anbieter gar nicht. Jetzt zaehlt allein ein ausdrueckliches `true`. S2 (hoch): `isAuthEnabled()` verlangte den Wert "true"; alles andere hiess "aus". Eine vergessene Variable ergab eine Anwendung, in der jeder Besucher alles darf, und nichts sah danach aus. Umgedreht: Abgeschaltet wird nur, wer ausdruecklich "false" setzt. Die Vorgaben in Compose und .env.example stehen auf true, und der Container warnt beim Start mehrzeilig, solange die Anmeldung aus ist. S5 (mittel): Der OIDC-Bootstrap las `user.count()` und legte danach an — zwei gleichzeitige erste Anmeldungen konnten beide Administrator werden. Jetzt in einer serialisierbaren Transaktion, wie die lokale Ersteinrichtung es laengst tut. S6 (niedrig): Die Herkunftspruefung verglich nur den Host, nicht das Schema. Das Schema kommt jetzt aus BASE_URL — die eine Angabe zum Ursprung, ueber die der Aufrufer nicht mitbestimmt. S3, S4 und S7 bleiben offen und stehen begruendet in docs/sicherheit.md. Bei S3 wurde der naheliegende Ausweg geprueft und verworfen: Waehrend der Sperre weiter Passwoerter zu pruefen kostet 128 MiB je Versuch und tauschte eine Sperre gegen ein Konto gegen eine gegen alle. Refs #20
341 lines
11 KiB
TypeScript
341 lines
11 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,
|
|
// Nur ein ausdrückliches `true` zählt. Ein fehlender Claim, `null`, `0`
|
|
// oder die Zeichenkette „true" gelten als **nicht** bestätigt.
|
|
//
|
|
// Vorher stand hier `!== false`, und damit war das Gegenteil der Fall:
|
|
// Ein Anbieter, der den Claim gar nicht schickt, hätte die Verknüpfung
|
|
// mit einem bestehenden Konto erlaubt — genau das, was die Prüfung in
|
|
// der Callback-Route verhindern soll.
|
|
emailVerified: payload.email_verified === true,
|
|
name,
|
|
groups: readGroups(payload, config.groupsClaim),
|
|
};
|
|
}
|