From 32ef93ae8ede44b530e73c7d020cd5f48e3fe693 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 20 Jul 2026 00:29:57 +0200 Subject: [PATCH] feat: SSO-Login per OpenID Connect (Authentik u.a.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 12 +++ CHANGELOG.md | 17 +++ DEPLOYMENT.md | 26 +++++ app/api/auth/oidc/callback/route.ts | 60 +++++++++++ app/api/auth/oidc/login/route.ts | 31 ++++++ app/login/page.tsx | 152 ++------------------------ components/LoginForm.tsx | 162 ++++++++++++++++++++++++++++ db/migrations/0003_oidc_state.sql | 12 +++ docker-compose.yml | 10 ++ lib/auth/oidc-client.ts | 99 +++++++++++++++++ lib/auth/oidc-config.ts | 35 ++++++ lib/auth/oidc-state.ts | 31 ++++++ lib/i18n/dictionaries.ts | 17 +++ package-lock.json | 10 ++ package.json | 3 +- 15 files changed, 533 insertions(+), 144 deletions(-) create mode 100644 app/api/auth/oidc/callback/route.ts create mode 100644 app/api/auth/oidc/login/route.ts create mode 100644 components/LoginForm.tsx create mode 100644 db/migrations/0003_oidc_state.sql create mode 100644 lib/auth/oidc-client.ts create mode 100644 lib/auth/oidc-config.ts create mode 100644 lib/auth/oidc-state.ts diff --git a/.env.example b/.env.example index 11a4889..2cb6a2e 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,15 @@ ADMIN_EMAIL= # Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen" # inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen. DEEPL_API_KEY= + +# Optional: SSO-Login per OpenID Connect (z. B. gegen ein selbst +# gehostetes Authentik). Ohne diese drei Werte bleibt der SSO-Button auf +# der Anmeldeseite ausgeblendet, Passwort/Passkey funktionieren +# unverändert. SSO-Login funktioniert NUR für bereits unter "Benutzer" +# angelegte E-Mail-Adressen – es werden keine neuen Konten angelegt und +# die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich dort +# steuerbar. Beim Identity Provider als Redirect-URI eintragen: +# /api/auth/oidc/callback +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d759f8..05227e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). +## [1.9.0] – 2026-07-20 + +### Hinzugefügt +- SSO-Login per OpenID Connect (z. B. gegen ein selbst gehostetes + Authentik), zusätzlich zu Passwort und Passkey. Konfiguration über + drei optionale Env-Vars (`OIDC_ISSUER`, `OIDC_CLIENT_ID`, + `OIDC_CLIENT_SECRET`) – ohne sie bleibt der SSO-Button auf der + Anmeldeseite ausgeblendet. Authorization-Code-Flow mit PKCE, ID-Token- + Signaturprüfung gegen das JWKS des Providers (neue Abhängigkeit + `jose`), State/Nonce serverseitig kurzlebig und einmalig verwendbar + (neue Tabelle `oidc_states`, Migration `0003_oidc_state.sql`). + SSO-Login funktioniert ausschließlich für bereits unter „Benutzer“ + angelegte E-Mail-Adressen – es werden nie neue Konten angelegt, und + die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich über die + bestehende Benutzerverwaltung steuerbar, nie aus SSO-Claims + übernommen. In DEPLOYMENT.md unter „SSO/OpenID Connect“ dokumentiert. + ## [1.8.1] – 2026-07-20 ### Hinzugefügt diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 49c81f8..31b00c2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -149,6 +149,32 @@ docker compose --env-file .env.local up -d werden im Kantinen-Bereich gepflegt. - Logo/Favicon/Farben unter „Design“ (nur Admin) anpassen. +## SSO/OpenID Connect (optional) + +Zusätzlich zu Passwort und Passkey kann ein SSO-Login gegen einen +beliebigen OpenID-Connect-Provider aktiviert werden (z. B. ein selbst +gehostetes [Authentik](https://goauthentik.io/)). Dazu in `.env.local` +setzen: + +```bash +OIDC_ISSUER=https://authentik.example.de/application/o/canteen-menu/ +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +``` + +Beim Identity Provider als Redirect-URI hinterlegen: +`/api/auth/oidc/callback` (also z. B. +`https://menu.example.de/api/auth/oidc/callback`). + +**Wichtig:** SSO-Login funktioniert ausschließlich für E-Mail-Adressen, +die bereits unter „Benutzer“ angelegt sind – es werden nie neue Konten +angelegt, und die Rolle (Admin/Kantinenmitarbeiter) bleibt ausschließlich +dort steuerbar. Ein Mitarbeiter muss also zuerst ganz normal als +Benutzer angelegt werden, bevor er sich per SSO anmelden kann. + +Ohne die drei Variablen bleibt der SSO-Button auf der Anmeldeseite +einfach ausgeblendet, Passwort/Passkey funktionieren unverändert. + ## Fehlerbehebung ### `password authentication failed for user "postgres"` in den App-Logs diff --git a/app/api/auth/oidc/callback/route.ts b/app/api/auth/oidc/callback/route.ts new file mode 100644 index 0000000..000044d --- /dev/null +++ b/app/api/auth/oidc/callback/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { createSession } from "@/lib/auth/session"; +import { isConfigured } from "@/lib/auth/oidc-config"; +import { consumeState } from "@/lib/auth/oidc-state"; +import { discover, exchangeCode, resolveEmail, verifyIdToken } from "@/lib/auth/oidc-client"; +import type { Role } from "@/lib/types"; + +function errorRedirect(request: Request, code: string): NextResponse { + return NextResponse.redirect(new URL(`/login?error=${code}`, request.url)); +} + +// GET statt POST: der Identity Provider leitet den Browser per +// Redirect hierher (Cross-Origin-Navigation) – die übliche +// isSameOrigin()-CSRF-Prüfung greift hier nicht und wird bewusst nicht +// verwendet. Die Absicherung ist der einmalig verwendbare state- +// Parameter (Standard-OIDC-Mechanismus, siehe lib/auth/oidc-state.ts). +export async function GET(request: Request) { + if (!isConfigured()) { + return errorRedirect(request, "oidc_failed"); + } + + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const idpError = url.searchParams.get("error"); + if (idpError || !code || !state) { + return errorRedirect(request, "oidc_failed"); + } + + const stored = await consumeState(state); + if (!stored) { + return errorRedirect(request, "oidc_invalid_state"); + } + + try { + const discovery = await discover(); + const { idToken, accessToken } = await exchangeCode(discovery, code, stored.codeVerifier); + const payload = await verifyIdToken(discovery, idToken, stored.nonce); + const email = await resolveEmail(discovery, payload, accessToken); + if (!email) { + return errorRedirect(request, "oidc_failed"); + } + + const user = await queryOne<{ id: string; role: Role }>( + "select id, role from users where email = $1", + [email.trim().toLowerCase()] + ); + if (!user) { + return errorRedirect(request, "oidc_unknown_user"); + } + + await createSession(user.id); + return NextResponse.redirect( + new URL(user.role === "admin" ? "/admin" : "/intern", request.url) + ); + } catch { + return errorRedirect(request, "oidc_failed"); + } +} diff --git a/app/api/auth/oidc/login/route.ts b/app/api/auth/oidc/login/route.ts new file mode 100644 index 0000000..8f97b76 --- /dev/null +++ b/app/api/auth/oidc/login/route.ts @@ -0,0 +1,31 @@ +import { createHash, randomBytes } from "node:crypto"; +import { NextResponse } from "next/server"; +import { isConfigured } from "@/lib/auth/oidc-config"; +import { storeState } from "@/lib/auth/oidc-state"; +import { buildAuthorizeUrl, discover } from "@/lib/auth/oidc-client"; + +// Bewusst ein einfacher GET-Link statt eines CSRF-gesicherten POSTs: +// der Browser muss hier auf die fremde Login-Seite des Identity +// Providers navigieren (funktioniert nicht per fetch/XHR) – identisch +// zum Muster jeder "Login mit X"-Schaltfläche. Die Absicherung gegen +// Cross-Site-Missbrauch übernimmt der state-Parameter im Callback. +export async function GET(request: Request) { + if (!isConfigured()) { + return NextResponse.json({ error: "oidc_not_configured" }, { status: 404 }); + } + + const state = randomBytes(32).toString("base64url"); + const nonce = randomBytes(32).toString("base64url"); + const codeVerifier = randomBytes(32).toString("base64url"); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + + try { + const discovery = await discover(); + await storeState(state, nonce, codeVerifier); + return NextResponse.redirect( + buildAuthorizeUrl(discovery, { state, nonce, codeChallenge }) + ); + } catch { + return NextResponse.redirect(new URL("/login?error=oidc_failed", request.url)); + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 1ebb374..87d4395 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,145 +1,11 @@ -"use client"; +import LoginForm from "@/components/LoginForm"; +import { isConfigured } from "@/lib/auth/oidc-config"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { startAuthentication } from "@simplewebauthn/browser"; -import { formatMessage } from "@/lib/i18n/shared"; -import { useDict } from "@/components/i18n/LocaleProvider"; -import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; -import type { Dict } from "@/lib/i18n/shared"; - -function authErrorMessage(dict: Dict, code: string): string { - if (code === "invalid_credentials") return dict.login.errorInvalidCredentials; - if (code === "password_disabled") return dict.login.errorPasswordDisabled; - if (/cancelled|aborted|NotAllowedError/i.test(code)) - return dict.login.errorPasskeyCancelled; - return formatMessage(dict.login.errorGeneric, { message: code }); -} - -function redirectPath(role: string): string { - return role === "admin" ? "/admin" : "/intern"; -} - -export default function LoginPage() { - const router = useRouter(); - const dict = useDict(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - async function signInWithPassword(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setBusy(true); - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - setError(authErrorMessage(dict, data.error ?? "unknown")); - setBusy(false); - return; - } - router.replace(redirectPath(data.role)); - router.refresh(); - } - - async function signInWithPasskey() { - setError(null); - setBusy(true); - try { - const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" }); - const options = await optionsRes.json(); - const response = await startAuthentication({ optionsJSON: options }); - - const verifyRes = await fetch("/api/auth/passkeys/login-verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ response }), - }); - const data = await verifyRes.json().catch(() => ({})); - if (!verifyRes.ok) { - setError(authErrorMessage(dict, data.error ?? "unknown")); - setBusy(false); - return; - } - router.replace(redirectPath(data.role)); - router.refresh(); - } catch (err) { - setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err))); - setBusy(false); - } - } - - return ( -
-
-
-

- {dict.login.title} -

- -
-
-
- - setEmail(e.target.value)} - autoComplete="username webauthn" - required - /> -
-
- - setPassword(e.target.value)} - autoComplete="current-password" - required - /> -
- {error &&

{error}

} - -
- -
- - {dict.login.or} - -
- - - -

