diff --git a/.server-changes/mfa-required.md b/.server-changes/mfa-required.md new file mode 100644 index 00000000000..6ac90cd3f01 --- /dev/null +++ b/.server-changes/mfa-required.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Self-hosted instances can set `MFA_REQUIRED` to make multi-factor authentication mandatory for every dashboard user. Until a user has enrolled a second factor they're taken to a setup page instead of the dashboard, and once enrolled they can no longer turn MFA off. Off by default. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 729a2331e89..ea2cc05dd21 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -400,6 +400,11 @@ const EnvironmentSchema = z WEBAPP_TELNET_LOGS_PORT: z.coerce.number().optional(), LOGIN_ORIGIN: z.string().default("http://localhost:3030"), LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true), + // Opt-in for self-hosted instances under a compliance program (FedRAMP, + // SOC 2, HIPAA): require every dashboard user to enroll a second factor + // (TOTP) before they can use the dashboard, and prevent them from + // disabling it afterwards. Off by default. + MFA_REQUIRED: BoolEnv.default(false), APP_ORIGIN: z.string().default("http://localhost:3030"), // Extra exact origins (comma separated) added to the document `img-src` CSP, // e.g. an SSO host serving profile images. Wildcards are refused. diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 2939faf0958..79f665e49a2 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -23,6 +23,7 @@ import { usePostHog } from "./hooks/usePostHog"; import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { clearImpersonation } from "./models/admin.server"; import { getImpersonationState, getRawImpersonationId } from "./services/impersonation.server"; +import { throwIfMfaRequired } from "./services/mfa/mfaRequirement.server"; import { getUser } from "./services/session.server"; import { normalizeIconContrast, @@ -81,6 +82,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const timezone = await getTimezonePreference(request); const user = await getUser(request); + // `MFA_REQUIRED`: refuse every non-exempt request from a signed-in user who + // has not enrolled a second factor, sending them to the setup page instead. + // + // `requireUser` applies the same check, but it only guards the routes that + // call it — this covers routes that authenticate with `requireUserId` + // (which stays cookie-only, with no user row to inspect). Action-only + // resource routes skip the root loader entirely, so they are gated only + // where they call `requireUser`; the MFA enrollment endpoint is exempt by + // design, since it is the only way to enroll. + // + // Logged-out requests are untouched — they take the normal login flow. + throwIfMfaRequired(request, user); // Feature-flagged; while off everyone stays on Dark at contrast 0. Admins // always get it. Cached: this loader runs on every request and navigation. const showThemeSwitcher = user diff --git a/apps/webapp/app/routes/_app/route.tsx b/apps/webapp/app/routes/_app/route.tsx index e1acc65f27e..8691bc88576 100644 --- a/apps/webapp/app/routes/_app/route.tsx +++ b/apps/webapp/app/routes/_app/route.tsx @@ -9,6 +9,11 @@ import { tenantContext } from "~/services/tenantContext.server"; import { confirmBasicDetailsPath } from "~/utils/pathBuilder"; export const loader = async ({ request }: LoaderFunctionArgs) => { + // `requireUser` also enforces `MFA_REQUIRED`: a signed-in user without a + // second factor is redirected to /mfa/setup before this loader gets any + // further. That check lives there rather than here so it also covers the + // routes not nested under this layout (/account/*, /invites, /admin/*) — + // see services/mfa/mfaRequirement.server.ts. const user = await requireUser(request); tenantContext.enrich({ userId: user.id }); diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx index 62f08f6e08b..857b818a923 100644 --- a/apps/webapp/app/routes/account.security/route.tsx +++ b/apps/webapp/app/routes/account.security/route.tsx @@ -8,6 +8,7 @@ import { import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { $replica } from "~/db.server"; +import { env } from "~/env.server"; import { requireUser } from "~/services/session.server"; import { getAllowedSessionOptions, @@ -49,7 +50,12 @@ export default function Page() { Security
- + {/* + Under MFA_REQUIRED every signed-in user is gated to the setup + page until they enroll, so this page is only reachable with MFA + already on — the toggle is hidden and there is nothing to disable. + */} +
(); + + return ; +} + +function ForcedMfaEnrollment({ redirectTo }: { redirectTo: string }) { + const { state, actions } = useMfaSetup(false, redirectTo); + const [totpCode, setTotpCode] = useState(""); + + const isEnrolled = state.phase === "showing-recovery" && !!state.recoveryCodes; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (totpCode.length !== 6) return; + actions.validateTotp(totpCode); + setTotpCode(""); + }; + + const downloadRecoveryCodes = () => { + if (!state.recoveryCodes) return; + + const blob = new Blob([state.recoveryCodes.join("\n")], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "trigger-dev-recovery-codes.txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( + + + + } + description="Your organization requires a second factor before you can use the dashboard. Set one up now with your authenticator app." + /> + + {isEnrolled ? ( + + ) : state.setupData ? ( + + ) : ( + + )} + + + + ); +} + +function StartEnrollment({ + onStart, + isSubmitting, + error, +}: { + onStart: () => void; + isSubmitting: boolean; + error?: string; +}) { + return ( +
+ + You'll scan a QR code with an app like Google Authenticator, 1Password or Authy, then enter + the 6-digit code it generates to prove it's set up correctly. + +
+ + {error && {error}} +
+
+ ); +} + +function AuthenticatorSetup({ + otpAuthUrl, + secret, + totpCode, + onTotpCodeChange, + onSubmit, + error, + isSubmitting, +}: { + otpAuthUrl: string; + secret: string; + totpCode: string; + onTotpCodeChange: (code: string) => void; + onSubmit: (e: React.FormEvent) => void; + error?: string; + isSubmitting: boolean; +}) { + return ( +
+
+ + Scan the QR code below with your authenticator app, then enter the 6-digit code it + generates. You can also copy the secret into your app manually. + + +
+
+ +
+ +
+ +
+ + + + + + + + + + +
+ + {error && {error}} + + +
+
+ ); +} + +function RecoveryCodes({ + codes, + onDownload, + onContinue, + isSubmitting, +}: { + codes: string[]; + onDownload: () => void; + onContinue: () => void; + isSubmitting: boolean; +}) { + return ( +
+ + Multi-factor authentication is on. Copy and store these recovery codes somewhere safe — + they're the only way back in if you lose your device. Each code works once. + + +
+
+ {codes.map((code, index) => ( + + {code} + + ))} +
+
+ + + Copy + +
+
+ + +
+ ); +} diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx index 7c1f4893d7f..030e2b2b370 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx @@ -1,6 +1,7 @@ import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; +import { env } from "~/env.server"; import { redirectWithSuccessMessage, redirectWithErrorMessage, @@ -10,6 +11,8 @@ import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuth import { requireUserId } from "~/services/session.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { MfaRateLimitError } from "~/services/mfa/mfaRateLimiterGlobal.server"; +import { sanitizeRedirectPath } from "~/utils"; +import { accountSecurityPath, mfaSetupPath, rootPath } from "~/utils/pathBuilder"; import { useMfaSetup } from "./useMfaSetup"; import { MfaToggle } from "./MfaToggle"; import { MfaSetupDialog } from "./MfaSetupDialog"; @@ -26,6 +29,10 @@ const formSchema = z.discriminatedUnion("action", [ }), z.object({ action: z.literal("saved-recovery-codes"), + // Where to send the user once enrollment is complete. Supplied by the + // forced-setup page so a user who was bounced there mid-navigation lands + // back where they were headed. + redirectTo: z.string().optional(), }), z.object({ action: z.literal("cancel-totp"), @@ -36,6 +43,36 @@ const formSchema = z.discriminatedUnion("action", [ }), ]); +// Where enrollment sends the user when they did not arrive via the forced +// setup page (i.e. they enabled MFA voluntarily from Account → Security). +const DEFAULT_ENROLLMENT_REDIRECT = accountSecurityPath(); + +/** + * Resolves the post-enrollment destination. + * + * `redirectTo` is user-influenced (it round-trips through the browser), so it + * goes through the same sanitizer the login flow uses: it must be a relative, + * navigable path, otherwise we fall back to the security page. + */ +function enrollmentRedirectPath(redirectTo: string | undefined): string { + if (env.MFA_REQUIRED) { + return sanitizeRedirectPath(redirectTo, rootPath()); + } + + return sanitizeRedirectPath(redirectTo, DEFAULT_ENROLLMENT_REDIRECT); +} + +/** + * Where to send the user when enrollment fails with an error. + * + * Under `MFA_REQUIRED` they have nowhere else to go — every other dashboard + * path bounces them straight back here — so send them to the setup page + * directly rather than making them round-trip through the gate. + */ +function enrollmentErrorRedirectPath(): string { + return env.MFA_REQUIRED ? mfaSetupPath() : DEFAULT_ENROLLMENT_REDIRECT; +} + function validateForm(formData: FormData) { const formEntries = Object.fromEntries(formData.entries()); @@ -82,6 +119,18 @@ export async function action({ request }: ActionFunctionArgs) { }); } case "disable-mfa": { + // Refused here too (the service refuses it independently) so the + // client gets a clean error rather than an unexpected redirect when + // the instance requires MFA. + if (env.MFA_REQUIRED) { + return typedjson({ + action: "disable-mfa" as const, + success: false as const, + error: + "Multi-factor authentication is required on this instance and cannot be disabled.", + }); + } + const result = await mfaSetupService.disableTotp(userId, { totpCode: submission.data.totpCode, recoveryCode: submission.data.recoveryCode, @@ -130,17 +179,21 @@ export async function action({ request }: ActionFunctionArgs) { }); } case "saved-recovery-codes": { - return redirectWithSuccessMessage("/account/security", request, "Successfully enabled MFA"); + return redirectWithSuccessMessage( + enrollmentRedirectPath(submission.data.redirectTo), + request, + "Successfully enabled MFA" + ); } } } catch (error) { if (error instanceof ServiceValidationError) { - return redirectWithErrorMessage("/account/security", request, error.message); + return redirectWithErrorMessage(enrollmentErrorRedirectPath(), request, error.message); } if (error instanceof MfaRateLimitError) { return redirectWithErrorMessage( - "/account/security", + enrollmentErrorRedirectPath(), request, "Too many attempts. Please try again later." ); @@ -151,7 +204,13 @@ export async function action({ request }: ActionFunctionArgs) { } } -export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { +export function MfaSetup({ + isEnabled, + isRequired = false, +}: { + isEnabled: boolean; + isRequired?: boolean; +}) { const { state, actions, @@ -170,7 +229,12 @@ export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { return ( <> - + {/* + With MFA_REQUIRED the second factor cannot be turned off, so the toggle + and the disable dialog are omitted entirely — there is no state the + user is allowed to reach by interacting with them. + */} + {!isRequired && } - + {!isRequired && ( + + )} ); } diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts index a5efd18935c..3d91079121f 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts +++ b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts @@ -168,7 +168,7 @@ function mfaReducer(state: MfaState, action: MfaAction): MfaState { } } -export function useMfaSetup(initialIsEnabled: boolean) { +export function useMfaSetup(initialIsEnabled: boolean, enrollmentRedirectTo?: string) { const fetcher = useTypedFetcher(); const [state, dispatch] = useReducer(mfaReducer, { @@ -259,7 +259,11 @@ export function useMfaSetup(initialIsEnabled: boolean) { saveRecoveryCodes: () => { dispatch({ type: "RECOVERY_CODES_SAVED" }); fetcher.submit( - { action: "saved-recovery-codes" }, + // Only sent when there's somewhere to go — the server falls back to + // the account security page otherwise. + enrollmentRedirectTo + ? { action: "saved-recovery-codes", redirectTo: enrollmentRedirectTo } + : { action: "saved-recovery-codes" }, { method: "POST", action: "/resources/account/mfa/setup" } ); }, diff --git a/apps/webapp/app/services/mfa/mfaRequirement.server.ts b/apps/webapp/app/services/mfa/mfaRequirement.server.ts new file mode 100644 index 00000000000..042a64975d3 --- /dev/null +++ b/apps/webapp/app/services/mfa/mfaRequirement.server.ts @@ -0,0 +1,66 @@ +import { redirect } from "@remix-run/node"; +import { env } from "~/env.server"; +import { sanitizeRedirectPath } from "~/utils"; +import { mfaSetupPath } from "~/utils/pathBuilder"; + +// Paths a signed-in user may reach while MFA is still required of them. The +// setup page itself, the whole auth flow (any deeper and a user who has not +// enrolled cannot log in at all, and `/login/mfa` must stay reachable for +// users who HAVE enrolled), logout, and the MFA setup fetcher endpoint — which +// is the only way to enroll, so gating it would deadlock the flow. +// +// `/logout` matters for the same loop reason as in `maybeAutoLogout`: the root +// loader runs for `/logout` itself, so an unconditional redirect here would +// prevent the session cookie ever being destroyed. +const MFA_SETUP_EXACT_PATHS = new Set(["/magic", "/logout", "/login", "/mfa/setup"]); +const MFA_SETUP_PREFIXES = ["/login/", "/auth/", "/resources/account/mfa/setup"]; + +/** True when `pathname` is reachable without having enrolled MFA yet. */ +export function isMfaExemptPath(pathname: string): boolean { + if (MFA_SETUP_EXACT_PATHS.has(pathname)) return true; + return MFA_SETUP_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix)); +} + +/** + * The second half of the `MFA_REQUIRED` gate: given a user who is missing a + * second factor, decide whether this request may proceed anyway. + * + * Returns a redirect to the MFA setup page, or `null` when the request is + * allowed (either because the flag is off, the user has enrolled, or the path + * is on the exemption list above). + * + * Callers are expected to `throw` the returned response — the same convention + * `requireUser` uses for its login redirect. + */ +export function mfaRequirementRedirect( + request: Request, + user: { mfaEnabledAt: Date | null } | null +): Response | null { + // Logged-out requests are handled by the normal login flow; they can only + // reach the exempt paths above anyway, but check the flag first so the + // common case costs a single boolean read. + if (!env.MFA_REQUIRED || !user || user.mfaEnabledAt) return null; + + const url = new URL(request.url); + if (isMfaExemptPath(url.pathname)) return null; + + // Carry the destination across the forced detour so the user lands where + // they were headed once enrollment completes. Sanitized: this is a + // user-influenced URL (the query string of the blocked request). + const redirectTo = sanitizeRedirectPath(`${url.pathname}${url.search}`); + const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); + return redirect(`${mfaSetupPath()}?${searchParams}`); +} + +/** + * Throwing variant for use inside auth helpers: aborts the request with the + * setup redirect when `MFA_REQUIRED` is on and the user has not enrolled. + * A no-op for every other case. + */ +export function throwIfMfaRequired( + request: Request, + user: { mfaEnabledAt: Date | null } | null +): void { + const redirectResponse = mfaRequirementRedirect(request, user); + if (redirectResponse) throw redirectResponse; +} diff --git a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts index 6a41f161cff..e2e9b73f22c 100644 --- a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts +++ b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts @@ -1,4 +1,5 @@ import { type SecretReference, type User, type PrismaClient } from "@trigger.dev/database"; +import { env } from "~/env.server"; import { prisma } from "~/db.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { createRandomStringGenerator } from "@better-auth/utils/random"; @@ -24,6 +25,16 @@ export class MultiFactorAuthenticationService { } public async disableTotp(userId: string, params: { totpCode?: string; recoveryCode?: string }) { + // Instance-level requirement: when `MFA_REQUIRED` is on, the second factor + // is not optional, so there is no code that can turn it off. Checked here + // rather than only in the route action so every caller — including future + // admin tooling — inherits the rule. + if (env.MFA_REQUIRED) { + throw new ServiceValidationError( + "Multi-factor authentication is required on this instance and cannot be disabled." + ); + } + const user = await this.#prismaClient.user.findFirst({ where: { id: userId }, include: { @@ -111,12 +122,26 @@ export class MultiFactorAuthenticationService { public async enableTotp(userId: string) { const user = await this.#prismaClient.user.findFirst({ where: { id: userId }, + include: { mfaSecretReference: true }, }); if (!user) { throw new ServiceValidationError("User not found"); } + // A user can leave enrollment half-finished (navigating away, or reloading + // the forced-setup page under `MFA_REQUIRED`) and come back to start over. + // Drop the pending secret first — otherwise the old row and its stored + // secret are orphaned every time they retry. + if (user.mfaSecretReference) { + const previousStore = getSecretStore(user.mfaSecretReference.provider); + await previousStore.deleteSecret(user.mfaSecretReference.key); + await this.#prismaClient.user.update({ + where: { id: userId }, + data: { mfaSecretReference: { delete: true } }, + }); + } + const secretStore = getSecretStore("DATABASE"); // Generate a new secret diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 90cda576a08..5cb5fcde6b2 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -5,6 +5,7 @@ import { extractClientIp } from "~/utils/extractClientIp.server"; import { authenticator } from "./auth.server"; import { getImpersonationId, getImpersonationState } from "./impersonation.server"; import { logger } from "./logger.server"; +import { throwIfMfaRequired } from "./mfa/mfaRequirement.server"; import { revalidateSsoSession } from "./ssoSessionRevalidation.server"; /** @@ -135,6 +136,14 @@ export async function requireUser(request: Request) { throw redirect(`/login?${searchParams}`); } + // `MFA_REQUIRED`: a signed-in user who has not enrolled a second factor may + // only reach the exempt paths (auth flow, logout, the setup page itself). + // Enforced here — rather than only in `_app/route.tsx` — because `/account/*`, + // `/invites`, `/admin/*` and the resource routes are not nested under `_app`, + // so the layout loader alone would leave them reachable. root.tsx applies the + // same check for routes that authenticate with `requireUserId` only. + throwIfMfaRequired(request, user); + // Shared with the root loader so the client never reads a different answer // than the one computed here. const { isImpersonating, isViewingAsUser } = await getImpersonationState(request, user.id); diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 38e4b9c7505..37679681146 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -110,6 +110,10 @@ export function accountSecurityPath() { return `/account/security`; } +export function mfaSetupPath() { + return `/mfa/setup`; +} + export function invitesPath() { return `/invites`; } diff --git a/apps/webapp/test/mfaRequirement.test.ts b/apps/webapp/test/mfaRequirement.test.ts new file mode 100644 index 00000000000..cb4ec948e09 --- /dev/null +++ b/apps/webapp/test/mfaRequirement.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it } from "vitest"; + +// env.server.ts parses process.env at import time, so flip the flag before the +// module under test (and the env module it depends on) loads. The off-state +// cases below turn it back off per-test rather than needing a second file. +process.env.MFA_REQUIRED = "1"; + +const { env } = await import("~/env.server"); +const { isMfaExemptPath, mfaRequirementRedirect } = + await import("~/services/mfa/mfaRequirement.server"); + +const requestFor = (path: string) => new Request(`https://app.example.com${path}`); + +// The Location header is emitted as a relative path ("/mfa/setup?..."), so +// resolving it needs the same origin the request was made against. +const locationOf = (response: Response) => + new URL(response.headers.get("location")!, "https://app.example.com"); + +const enrolled = { mfaEnabledAt: new Date() }; +const notEnrolled = { mfaEnabledAt: null }; + +afterEach(() => { + // Restored rather than left on, so a later test in this file that expects the + // flag off starts from the right state. + env.MFA_REQUIRED = true; +}); + +describe("isMfaExemptPath", () => { + it("exempts the setup page, the whole auth flow and logout", () => { + for (const path of [ + "/mfa/setup", + "/login", + "/login/mfa", + "/login/magic", + "/login/sso", + "/logout", + "/magic", + "/auth/github/callback", + "/auth/google/callback", + "/auth/sso/callback", + ]) { + expect(isMfaExemptPath(path), `${path} should be exempt`).toBe(true); + } + }); + + it("exempts the MFA setup fetcher endpoint", () => { + expect(isMfaExemptPath("/resources/account/mfa/setup")).toBe(true); + }); + + it("does not exempt other resource endpoints", () => { + expect(isMfaExemptPath("/resources/account/session-duration")).toBe(false); + expect(isMfaExemptPath("/resources/some/other/endpoint")).toBe(false); + }); + + it("does not exempt dashboard routes", () => { + for (const path of [ + "/", + "/account/security", + "/account/tokens", + "/invites", + "/admin", + "/orgs/acme/envs/production/runs", + "/confirm-basic-details", + ]) { + expect(isMfaExemptPath(path), `${path} should not be exempt`).toBe(false); + } + }); + + it("does not treat a prefix of an exempt path as exempt", () => { + // "/logins" must not match the "/login" exact entry, and a bare "/auth" + // (no trailing segment) is not a real route but is also not exempt here. + expect(isMfaExemptPath("/logins")).toBe(false); + }); +}); + +describe("mfaRequirementRedirect", () => { + it("redirects a signed-in user without MFA to the setup page", () => { + const response = mfaRequirementRedirect(requestFor("/orgs/acme"), notEnrolled); + + expect(response).not.toBeNull(); + expect(response?.status).toBe(302); + + const location = locationOf(response!); + expect(location.pathname).toBe("/mfa/setup"); + // The blocked destination is carried across so they land where they meant to. + expect(location.searchParams.get("redirectTo")).toBe("/orgs/acme"); + }); + + it("preserves the query string of the blocked request", () => { + const response = mfaRequirementRedirect( + requestFor("/orgs/acme/envs/dev/runs?run=abc&tab=logs"), + notEnrolled + ); + + const location = locationOf(response!); + expect(location.searchParams.get("redirectTo")).toBe( + "/orgs/acme/envs/dev/runs?run=abc&tab=logs" + ); + }); + + it("sanitizes a redirect target that is not a navigable path", () => { + // A resource endpoint would render blank as a destination, so it falls back + // to the root path instead of being propagated. + const response = mfaRequirementRedirect(requestFor("/resources/foo"), notEnrolled); + + const location = locationOf(response!); + expect(location.searchParams.get("redirectTo")).toBe("/"); + }); + + it("allows a user who has enrolled MFA", () => { + expect(mfaRequirementRedirect(requestFor("/orgs/acme"), enrolled)).toBeNull(); + }); + + it("does nothing when the flag is off", () => { + env.MFA_REQUIRED = false; + + expect(mfaRequirementRedirect(requestFor("/orgs/acme"), notEnrolled)).toBeNull(); + }); + + it("does nothing for a logged-out request", () => { + expect(mfaRequirementRedirect(requestFor("/orgs/acme"), null)).toBeNull(); + }); + + it("allows the exempt paths through so the user can enroll or log out", () => { + for (const path of ["/mfa/setup", "/login", "/login/mfa", "/logout", "/magic"]) { + expect(mfaRequirementRedirect(requestFor(path), notEnrolled), `${path}`).toBeNull(); + } + }); +}); + +describe("throwIfMfaRequired", () => { + it("throws the redirect when the user is missing MFA", async () => { + const { throwIfMfaRequired } = await import("~/services/mfa/mfaRequirement.server"); + + expect(() => throwIfMfaRequired(requestFor("/orgs/acme"), notEnrolled)).toThrow(); + }); + + it("does not throw when the user has enrolled", async () => { + const { throwIfMfaRequired } = await import("~/services/mfa/mfaRequirement.server"); + + expect(() => throwIfMfaRequired(requestFor("/orgs/acme"), enrolled)).not.toThrow(); + }); +}); diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx index 8d31694686e..1768409a688 100644 --- a/docs/self-hosting/env/webapp.mdx +++ b/docs/self-hosting/env/webapp.mdx @@ -42,6 +42,7 @@ mode: "wide" | **Auth** | | | | | `WHITELISTED_EMAILS` | No | — | Whitelisted emails regex. | | `LOGIN_RATE_LIMITS_ENABLED` | No | true | Enable rate limiting on magic-link login. | +| `MFA_REQUIRED` | No | false | Require every dashboard user to set up multi-factor authentication (TOTP). When enabled, a user without MFA is sent to a setup page and cannot use the dashboard until they enroll, and MFA cannot be turned off afterwards. | | `AUTH_GITHUB_CLIENT_ID` | No | — | GitHub client ID. | | `AUTH_GITHUB_CLIENT_SECRET` | No | — | GitHub client secret. | | `CSP_IMG_SRC_ALLOWLIST` | No | — | Extra hosts the dashboard may load images from, e.g. an SSO or avatar host serving profile images. A comma-separated list of exact origins (`https://sso.example.com`); a wildcard host such as `https://*.example.com` is refused. |