Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion ee/apps/den-api/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions ee/apps/den-api/test/single-org-route-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 24 additions & 14 deletions ee/apps/den-web/app/sso/[orgSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -12,6 +12,7 @@ export default function OrganizationSsoSignInPage() {
const [error, setError] = useState<string | null>(null);
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const startRequests = useRef(new Map<string, ReturnType<typeof requestJson>>());
const orgSlug = typeof params?.orgSlug === "string" ? params.orgSlug : "";

const callbackURL = useMemo(() => searchParams.get("callbackURL") || getSocialCallbackUrl(), [searchParams]);
Expand All @@ -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(
Expand Down