diff --git a/ee/apps/den-api/src/routes/auth/index.ts b/ee/apps/den-api/src/routes/auth/index.ts index fb3aca0174..80c2348bb3 100644 --- a/ee/apps/den-api/src/routes/auth/index.ts +++ b/ee/apps/den-api/src/routes/auth/index.ts @@ -273,6 +273,13 @@ export function isBetterAuthEmailPasswordRequest(request: Request) { return request.method.toUpperCase() === "POST" && (path === "/sign-in/email" || path === "/sign-up/email") } +// Email OTP sign-in issues a session (and creates unknown users) just like +// email/password, so SSO-only deployments must reject it as well. +export function isBetterAuthEmailOtpSignInRequest(request: Request) { + const url = new URL(request.url) + return request.method.toUpperCase() === "POST" && getBetterAuthProxyPath(url.pathname) === "/sign-in/email-otp" +} + export function isBetterAuthEmailSignupRequest(request: Request) { const url = new URL(request.url) return request.method.toUpperCase() === "POST" && getBetterAuthProxyPath(url.pathname) === "/sign-up/email" @@ -341,7 +348,7 @@ async function getSingleOrgAuthGuardResponse(request: Request, context: Context, } } - if (isBetterAuthEmailPasswordRequest(request)) { + if (isBetterAuthEmailPasswordRequest(request) || isBetterAuthEmailOtpSignInRequest(request)) { const status = await getSingletonSsoStatus() if (status.configured) { return singleOrgSsoRequiredResponse(status.signInPath) diff --git a/ee/apps/den-api/test/single-org-route-guards.test.ts b/ee/apps/den-api/test/single-org-route-guards.test.ts index 3f3271f0b3..592a0fba96 100644 --- a/ee/apps/den-api/test/single-org-route-guards.test.ts +++ b/ee/apps/den-api/test/single-org-route-guards.test.ts @@ -177,6 +177,20 @@ test("single_org SSO-only guard recognizes email/password auth requests", () => }))).toBe(false) }) +test("single_org SSO-only guard recognizes email OTP sign-in requests", () => { + expect(authRoutesModule.isBetterAuthEmailOtpSignInRequest(new Request("http://den.local/api/auth/sign-in/email-otp", { + method: "POST", + }))).toBe(true) + + expect(authRoutesModule.isBetterAuthEmailOtpSignInRequest(new Request("http://den.local/api/auth/sign-in/email", { + method: "POST", + }))).toBe(false) + + expect(authRoutesModule.isBetterAuthEmailOtpSignInRequest(new Request("http://den.local/api/auth/sign-in/email-otp", { + method: "GET", + }))).toBe(false) +}) + test("single_org SSO-only guard still recognizes invited email signup requests", () => { expect(authRoutesModule.isBetterAuthEmailPasswordRequest(new Request("http://den.local/api/auth/sign-up/email?invite=inv_example", { method: "POST", diff --git a/ee/apps/den-web/app/sso/[orgSlug]/page.tsx b/ee/apps/den-web/app/sso/[orgSlug]/page.tsx index b44ea5302a..4919349cb5 100644 --- a/ee/apps/den-web/app/sso/[orgSlug]/page.tsx +++ b/ee/apps/den-web/app/sso/[orgSlug]/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { DenStatusScreen } from "../../../components/den-status-screen"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { getSocialCallbackUrl, requestJson } from "../../(den)/_lib/den-flow"; @@ -12,6 +12,7 @@ export default function OrganizationSsoSignInPage() { const [error, setError] = useState(null); const [redirectUrl, setRedirectUrl] = useState(null); const [attempt, setAttempt] = useState(0); + const startRequests = useRef(new Map>()); const orgSlug = typeof params?.orgSlug === "string" ? params.orgSlug : ""; const callbackURL = useMemo(() => searchParams.get("callbackURL") || getSocialCallbackUrl(), [searchParams]); @@ -25,19 +26,28 @@ export default function OrganizationSsoSignInPage() { void (async () => { try { - const { response, payload } = await requestJson("/api/auth/sign-in/sso", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - organizationSlug: orgSlug, - callbackURL, - errorCallbackURL, - loginHint, - }), - }); + // React StrictMode runs this effect twice in dev. A second sign-in/sso + // call overwrites the OAuth state cookie and the callback then fails + // with state_mismatch, so both runs share one request. + const requestKey = JSON.stringify([orgSlug, callbackURL, errorCallbackURL, loginHint, attempt]); + let startRequest = startRequests.current.get(requestKey); + if (!startRequest) { + startRequest = requestJson("/api/auth/sign-in/sso", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + organizationSlug: orgSlug, + callbackURL, + errorCallbackURL, + loginHint, + }), + }); + startRequests.current.set(requestKey, startRequest); + } + const { response, payload } = await startRequest; if (!response.ok) { throw new Error(