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.
144 lines
4.7 KiB
TypeScript
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 };
|
|
}
|