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,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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user