- - {dict.login.backToMenu} - -

-
-
- ); +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string }>; +}) { + const { error } = await searchParams; + return ; } diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..17f9cdb --- /dev/null +++ b/components/LoginForm.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { startAuthentication } from "@simplewebauthn/browser"; +import { formatMessage } from "@/lib/i18n/shared"; +import { useDict } from "@/components/i18n/LocaleProvider"; +import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; +import type { Dict } from "@/lib/i18n/shared"; + +function authErrorMessage(dict: Dict, code: string): string { + if (code === "invalid_credentials") return dict.login.errorInvalidCredentials; + if (code === "password_disabled") return dict.login.errorPasswordDisabled; + if (code === "oidc_invalid_state") return dict.login.errorOidcInvalidState; + if (code === "oidc_unknown_user") return dict.login.errorOidcUnknownUser; + if (code === "oidc_failed") return dict.login.errorOidcFailed; + if (/cancelled|aborted|NotAllowedError/i.test(code)) + return dict.login.errorPasskeyCancelled; + return formatMessage(dict.login.errorGeneric, { message: code }); +} + +function redirectPath(role: string): string { + return role === "admin" ? "/admin" : "/intern"; +} + +export default function LoginForm({ + oidcEnabled, + initialError, +}: { + oidcEnabled: boolean; + initialError: string | null; +}) { + const router = useRouter(); + const dict = useDict(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(() => + initialError ? authErrorMessage(dict, initialError) : null + ); + const [busy, setBusy] = useState(false); + + async function signInWithPassword(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(authErrorMessage(dict, data.error ?? "unknown")); + setBusy(false); + return; + } + router.replace(redirectPath(data.role)); + router.refresh(); + } + + async function signInWithPasskey() { + setError(null); + setBusy(true); + try { + const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" }); + const options = await optionsRes.json(); + const response = await startAuthentication({ optionsJSON: options }); + + const verifyRes = await fetch("/api/auth/passkeys/login-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response }), + }); + const data = await verifyRes.json().catch(() => ({})); + if (!verifyRes.ok) { + setError(authErrorMessage(dict, data.error ?? "unknown")); + setBusy(false); + return; + } + router.replace(redirectPath(data.role)); + router.refresh(); + } catch (err) { + setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err))); + setBusy(false); + } + } + + return ( +
+
+
+

+ {dict.login.title} +

+ +
+
+
+ + setEmail(e.target.value)} + autoComplete="username webauthn" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+ {error &&

{error}

} + +
+ +
+ + {dict.login.or} + +
+ + + + {oidcEnabled && ( + + {dict.login.ssoButton} + + )} + +

