feat(auth): add generic OIDC login with mandatory groups

This commit is contained in:
2026-08-17 05:44:10 +02:00
parent 202822f3ba
commit 4fe51cbeb1
20 changed files with 1144 additions and 40 deletions
+132 -8
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomBytes } from "node:crypto";
import type { AuthenticationConfigProvider, LoadedAuthConfig } from "./types.js";
import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import type { AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js";
@@ -8,12 +8,15 @@ import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCoo
import { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js";
import { verifyWithDummy } from "./password.js";
import type { OidcProtocol } from "./oidc-client.js";
const TEN_MINUTES_MS = 10 * 60 * 1000;
const REMEMBER_COOKIE_SECONDS = 2_592_000;
const MAX_USERNAME_LENGTH = 64;
const MAX_PASSWORD_LENGTH = 1024;
const MAX_LIMIT_ENTRIES = 10_000;
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
@@ -22,6 +25,8 @@ export interface AuthRouteDependencies {
/** Test-only compatibility seam; production resolves from each loaded config snapshot. */
localUserRegistry?: LocalUserRegistry;
resolveLocalUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
oidcProtocol?: OidcProtocol;
resolveOidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
}
interface LoginPayload {
@@ -118,7 +123,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
if (!snapshot) return unavailable(reply);
const mode = snapshot.value.mode;
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
return reply.send({ mode, localLogin: mode === "local", oidcLogin: mode === "oidc" });
});
app.post("/auth/local/login", async (request, reply) => {
@@ -180,8 +185,83 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
}
});
app.get("/auth/oidc/login", async (_request, reply) => notImplemented(reply));
app.get("/auth/oidc/callback", async (_request, reply) => notImplemented(reply));
app.get("/auth/oidc/login", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentOidcConfig(loaded, deps);
if (!configured || !deps.sessionStore) return unavailable(reply);
const nonce = randomOidcValue();
const codeVerifier = randomOidcValue();
try {
const created = await deps.sessionStore.createOidcState({
nonce,
codeVerifier,
returnTo: "/",
authConfigRevision: configured.loaded.revision,
issuer: configured.config.oidc.issuer,
});
try {
const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier });
return reply.redirect(location.href);
} catch {
await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined);
return unavailable(reply);
}
} catch {
return unavailable(reply);
}
});
app.get("/auth/oidc/callback", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
if (!loaded || !deps.sessionStore) return oidcCallbackFailed(reply);
const callback = oidcCallbackUrl(request, loaded.value.publicUrl);
if (!callback) return oidcCallbackFailed(reply);
let state;
try {
state = await deps.sessionStore.consumeOidcState(callback.state);
} catch {
return oidcCallbackFailed(reply);
}
const configured = currentOidcConfig(loaded, deps);
if (!configured || !state || state.returnTo !== "/" || state.authConfigRevision !== configured.loaded.revision
|| state.issuer !== configured.config.oidc.issuer) {
return oidcCallbackFailed(reply);
}
try {
const identity = await configured.protocol.callback({
currentUrl: callback.currentUrl,
state: callback.state,
nonce: state.nonce,
codeVerifier: state.codeVerifier,
});
if (identity.issuer !== configured.config.oidc.issuer) return oidcCallbackFailed(reply);
const roles = oidcRoles(identity.groups, configured.config);
const absoluteTtlMs = Math.min(
configured.config.session.oidcTtlSeconds * 1000,
identity.tokenExpiresAt.getTime() - Date.now(),
);
if (!Number.isSafeInteger(absoluteTtlMs) || absoluteTtlMs <= 0) return oidcCallbackFailed(reply);
const created = await deps.sessionStore.create({
principal: {
issuer: identity.issuer,
subject: identity.subject,
...(identity.displayName === undefined ? {} : { displayName: identity.displayName }),
roles,
permissions: rolesToPermissions(roles),
isAdmin: roles.includes("admin"),
},
method: "oidc",
remembered: false,
authConfigRevision: configured.loaded.revision,
idleTtlMs: configured.config.session.regularIdleSeconds * 1000,
absoluteTtlMs,
});
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.loaded, false));
return reply.redirect(state.returnTo);
} catch {
return oidcCallbackFailed(reply);
}
});
app.post("/auth/logout", async (request, reply) => {
const token = request.authSessionToken;
@@ -278,6 +358,54 @@ function cookieOptions(snapshot: LoadedAuthConfig | undefined, remembered: boole
};
}
function currentOidcConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
| { loaded: LoadedAuthConfig; config: OidcAuthenticationConfig; protocol: OidcProtocol }
| undefined {
if (!loaded || loaded.value.mode !== "oidc") return undefined;
const protocol = deps.resolveOidcProtocol?.(loaded) ?? deps.oidcProtocol;
return protocol ? { loaded, config: loaded.value, protocol } : undefined;
}
function randomOidcValue(): string {
return randomBytes(32).toString("base64url");
}
function oidcCallbackUrl(request: FastifyRequest, publicUrl: string): { currentUrl: URL; state: string } | undefined {
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) return undefined;
let supplied: URL;
let target: URL;
try {
supplied = new URL(request.url, "http://callback.invalid");
target = new URL(OIDC_CALLBACK_PATH, publicUrl);
} catch {
return undefined;
}
if (supplied.pathname !== "/auth/oidc/callback") return undefined;
const allowed = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
const copied = new URLSearchParams();
let state: string | undefined;
for (const [key, value] of supplied.searchParams) {
if (!allowed.has(key) || value.length > 2048 || copied.has(key)) return undefined;
copied.set(key, value);
if (key === "state") state = value;
}
if (!state || !/^[A-Za-z0-9_-]{43}$/.test(state)) return undefined;
target.search = copied.toString();
return { currentUrl: target, state };
}
function oidcCallbackFailed(reply: FastifyReply) {
return reply.code(401).send({ code: "oidc_callback_failed", error: "OIDC sign-in could not be completed" });
}
function oidcRoles(groups: readonly string[], config: OidcAuthenticationConfig): Role[] {
const roles = new Set<Role>();
for (const group of groups) {
for (const role of config.authorization.groupRoles[group] ?? []) roles.add(role);
}
return [...roles];
}
function loginPayload(request: FastifyRequest): LoginPayload {
const body = request.body;
if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false };
@@ -316,7 +444,3 @@ function loginLimited(reply: FastifyReply): FastifyReply {
function unavailable(reply: FastifyReply): FastifyReply {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
function notImplemented(reply: FastifyReply): FastifyReply {
return reply.code(501).send({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
}