feat(auth): add generic OIDC login with mandatory groups
This commit is contained in:
+132
-8
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user