CI / pruefen (pull_request) Canceled after 0s
Ein Passkey ist der dritte Weg in denselben Trichter: finishLogin() prueft die Antwort und gibt die Kennung zurueck, der Endpunkt legt damit ueber createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die Sitzungsschicht bleibt unangetastet. Die Trennung zwischen Pruefung und Sitzung ist Absicht: in passkeys.ts steht nur WebAuthn, das Setzen von Cookie und Sitzungszeile gehoert zur HTTP-Schicht. Die Challenge liegt in einem signierten, fuenf Minuten gueltigen httpOnly- Cookie statt in einer Tabelle — das spart Schema und Aufraeumjob fuer alles, was liegen bleibt, wenn jemand den Dialog abbricht. Die RP ID kommt aus APP_URL, nicht aus dem Host-Header. Hinter einem Reverse Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen Namen gebunden. Ueber nacktes HTTP verschwindet die Verwaltung mit einem Hinweis statt einen kryptischen Browserfehler zu zeigen. Den letzten verbleibenden Anmeldeweg laesst sich nicht entfernen: wer kein Passwort und keine OIDC-Verknuepfung hat, behaelt seinen letzten Passkey. Die Bibliothek prueft den Signaturzaehler selbst und wirft dabei gewoehnliche Error. Die werden jetzt uebersetzt, damit eine erwartete Ablehnung nicht als unerwarteter Serverfehler im Log landet. Die eigene Zaehlerpruefung bleibt als zusaetzliche Sicherung fuer den Fall, den die Bibliothek durchlaesst. Geprueft mit virtuellem Authenticator gegen einen Produktionsbau: anlegen, abmelden, Anmeldung allein per Passkey, Sitzungscookie gesetzt, Challenge-Cookie verbraucht, Passwortweg unveraendert. Mit kuenstlich hochgesetztem Zaehler wird die Anmeldung abgelehnt und der Zaehler bleibt stehen. Dazu sieben Tests fuer Verwaltung, Rechte und die Aussperrsperre. Refs #6
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
import "server-only";
|
|
|
|
/**
|
|
* Zentraler, geprüfter Zugriff auf die Umgebungsvariablen.
|
|
*
|
|
* Absicht: Tippfehler und fehlende Pflichtwerte fallen beim Start auf, nicht
|
|
* erst irgendwo tief in einem Request. Optionale Bausteine (TMDB, SMTP, OIDC)
|
|
* melden sich stattdessen über `isConfigured`-Flags ab.
|
|
*/
|
|
|
|
function required(name: string, fallback?: string): string {
|
|
const value = process.env[name] ?? fallback;
|
|
if (!value) {
|
|
throw new Error(
|
|
`Umgebungsvariable ${name} fehlt. Bitte in der .env setzen — siehe .env.example.`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optional(name: string, fallback = ""): string {
|
|
return process.env[name]?.trim() ?? fallback;
|
|
}
|
|
|
|
function bool(name: string, fallback: boolean): boolean {
|
|
const raw = process.env[name]?.trim().toLowerCase();
|
|
if (raw === undefined || raw === "") return fallback;
|
|
return raw === "1" || raw === "true" || raw === "yes" || raw === "ja";
|
|
}
|
|
|
|
function int(name: string, fallback: number): number {
|
|
const raw = Number.parseInt(process.env[name] ?? "", 10);
|
|
return Number.isFinite(raw) ? raw : fallback;
|
|
}
|
|
|
|
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
|
|
export const env = {
|
|
appName: optional("APP_NAME", "Flimmer"),
|
|
appUrl: optional("APP_URL", "http://localhost:3000").replace(/\/+$/, ""),
|
|
databaseUrl: isBuildPhase
|
|
? optional("DATABASE_URL", "postgresql://build:build@localhost:5432/build")
|
|
: required("DATABASE_URL"),
|
|
authSecret: isBuildPhase ? optional("AUTH_SECRET", "build-time-placeholder") : required("AUTH_SECRET"),
|
|
dataDir: optional("DATA_DIR", "./data"),
|
|
timezone: optional("TZ", "Europe/Berlin"),
|
|
|
|
allowRegistrationDefault: bool("ALLOW_REGISTRATION", true),
|
|
|
|
tmdb: {
|
|
apiKey: optional("TMDB_API_KEY"),
|
|
/**
|
|
* Abweichende Adresse der TMDB-API. Gedacht für Instanzen hinter einem
|
|
* eigenen Proxy und für den Testbetrieb; leer lassen heißt: direkt zu TMDB.
|
|
*/
|
|
baseUrl: optional("TMDB_BASE_URL", "https://api.themoviedb.org/3").replace(/\/+$/, ""),
|
|
imageBaseUrl: optional("TMDB_IMAGE_BASE_URL", "https://image.tmdb.org/t/p").replace(/\/+$/, ""),
|
|
accessToken: optional("TMDB_ACCESS_TOKEN"),
|
|
language: optional("TMDB_LANGUAGE", "de-DE"),
|
|
region: optional("TMDB_REGION", "DE"),
|
|
get isConfigured() {
|
|
return Boolean(this.apiKey || this.accessToken);
|
|
},
|
|
},
|
|
|
|
scheduler: {
|
|
enabled: bool("ENABLE_SCHEDULER", false),
|
|
hour: Math.min(23, Math.max(0, int("SYNC_HOUR", 4))),
|
|
cronSecret: optional("CRON_SECRET"),
|
|
},
|
|
|
|
smtp: {
|
|
host: optional("SMTP_HOST"),
|
|
port: int("SMTP_PORT", 587),
|
|
user: optional("SMTP_USER"),
|
|
password: optional("SMTP_PASSWORD"),
|
|
secure: bool("SMTP_SECURE", false),
|
|
from: optional("SMTP_FROM", "Flimmer <flimmer@localhost>"),
|
|
get isConfigured() {
|
|
return Boolean(this.host);
|
|
},
|
|
},
|
|
|
|
passkeys: {
|
|
/**
|
|
* Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie
|
|
* kosten nichts, solange niemand einen anlegt.
|
|
*/
|
|
enabled: bool("ENABLE_PASSKEYS", true),
|
|
/**
|
|
* Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt
|
|
* aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse
|
|
* Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären
|
|
* plötzlich ungültig.
|
|
*/
|
|
get rpId(): string {
|
|
try {
|
|
return new URL(env.appUrl).hostname;
|
|
} catch {
|
|
return "localhost";
|
|
}
|
|
},
|
|
/**
|
|
* Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und
|
|
* localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig.
|
|
*/
|
|
get isAvailable(): boolean {
|
|
if (!this.enabled) return false;
|
|
try {
|
|
const url = new URL(env.appUrl);
|
|
if (url.protocol === "https:") return true;
|
|
return url.hostname === "localhost" || url.hostname.endsWith(".localhost");
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
},
|
|
|
|
oidc: {
|
|
issuer: optional("OIDC_ISSUER"),
|
|
clientId: optional("OIDC_CLIENT_ID"),
|
|
clientSecret: optional("OIDC_CLIENT_SECRET"),
|
|
scopes: optional("OIDC_SCOPES", "openid profile email"),
|
|
buttonLabel: optional("OIDC_BUTTON_LABEL", "Mit SSO anmelden"),
|
|
autoRegister: bool("OIDC_AUTO_REGISTER", true),
|
|
get isConfigured() {
|
|
return Boolean(this.issuer && this.clientId && this.clientSecret);
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
export const isProduction = process.env.NODE_ENV === "production";
|