fix(auth): give OIDC/SAML logins a real MFA challenge instead of a hard block (#536)

Fixes #533, found while working on #529/#531.

OIDC and SAML logins hard-blocked on the MFA policy with zero check of whether the user actually enrolled TOTP, and no challenge step at all. Once an admin turned on an MFA-required policy, every SSO user was permanently locked out regardless of enrollment status.

- Extract the post-auth MFA decision (challenge / enrollment-required / proceed) into a shared, unit-tested function so OIDC and SAML can't independently diverge again
- An already-enrolled user now gets a real challenge (reusing the existing, auth-method-agnostic MFA completion flow) instead of being blocked
- An unenrolled user under a required policy gets a distinct, correctly mapped error instead of the old generic one
- Fix a real fail-open regression caught in review: a transient DB error during the enrollment-status check could have silently skipped MFA entirely for an enrolled user; now it fails closed and logs
- Strip the one-time challenge token from the URL after consuming it
This commit is contained in:
SnapOtter
2026-07-16 18:08:24 +08:00
committed by GitHub
parent 190d4c2a00
commit bbfcbe9c82
7 changed files with 591 additions and 22 deletions
+16
View File
@@ -106,6 +106,22 @@ export function isMfaRequiredForUser(policy: MfaPolicy, userRole: string): boole
return false;
}
// The post-authentication MFA decision, shared by every login path (local
// password, OIDC, SAML) so a new auth method can't silently diverge from the
// others the way OIDC/SAML once did (they blocked on policy alone, with no
// totpEnabled check and no challenge step -- snapotter-hq/SnapOtter#533).
export type ExternalMfaOutcome = "proceed" | "challenge" | "enrollment_required";
export function resolveExternalLoginMfaOutcome(
policy: MfaPolicy,
userRole: string,
totpEnabled: boolean,
): ExternalMfaOutcome {
if (totpEnabled) return "challenge";
if (isMfaRequiredForUser(policy, userRole)) return "enrollment_required";
return "proceed";
}
// ── MFA plugin registration ───────────────────────────────────────
export async function registerMfa(app: FastifyInstance): Promise<void> {
+53 -10
View File
@@ -1,8 +1,11 @@
import { randomUUID } from "node:crypto";
import type {} from "@fastify/cookie";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import * as oidc from "openid-client";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js";
import { resolveExternalUser, sanitizeUsername } from "../lib/external-auth-resolver.js";
import { authAttempts } from "../lib/metrics.js";
@@ -274,22 +277,62 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
const resolvedUser = result.user;
let mfaRequired = false;
// Unguarded on purpose: this read decides whether MFA gets checked at
// all, so a DB error here must fail the login, not silently skip MFA
// for an enrolled user. The try/catch below is scoped only to the
// optional MFA plugin/policy lookup, same as it always was.
let dbUser: { totpEnabled: boolean } | undefined;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, resolvedUser.role);
} catch {
// MFA plugin not loaded
}
if (mfaRequired) {
[dbUser] = await db
.select({ totpEnabled: schema.users.totpEnabled })
.from(schema.users)
.where(eq(schema.users.id, resolvedUser.id));
} catch (err) {
request.log.error(
{ err, userId: resolvedUser.id },
"OIDC callback: failed to read MFA enrollment status",
);
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", {
userId: resolvedUser.id,
username: resolvedUser.username,
reason: "mfa_required",
reason: "mfa_check_error",
});
return redirectToLogin(reply, "mfa_required");
return redirectToLogin(reply, "oidc_auth_failed");
}
let mfaOutcome: "proceed" | "challenge" | "enrollment_required" = "proceed";
try {
const { getMfaPolicy, resolveExternalLoginMfaOutcome } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaOutcome = resolveExternalLoginMfaOutcome(
policy,
resolvedUser.role,
dbUser?.totpEnabled ?? false,
);
} catch {
// MFA plugin not loaded
}
if (mfaOutcome === "challenge") {
const mfaToken = randomUUID();
const redis = sharedRedis();
await redis.setex(`mfa:${mfaToken}`, 300, resolvedUser.id);
await audit("MFA_CHALLENGE_ISSUED", {
userId: resolvedUser.id,
username: resolvedUser.username,
});
return reply.redirect(`/login?mfaToken=${mfaToken}`);
}
if (mfaOutcome === "enrollment_required") {
authAttempts.inc({ method: "oidc", result: "failure" });
await audit("OIDC_LOGIN_FAILED", {
userId: resolvedUser.id,
username: resolvedUser.username,
reason: "mfa_enrollment_required",
});
return redirectToLogin(reply, "mfa_enrollment_required");
}
// 5. Create session
+53 -10
View File
@@ -1,9 +1,12 @@
import { randomUUID } from "node:crypto";
import { parse as parseQs } from "node:querystring";
import type {} from "@fastify/cookie";
import { SAML } from "@node-saml/node-saml";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest } from "../lib/audit.js";
import {
findUniqueUsername,
@@ -164,22 +167,62 @@ export async function registerSaml(app: FastifyInstance): Promise<void> {
const resolvedUser = result.user;
let mfaRequired = false;
// Unguarded on purpose: this read decides whether MFA gets checked at
// all, so a DB error here must fail the login, not silently skip MFA
// for an enrolled user. The try/catch below is scoped only to the
// optional MFA plugin/policy lookup, same as it always was.
let dbUser: { totpEnabled: boolean } | undefined;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, resolvedUser.role);
} catch {
// MFA plugin not loaded
}
if (mfaRequired) {
[dbUser] = await db
.select({ totpEnabled: schema.users.totpEnabled })
.from(schema.users)
.where(eq(schema.users.id, resolvedUser.id));
} catch (err) {
request.log.error(
{ err, userId: resolvedUser.id },
"SAML callback: failed to read MFA enrollment status",
);
authAttempts.inc({ method: "saml", result: "failure" });
await audit("SAML_LOGIN_FAILED", {
userId: resolvedUser.id,
username: resolvedUser.username,
reason: "mfa_required",
reason: "mfa_check_error",
});
return redirectToLogin(reply, "mfa_required");
return redirectToLogin(reply, "saml_auth_failed");
}
let mfaOutcome: "proceed" | "challenge" | "enrollment_required" = "proceed";
try {
const { getMfaPolicy, resolveExternalLoginMfaOutcome } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaOutcome = resolveExternalLoginMfaOutcome(
policy,
resolvedUser.role,
dbUser?.totpEnabled ?? false,
);
} catch {
// MFA plugin not loaded
}
if (mfaOutcome === "challenge") {
const mfaToken = randomUUID();
const redis = sharedRedis();
await redis.setex(`mfa:${mfaToken}`, 300, resolvedUser.id);
await audit("MFA_CHALLENGE_ISSUED", {
userId: resolvedUser.id,
username: resolvedUser.username,
});
return reply.redirect(`/login?mfaToken=${mfaToken}`);
}
if (mfaOutcome === "enrollment_required") {
authAttempts.inc({ method: "saml", result: "failure" });
await audit("SAML_LOGIN_FAILED", {
userId: resolvedUser.id,
username: resolvedUser.username,
reason: "mfa_enrollment_required",
});
return redirectToLogin(reply, "mfa_enrollment_required");
}
// Create session (same pattern as OIDC)