feat: SSO-Login per OpenID Connect (Authentik u.a.)
Dritte Login-Methode neben Passwort und Passkey, konfiguriert über OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (optional, Button bleibt ohne diese Vars ausgeblendet). Authorization-Code-Flow mit PKCE, ID-Token-Signaturprüfung gegen das JWKS des Providers via neuer Abhängigkeit "jose" (RS256 fest gepinnt gegen Algorithm-Confusion), State/Nonce kurzlebig und einmalig verwendbar in neuer Tabelle oidc_states (Migration 0003, analog zu webauthn_challenges). Funktioniert ausschließlich für bereits angelegte Benutzer (Abgleich per E-Mail) – kein Auto-Provisioning, keine Rollenübernahme aus Claims, beides bleibt exklusiv über die bestehende Benutzerverwaltung steuerbar. app/login/page.tsx zu Server-Component-Wrapper reduziert (prüft isConfigured(), liest ?error= aus searchParams), bisheriger Inhalt nach components/LoginForm.tsx verschoben. Version 1.9.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import "server-only";
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||
import { clientId, clientSecret, issuer, redirectUri } from "./oidc-config";
|
||||
|
||||
export interface OidcDiscovery {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
jwks_uri: string;
|
||||
userinfo_endpoint?: string;
|
||||
}
|
||||
|
||||
// Kein Caching des Discovery-Dokuments: Login ist bei ~250 Mitarbeitern
|
||||
// kein Hot Path, ein zusätzlicher HTTPS-Request pro Anmeldeversuch
|
||||
// fällt nicht ins Gewicht – vermeidet Cache-Invalidierungslogik ohne
|
||||
// echten Bedarf.
|
||||
export async function discover(): Promise<OidcDiscovery> {
|
||||
const res = await fetch(`${issuer().replace(/\/$/, "")}/.well-known/openid-configuration`);
|
||||
if (!res.ok) throw new Error(`oidc_discovery_failed_${res.status}`);
|
||||
return (await res.json()) as OidcDiscovery;
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl(
|
||||
discovery: OidcDiscovery,
|
||||
params: { state: string; nonce: string; codeChallenge: string }
|
||||
): string {
|
||||
const url = new URL(discovery.authorization_endpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", clientId());
|
||||
url.searchParams.set("redirect_uri", redirectUri());
|
||||
url.searchParams.set("scope", "openid email profile");
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("nonce", params.nonce);
|
||||
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
discovery: OidcDiscovery,
|
||||
code: string,
|
||||
codeVerifier: string
|
||||
): Promise<{ idToken: string; accessToken: string | null }> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientId(),
|
||||
client_secret: clientSecret(),
|
||||
redirect_uri: redirectUri(),
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
const res = await fetch(discovery.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) throw new Error(`oidc_token_exchange_failed_${res.status}`);
|
||||
const data = (await res.json()) as { id_token?: string; access_token?: string };
|
||||
if (!data.id_token) throw new Error("oidc_missing_id_token");
|
||||
return { idToken: data.id_token, accessToken: data.access_token ?? null };
|
||||
}
|
||||
|
||||
// Algorithmus bewusst auf RS256 festgenagelt (Authentik-Standard) statt
|
||||
// dem Token zu vertrauen, welchen Algorithmus es selbst behauptet zu
|
||||
// nutzen ("algorithm confusion"-Angriffsklasse bei JWTs).
|
||||
export async function verifyIdToken(
|
||||
discovery: OidcDiscovery,
|
||||
idToken: string,
|
||||
expectedNonce: string
|
||||
): Promise<JWTPayload> {
|
||||
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
|
||||
const { payload } = await jwtVerify(idToken, jwks, {
|
||||
issuer: issuer(),
|
||||
audience: clientId(),
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
if (payload.nonce !== expectedNonce) {
|
||||
throw new Error("oidc_nonce_mismatch");
|
||||
}
|
||||
if (payload.email_verified === false) {
|
||||
throw new Error("oidc_email_not_verified");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** E-Mail bevorzugt aus dem ID-Token, sonst Fallback auf den Userinfo-Endpoint. */
|
||||
export async function resolveEmail(
|
||||
discovery: OidcDiscovery,
|
||||
payload: JWTPayload,
|
||||
accessToken: string | null
|
||||
): Promise<string | null> {
|
||||
if (typeof payload.email === "string" && payload.email) return payload.email;
|
||||
if (!accessToken || !discovery.userinfo_endpoint) return null;
|
||||
const res = await fetch(discovery.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const info = (await res.json()) as { email?: string };
|
||||
return typeof info.email === "string" && info.email ? info.email : null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import "server-only";
|
||||
|
||||
// SSO-Konfiguration wird direkt aus Env-Vars abgeleitet – kein
|
||||
// Cloud-Konfigurationsschritt, analog zu lib/auth/webauthn-config.ts.
|
||||
// "Aktiviert" ergibt sich implizit aus dem Vorhandensein der drei
|
||||
// Pflicht-Vars (wie bei DEEPL_API_KEY), kein separates Enable-Flag.
|
||||
|
||||
export function isConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.OIDC_ISSUER &&
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
export function issuer(): string {
|
||||
return process.env.OIDC_ISSUER!;
|
||||
}
|
||||
|
||||
export function clientId(): string {
|
||||
return process.env.OIDC_CLIENT_ID!;
|
||||
}
|
||||
|
||||
export function clientSecret(): string {
|
||||
return process.env.OIDC_CLIENT_SECRET!;
|
||||
}
|
||||
|
||||
// Bewusst aus APP_URL abgeleitet statt aus Request-Headern (wie z. B.
|
||||
// proxy.ts es für den Login-Redirect tut): der Wert muss exakt mit der
|
||||
// beim Identity Provider hinterlegten Redirect-URI übereinstimmen,
|
||||
// darf sich also nicht je nach eingehendem Host-Header ändern.
|
||||
export function redirectUri(): string {
|
||||
const appUrl = new URL(process.env.APP_URL ?? "http://localhost:3000");
|
||||
return `${appUrl.origin}/api/auth/oidc/callback`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import "server-only";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function storeState(
|
||||
state: string,
|
||||
nonce: string,
|
||||
codeVerifier: string
|
||||
): Promise<void> {
|
||||
// Kein Cron nötig: abgelaufene Zeilen bei jedem neuen State gleich
|
||||
// mit aufräumen (wie bei webauthn_challenges).
|
||||
await query("delete from oidc_states where expires_at < now()");
|
||||
await query(
|
||||
"insert into oidc_states (state, nonce, code_verifier, expires_at) values ($1, $2, $3, $4)",
|
||||
[state, nonce, codeVerifier, new Date(Date.now() + STATE_TTL_MS)]
|
||||
);
|
||||
}
|
||||
|
||||
/** Einmalig verwendbar: die Zeile wird beim Abruf sofort gelöscht. */
|
||||
export async function consumeState(
|
||||
state: string
|
||||
): Promise<{ nonce: string; codeVerifier: string } | null> {
|
||||
const row = await queryOne<{ nonce: string; code_verifier: string }>(
|
||||
`delete from oidc_states
|
||||
where state = $1 and expires_at > now()
|
||||
returning nonce, code_verifier`,
|
||||
[state]
|
||||
);
|
||||
return row ? { nonce: row.nonce, codeVerifier: row.code_verifier } : null;
|
||||
}
|
||||
@@ -46,12 +46,18 @@ const de = {
|
||||
submit: "Anmelden",
|
||||
or: "oder",
|
||||
passkeyButton: "Mit Passkey anmelden",
|
||||
ssoButton: "Mit Single Sign-On anmelden",
|
||||
backToMenu: "← Zur Speisekarte",
|
||||
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
|
||||
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
|
||||
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
|
||||
errorPasswordDisabled:
|
||||
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.",
|
||||
errorOidcInvalidState:
|
||||
"Die SSO-Anmeldung ist abgelaufen oder ungültig. Bitte erneut versuchen.",
|
||||
errorOidcUnknownUser:
|
||||
"Für diese E-Mail-Adresse existiert kein Konto. Bitte an die Verwaltung wenden.",
|
||||
errorOidcFailed: "SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
},
|
||||
publicMenu: {
|
||||
prevWeek: "← Vorherige Woche",
|
||||
@@ -333,12 +339,17 @@ const en: Dict = {
|
||||
submit: "Log in",
|
||||
or: "or",
|
||||
passkeyButton: "Sign in with passkey",
|
||||
ssoButton: "Sign in with Single Sign-On",
|
||||
backToMenu: "← Back to menu",
|
||||
errorInvalidCredentials: "Email address or password is incorrect.",
|
||||
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
|
||||
errorGeneric: "Sign-in failed: {message}",
|
||||
errorPasswordDisabled:
|
||||
"Password sign-in is disabled for this account. Please sign in with a passkey.",
|
||||
errorOidcInvalidState: "The SSO sign-in expired or was invalid. Please try again.",
|
||||
errorOidcUnknownUser:
|
||||
"No account exists for this email address. Please contact your administrator.",
|
||||
errorOidcFailed: "SSO sign-in failed. Please try again.",
|
||||
},
|
||||
publicMenu: {
|
||||
prevWeek: "← Previous week",
|
||||
@@ -618,12 +629,18 @@ const pl: Dict = {
|
||||
submit: "Zaloguj się",
|
||||
or: "lub",
|
||||
passkeyButton: "Zaloguj się kluczem dostępu",
|
||||
ssoButton: "Zaloguj się przez Single Sign-On",
|
||||
backToMenu: "← Powrót do jadłospisu",
|
||||
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
|
||||
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
|
||||
errorGeneric: "Logowanie nie powiodło się: {message}",
|
||||
errorPasswordDisabled:
|
||||
"Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.",
|
||||
errorOidcInvalidState:
|
||||
"Logowanie SSO wygasło lub jest nieprawidłowe. Spróbuj ponownie.",
|
||||
errorOidcUnknownUser:
|
||||
"Nie istnieje konto dla tego adresu e-mail. Skontaktuj się z administracją.",
|
||||
errorOidcFailed: "Logowanie SSO nie powiodło się. Spróbuj ponownie.",
|
||||
},
|
||||
publicMenu: {
|
||||
prevWeek: "← Poprzedni tydzień",
|
||||
|
||||
Reference in New Issue
Block a user