Files
flimmer/src/lib/oidc.ts
T
Florian Wilde 7f8ad716b6 feat: optionale SSO-Anmeldung über OIDC
Authorization Code mit PKCE, ID-Token gegen die JWKS des Anbieters geprüft.
Ohne Client-Bibliothek: Flimmer braucht genau einen Ablauf, dafür lohnt
keine weitere Abhängigkeit mit eigenem Versionsrhythmus.

Bekannte SSO-Kennungen werden verknüpft, sonst über die E-Mail-Adresse
einem vorhandenen Konto zugeordnet; das Anlegen neuer Konten lässt sich
per OIDC_AUTO_REGISTER unterbinden.
2026-09-01 18:46:47 +02:00

144 lines
4.7 KiB
TypeScript

import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { env } from "./env";
/**
* Schlanke OIDC-Anbindung (Authorization Code + PKCE) für Authelia, Keycloak,
* Authentik und Verwandte.
*
* Bewusst ohne Client-Bibliothek: Flimmer braucht genau einen Ablauf, und der
* passt in wenige Dutzend Zeilen — dafür lohnt keine weitere Abhängigkeit mit
* eigenem Versionsrhythmus.
*/
type Discovery = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
end_session_endpoint?: string;
};
let cached: { at: number; doc: Discovery } | null = null;
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
export async function discover(): Promise<Discovery> {
if (cached && Date.now() - cached.at < DISCOVERY_TTL_MS) return cached.doc;
const url = `${env.oidc.issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`OIDC-Discovery fehlgeschlagen (${response.status}) für ${url}`);
}
const doc = (await response.json()) as Discovery;
cached = { at: Date.now(), doc };
return doc;
}
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
async function getJwks() {
if (!jwks) {
const doc = await discover();
jwks = createRemoteJWKSet(new URL(doc.jwks_uri));
}
return jwks;
}
export const redirectUri = () => `${env.appUrl}/api/auth/oidc/callback`;
/** Erzeugt state und PKCE-Verifier für einen neuen Anmeldeversuch. */
export function createChallenge() {
const state = randomBytes(16).toString("base64url");
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { state, verifier, challenge };
}
export async function authorizationUrl(state: string, challenge: string): Promise<string> {
const doc = await discover();
const url = new URL(doc.authorization_endpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", env.oidc.clientId);
url.searchParams.set("redirect_uri", redirectUri());
url.searchParams.set("scope", env.oidc.scopes);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
export type OidcIdentity = {
issuer: string;
subject: string;
email: string | null;
name: string | null;
};
/** Tauscht den Code gegen Tokens und prüft das ID-Token. */
export async function exchangeCode(code: string, verifier: string): Promise<OidcIdentity> {
const doc = await discover();
const response = await fetch(doc.token_endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri(),
client_id: env.oidc.clientId,
client_secret: env.oidc.clientSecret,
code_verifier: verifier,
}),
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Token-Tausch fehlgeschlagen (${response.status}).`);
}
const tokens = (await response.json()) as { id_token?: string; access_token?: string };
if (!tokens.id_token) {
throw new Error("Der Anbieter hat kein ID-Token geliefert.");
}
const { payload } = await jwtVerify(tokens.id_token, await getJwks(), {
issuer: doc.issuer,
audience: env.oidc.clientId,
});
const subject = typeof payload.sub === "string" ? payload.sub : null;
if (!subject) throw new Error("Das ID-Token enthält keine Subjekt-Kennung.");
let email = typeof payload.email === "string" ? payload.email.toLowerCase() : null;
let name =
typeof payload.name === "string"
? payload.name
: typeof payload.preferred_username === "string"
? payload.preferred_username
: null;
// Manche Anbieter liefern Profilangaben erst am userinfo-Endpunkt.
if ((!email || !name) && doc.userinfo_endpoint && tokens.access_token) {
try {
const info = await fetch(doc.userinfo_endpoint, {
headers: { authorization: `Bearer ${tokens.access_token}` },
cache: "no-store",
});
if (info.ok) {
const profile = (await info.json()) as Record<string, unknown>;
if (!email && typeof profile.email === "string") email = profile.email.toLowerCase();
if (!name && typeof profile.name === "string") name = profile.name;
}
} catch {
// Profilangaben sind nur schmückendes Beiwerk — der Login klappt auch ohne.
}
}
return { issuer: doc.issuer, subject, email, name };
}