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
+21 -2
View File
@@ -129,7 +129,7 @@ function LanguageSelector() {
export function LoginPage() {
const { t } = useTranslation();
const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName, ssoEnforced } = useAuth();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
@@ -141,6 +141,23 @@ export function LoginPage() {
const mfaInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// A successful OIDC/SAML login for an already-enrolled user redirects
// here with a one-time mfaToken instead of completing the session
// directly, so the TOTP challenge can be completed the same way a local
// login's challenge is.
const redirectedMfaToken = searchParams.get("mfaToken");
if (redirectedMfaToken) {
setMfaToken(redirectedMfaToken);
setShowMfaPrompt(true);
setTimeout(() => mfaInputRef.current?.focus(), 100);
// Drop it from the URL: it's a one-time credential and has no business
// sitting in browser history or a Referer header for the rest of the
// challenge. Also stops a later effect re-run (e.g. a locale switch)
// from reopening the prompt after the user has moved past it.
setSearchParams({}, { replace: true });
return;
}
const authError = searchParams.get("error");
if (authError) {
const errorMessages: Record<string, string> = {
@@ -152,10 +169,12 @@ export function LoginPage() {
saml_auth_failed: t.auth.samlAuthFailed,
saml_user_not_authorized: t.auth.samlUserNotAuthorized,
saml_user_limit_reached: t.auth.samlUserLimitReached,
mfa_enrollment_required: t.auth.mfaEnrollmentRequired,
};
setError(errorMessages[authError] || t.auth.oidcGenericError);
setSearchParams({}, { replace: true });
}
}, [searchParams, t]);
}, [searchParams, setSearchParams, t]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();