Compare commits

..
4 Commits
Author SHA1 Message Date
florian.wilde db868d1174 Merge feature/v1.9: SSO-Login per OpenID Connect (Authentik u.a.) 2026-07-20 00:34:11 +02:00
florian.wildeandClaude Sonnet 5 32ef93ae8e 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>
2026-07-20 00:29:57 +02:00
florian.wilde 73cbda65a1 Merge feature/update-script: Update-Skript für Linux-Deployments (update.sh) 2026-07-20 00:08:18 +02:00
florian.wildeandClaude Sonnet 5 3b5fbd1bcd feat: Update-Skript fuer Linux-Deployments (update.sh)
Stoppt den Docker-Stack, sichert lokale Aenderungen an
docker-compose.yml per git stash (falls vorhanden), fuehrt git pull
aus, spielt die Aenderungen zurueck und startet den Stack neu im
Build-Modus. Bricht bei git-pull- oder Stash-Konflikten kontrolliert
ab statt mit einem halb aktualisierten Stand weiterzumachen.

Version 1.8.1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 00:05:56 +02:00
16 changed files with 600 additions and 146 deletions
+12
View File
@@ -48,3 +48,15 @@ ADMIN_EMAIL=
# Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen" # Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen"
# inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen. # inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen.
DEEPL_API_KEY= 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:
# <APP_URL>/api/auth/oidc/callback
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
+28
View File
@@ -4,6 +4,34 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). 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
- `update.sh`: Update-Skript für Linux-Deployments – stoppt den Docker-
Stack, sichert lokale Änderungen an `docker-compose.yml` per
`git stash` (falls vorhanden), führt `git pull` aus, spielt die
Änderungen zurück und startet den Stack neu im Build-Modus. Bricht
bei einem `git pull`- oder Stash-Konflikt kontrolliert ab, statt mit
einem halb aktualisierten Stand weiterzumachen. In DEPLOYMENT.md unter
„Updates einspielen“ dokumentiert.
## [1.8.0] – 2026-07-19 ## [1.8.0] – 2026-07-19
### Hinzugefügt ### Hinzugefügt
+35
View File
@@ -149,6 +149,32 @@ docker compose --env-file .env.local up -d
werden im Kantinen-Bereich gepflegt. werden im Kantinen-Bereich gepflegt.
- Logo/Favicon/Farben unter „Design“ (nur Admin) anpassen. - 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=<client-id-aus-authentik>
OIDC_CLIENT_SECRET=<client-secret-aus-authentik>
```
Beim Identity Provider als Redirect-URI hinterlegen:
`<APP_URL>/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 ## Fehlerbehebung
### `password authentication failed for user "postgres"` in den App-Logs ### `password authentication failed for user "postgres"` in den App-Logs
@@ -225,6 +251,15 @@ Neue Datenbank-Migrationen (`db/migrations/*.sql`) werden beim Start
automatisch und idempotent angewendet, bestehende Daten bleiben automatisch und idempotent angewendet, bestehende Daten bleiben
erhalten. erhalten.
Alternativ übernimmt `./update.sh` den kompletten Ablauf: Stack stoppen,
lokale Änderungen an `docker-compose.yml` per `git stash` sichern (falls
vorhanden), `git pull`, Änderungen zurückspielen, Stack neu bauen und
starten:
```bash
./update.sh
```
## Backup ## Backup
Zwei Docker-Volumes enthalten alle persistenten Daten: Zwei Docker-Volumes enthalten alle persistenten Daten:
+60
View File
@@ -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");
}
}
+31
View File
@@ -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));
}
}
+9 -143
View File
@@ -1,145 +1,11 @@
"use client"; import LoginForm from "@/components/LoginForm";
import { isConfigured } from "@/lib/auth/oidc-config";
import { useState } from "react"; export default async function LoginPage({
import { useRouter } from "next/navigation"; searchParams,
import Link from "next/link"; }: {
import { startAuthentication } from "@simplewebauthn/browser"; searchParams: Promise<{ error?: string }>;
import { formatMessage } from "@/lib/i18n/shared"; }) {
import { useDict } from "@/components/i18n/LocaleProvider"; const { error } = await searchParams;
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; return <LoginForm oidcEnabled={isConfigured()} initialError={error ?? null} />;
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<string | null>(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 (
<div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-sm">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.login.title}
</h1>
<LanguageSwitcher variant="light" />
</div>
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label className="label" htmlFor="email">
{dict.common.email}
</label>
<input
id="email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username webauthn"
required
/>
</div>
<div>
<label className="label" htmlFor="password">
{dict.common.password}
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
{dict.login.submit}
</button>
</form>
<div className="my-4 flex items-center gap-3 text-xs text-gray-400">
<span className="h-px flex-1 bg-gray-200" />
{dict.login.or}
<span className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={signInWithPasskey}
className="btn btn-neutral w-full"
disabled={busy}
>
{dict.login.passkeyButton}
</button>
<p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
{dict.login.backToMenu}
</Link>
</p>
</div>
</div>
);
} }
+162
View File
@@ -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<string | null>(() =>
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 (
<div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-sm">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.login.title}
</h1>
<LanguageSwitcher variant="light" />
</div>
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label className="label" htmlFor="email">
{dict.common.email}
</label>
<input
id="email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username webauthn"
required
/>
</div>
<div>
<label className="label" htmlFor="password">
{dict.common.password}
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
{dict.login.submit}
</button>
</form>
<div className="my-4 flex items-center gap-3 text-xs text-gray-400">
<span className="h-px flex-1 bg-gray-200" />
{dict.login.or}
<span className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={signInWithPasskey}
className="btn btn-neutral w-full"
disabled={busy}
>
{dict.login.passkeyButton}
</button>
{oidcEnabled && (
<a href="/api/auth/oidc/login" className="btn btn-neutral w-full mt-2">
{dict.login.ssoButton}
</a>
)}
<p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
{dict.login.backToMenu}
</Link>
</p>
</div>
</div>
);
}
+12
View File
@@ -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()
);
+10
View File
@@ -34,6 +34,13 @@ services:
# (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key # (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key
# bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer. # bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer.
DEEPL_API_KEY: ${DEEPL_API_KEY:-} 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 # Muss auf das "volumes:"-Ziel unten UND das im Dockerfile per
# "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese # "mkdir/chown" vorbereitete Verzeichnis zeigen – ohne diese
# Variable würde lib/branding.ts auf ${PWD}/data/branding # Variable würde lib/branding.ts auf ${PWD}/data/branding
@@ -77,6 +84,9 @@ services:
SITE_TITLE: ${SITE_TITLE:-Canteen Menu} SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DEEPL_API_KEY: ${DEEPL_API_KEY:-} DEEPL_API_KEY: ${DEEPL_API_KEY:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
ports: ports:
- "0.0.0.0:3000:3000" - "0.0.0.0:3000:3000"
volumes: volumes:
+99
View File
@@ -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;
}
+35
View File
@@ -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`;
}
+31
View File
@@ -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;
}
+17
View File
@@ -46,12 +46,18 @@ const de = {
submit: "Anmelden", submit: "Anmelden",
or: "oder", or: "oder",
passkeyButton: "Mit Passkey anmelden", passkeyButton: "Mit Passkey anmelden",
ssoButton: "Mit Single Sign-On anmelden",
backToMenu: "← Zur Speisekarte", backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.", errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.", errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}", errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled: errorPasswordDisabled:
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.", "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: { publicMenu: {
prevWeek: "← Vorherige Woche", prevWeek: "← Vorherige Woche",
@@ -333,12 +339,17 @@ const en: Dict = {
submit: "Log in", submit: "Log in",
or: "or", or: "or",
passkeyButton: "Sign in with passkey", passkeyButton: "Sign in with passkey",
ssoButton: "Sign in with Single Sign-On",
backToMenu: "← Back to menu", backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.", errorInvalidCredentials: "Email address or password is incorrect.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.", errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}", errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled: errorPasswordDisabled:
"Password sign-in is disabled for this account. Please sign in with a passkey.", "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: { publicMenu: {
prevWeek: "← Previous week", prevWeek: "← Previous week",
@@ -618,12 +629,18 @@ const pl: Dict = {
submit: "Zaloguj się", submit: "Zaloguj się",
or: "lub", or: "lub",
passkeyButton: "Zaloguj się kluczem dostępu", passkeyButton: "Zaloguj się kluczem dostępu",
ssoButton: "Zaloguj się przez Single Sign-On",
backToMenu: "← Powrót do jadłospisu", backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.", errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.", errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}", errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled: errorPasswordDisabled:
"Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.", "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: { publicMenu: {
prevWeek: "← Poprzedni tydzień", prevWeek: "← Poprzedni tydzień",
+12 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.8.0", "version": "1.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.8.0", "version": "1.8.1",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10", "next": "16.2.10",
"pg": "^8.22.0", "pg": "^8.22.0",
"react": "19.2.4", "react": "19.2.4",
@@ -4930,6 +4931,15 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "1.8.0", "version": "1.9.0",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs", "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": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"jose": "^6.2.3",
"next": "16.2.10", "next": "16.2.10",
"pg": "^8.22.0", "pg": "^8.22.0",
"react": "19.2.4", "react": "19.2.4",
Executable
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Aktualisiert ein per Docker Compose betriebenes Deployment:
# Stack stoppen -> lokale docker-compose.yml-Änderungen sichern (git stash)
# -> git pull -> Änderungen zurückspielen -> Stack neu bauen und starten.
#
# Aufruf im Projektverzeichnis (dort, wo docker-compose.yml liegt):
# ./update.sh
set -euo pipefail
cd "$(dirname "$0")"
ENV_FILE=".env.local"
if [ ! -f "$ENV_FILE" ]; then
echo "Fehler: $ENV_FILE nicht gefunden. Bitte im Projektverzeichnis ausführen." >&2
exit 1
fi
echo "==> Stoppe laufenden Stack..."
docker compose --env-file "$ENV_FILE" down
STASHED=0
if ! git diff --quiet -- docker-compose.yml || ! git diff --cached --quiet -- docker-compose.yml; then
echo "==> Sichere lokale Änderungen an docker-compose.yml (git stash)..."
git stash push --message "update.sh: docker-compose.yml vor Pull gesichert" -- docker-compose.yml
STASHED=1
fi
echo "==> Hole neueste Version (git pull)..."
git pull
if [ "$STASHED" -eq 1 ]; then
echo "==> Stelle lokale Änderungen an docker-compose.yml wieder her..."
if ! git stash pop; then
echo "Fehler: git stash pop hatte einen Konflikt in docker-compose.yml." >&2
echo "Bitte den Konflikt manuell auflösen (git status), danach Stack manuell starten:" >&2
echo " docker compose --env-file $ENV_FILE up -d --build" >&2
exit 1
fi
fi
echo "==> Baue und starte Stack neu..."
docker compose --env-file "$ENV_FILE" up -d --build
echo "==> Fertig. Status:"
docker compose ps