+ + {dict.login.backToMenu} + +

+
+
+ ); +} diff --git a/db/migrations/0003_oidc_state.sql b/db/migrations/0003_oidc_state.sql new file mode 100644 index 0000000..42909cc --- /dev/null +++ b/db/migrations/0003_oidc_state.sql @@ -0,0 +1,12 @@ +-- Kurzlebiger, einmalig verwendbarer State-Store für den OpenID-Connect- +-- Login (SSO, z. B. gegen Authentik). Analog zu webauthn_challenges: +-- der "state"-Wert selbst ist die CSRF-Absicherung für den Callback +-- (der Callback ist ein Cross-Origin-GET vom Identity Provider, der +-- normale isSameOrigin()-Check greift dort nicht). +create table oidc_states ( + state text primary key, + nonce text not null, + code_verifier text not null, + expires_at timestamptz not null, + created_at timestamptz not null default now() +); diff --git a/docker-compose.yml b/docker-compose.yml index 3a49bf9..c178622 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,13 @@ services: # (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key # bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer. DEEPL_API_KEY: ${DEEPL_API_KEY:-} + # Optional: SSO-Login per OpenID Connect (z. B. Authentik). Ohne + # diese drei Werte bleibt der SSO-Button ausgeblendet. Funktioniert + # nur für bereits angelegte Benutzer (Abgleich per E-Mail), legt nie + # neue Konten an. + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} # Muss auf das "volumes:"-Ziel unten UND das im Dockerfile per # "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese # Variable würde lib/branding.ts auf ${PWD}/data/branding @@ -77,6 +84,9 @@ services: SITE_TITLE: ${SITE_TITLE:-Canteen Menu} ADMIN_EMAIL: ${ADMIN_EMAIL:-} DEEPL_API_KEY: ${DEEPL_API_KEY:-} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} ports: - "0.0.0.0:3000:3000" volumes: diff --git a/lib/auth/oidc-client.ts b/lib/auth/oidc-client.ts new file mode 100644 index 0000000..d058b31 --- /dev/null +++ b/lib/auth/oidc-client.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/lib/auth/oidc-config.ts b/lib/auth/oidc-config.ts new file mode 100644 index 0000000..6edaead --- /dev/null +++ b/lib/auth/oidc-config.ts @@ -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`; +} diff --git a/lib/auth/oidc-state.ts b/lib/auth/oidc-state.ts new file mode 100644 index 0000000..954563d --- /dev/null +++ b/lib/auth/oidc-state.ts @@ -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 { + // 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; +} diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts index afa1e55..5672ac2 100644 --- a/lib/i18n/dictionaries.ts +++ b/lib/i18n/dictionaries.ts @@ -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ń", diff --git a/package-lock.json b/package-lock.json index 08eda67..d889565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", + "jose": "^6.2.3", "next": "16.2.10", "pg": "^8.22.0", "react": "19.2.4", @@ -4930,6 +4931,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 93a24d8..f124cd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canteen-menu", - "version": "1.8.1", + "version": "1.9.0", "private": true, "scripts": { "predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs", @@ -15,6 +15,7 @@ "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", + "jose": "^6.2.3", "next": "16.2.10", "pg": "^8.22.0", "react": "19.2.4",