Skip to content
Merged
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
26 changes: 26 additions & 0 deletions apps/web/app/(use-page-wrapper)/auth/sso/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";

import { buildLegacyCtx } from "@lib/buildLegacyCtx";

import SsoRedirect from "~/auth/sso-view";

export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("sso_redirecting_title"),
(t) => t("sso_redirecting_body"),
undefined,
undefined,
"/auth/sso"
);
};

const Page = async ({ params, searchParams }: PageProps) => {
const h = await headers();
const context = buildLegacyCtx(h, await cookies(), await params, await searchParams);

return <SsoRedirect query={context.query} />;
};

export default Page;
61 changes: 61 additions & 0 deletions apps/web/modules/auth/sso-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import { signIn } from "next-auth/react";
import type { ParsedUrlQuery } from "node:querystring";
import { useEffect, useState } from "react";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import useRef from React to track whether the sign-in flow has already been initiated, preventing duplicate execution in Strict Mode.

Suggested change
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";


import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";

import AuthContainer from "@components/ui/AuthContainer";

export type PageProps = {
query: ParsedUrlQuery;
};

/**
* Bridge page of the DOS ID auto-SSO flow. The middleware sends /auth/login here
* whenever the dos-id provider is configured, and this view immediately starts the
* next-auth sign-in (which owns CSRF + state + the IdP redirect). The classic
* login form stays reachable through /auth/login?direct=1 as the break-glass path.
*/
export function SsoRedirect(props: PageProps) {
const { t } = useLocale();
const [failed, setFailed] = useState(false);

const callbackUrl = typeof props.query.callbackUrl === "string" ? props.query.callbackUrl : "/";

useEffect(() => {
let cancelled = false;
signIn("dos-id", { callbackUrl, redirect: true }).catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [callbackUrl]);
Comment on lines +28 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Prevent Duplicate signIn Calls in Strict Mode

In React 18 Strict Mode (and potentially during fast re-renders), useEffect runs twice on mount. Calling signIn concurrently twice can overwrite the NextAuth state/nonce cookies, leading to state mismatch errors (OAuthCallback errors) when the user returns from the IdP.

Using a useRef to ensure signIn is only called once solves this issue.

  const signInAttempted = useRef(false);

  useEffect(() => {
    if (signInAttempted.current) return;
    signInAttempted.current = true;

    let cancelled = false;
    signIn("dos-id", { callbackUrl, redirect: true }).catch(() => {
      if (!cancelled) setFailed(true);
    });
    return () => {
      cancelled = true;
    };
  }, [callbackUrl]);


const fallbackHref = `/auth/login?direct=1&callbackUrl=${encodeURIComponent(callbackUrl)}`;

return (
<AuthContainer showLogo>
<div className="text-center">
<h3 className="text-emphasis text-lg font-medium leading-6">
{t(failed ? "sso_redirect_failed" : "sso_redirecting_title")}
</h3>
<div className="mt-2">
<p className="text-subtle text-sm">{t("sso_redirecting_body")}</p>
</div>
</div>
<Button
className="mt-6 flex w-full justify-center"
loading={!failed}
disabled={!failed}
href={fallbackHref}>
{t("sso_use_other_login")}
</Button>
</AuthContainer>
);
}

export default SsoRedirect;
69 changes: 68 additions & 1 deletion apps/web/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ describe("Middleware Integration Tests", () => {
describe("CSP Headers", () => {
beforeEach(() => {
vi.stubEnv("CSP_POLICY", "strict");
// Keep these tests independent of any local .env that configures dos-id,
// otherwise /auth/login would take the auto-SSO redirect instead.
vi.stubEnv("OIDC_CLIENT_ID", "");
vi.stubEnv("OIDC_CLIENT_SECRET", "");
});

afterEach(() => {
Expand Down Expand Up @@ -342,7 +346,9 @@ describe("Middleware Integration Tests", () => {
});

it("should add x-csp-status when CSP_POLICY not set", async () => {
vi.unstubAllEnvs();
// Unset only CSP_POLICY; keep the OIDC stubs from beforeEach so this test
// stays independent of any local dos-id configuration.
vi.stubEnv("CSP_POLICY", "");

const req = createTestRequest({
url: `${WEBAPP_URL}/auth/login`,
Expand Down Expand Up @@ -441,6 +447,67 @@ describe("Middleware Integration Tests", () => {
});
});

describe("DOS ID auto-SSO redirect", () => {
beforeEach(() => {
vi.stubEnv("OIDC_CLIENT_ID", "test-client-id");
vi.stubEnv("OIDC_CLIENT_SECRET", "test-client-secret");
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("redirects /auth/login to /auth/sso when the dos-id provider is configured", async () => {
const req = createTestRequest({ url: `${WEBAPP_URL}/auth/login` });

const res = await callProxy(req);
expectStatus(res, 307);
const location = getHeader(res, "location") || "";
expect(location).toContain("/auth/sso");
});

it("forwards callbackUrl to the SSO bridge", async () => {
const req = createTestRequest({ url: `${WEBAPP_URL}/auth/login?callbackUrl=%2Fbooking%2F1` });

const res = await callProxy(req);
const location = getHeader(res, "location") || "";
expect(location).toContain("/auth/sso");
expect(location).toContain("callbackUrl=%2Fbooking%2F1");
});

it("keeps the classic form for ?direct=1 (break-glass)", async () => {
const req = createTestRequest({ url: `${WEBAPP_URL}/auth/login?direct=1` });

const res = await callProxy(req);
expect(getHeader(res, "x-middleware-next")).toBe("1");
});
Comment on lines +478 to +483

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case to verify that the middleware does not redirect to the SSO bridge when an error parameter is present in the URL, preventing infinite redirect loops.

  it("keeps the classic form for ?direct=1 (break-glass)", async () => {
    const req = createTestRequest({ url: `${WEBAPP_URL}/auth/login?direct=1` });

    const res = await callProxy(req);
    expect(getHeader(res, "x-middleware-next")).toBe("1");
  });

  it("does not redirect when an error parameter is present", async () => {
    const req = createTestRequest({ url: `${WEBAPP_URL}/auth/login?error=OAuthSignin` });

    const res = await callProxy(req);
    expect(getHeader(res, "x-middleware-next")).toBe("1");
  });


it("does not redirect when the dos-id provider is not configured", async () => {
vi.stubEnv("OIDC_CLIENT_ID", "");
vi.stubEnv("OIDC_CLIENT_SECRET", "");

const req = createTestRequest({ url: `${WEBAPP_URL}/auth/login` });

const res = await callProxy(req);
expect(getHeader(res, "x-middleware-next")).toBe("1");
});

it("does not redirect non-login paths", async () => {
const req = createTestRequest({ url: `${WEBAPP_URL}/auth/logout` });

const res = await callProxy(req);
expect(getHeader(res, "x-middleware-next")).toBe("1");
});

it("also covers the legacy /login alias", async () => {
const req = createTestRequest({ url: `${WEBAPP_URL}/login` });

const res = await callProxy(req);
expectStatus(res, 307);
expect(getHeader(res, "location")).toContain("/auth/sso");
});
});

describe("Middleware Matcher Configuration", () => {
const matcher: string[] = config.matcher;

Expand Down
26 changes: 26 additions & 0 deletions apps/web/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ const shouldEnforceCsp = (url: URL) => {
return url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login");
};

// Mirror of the registration gate in packages/features/auth (next-auth-options):
// the dos-id provider only exists when both credentials are configured. The
// NEXT_PUBLIC login flag alone is not enough - redirecting to an unregistered
// provider would make signIn fail.
const isDosIdProviderConfigured = () =>
!!(
(process.env.OIDC_CLIENT_ID || "").trim() &&
(process.env.OIDC_CLIENT_SECRET || process.env.CROVE_OAUTH_CLIENT_SECRET || "").trim()
);

const isLoginPath = (url: URL) => url.pathname === "/auth/login" || url.pathname === "/login";

// ?direct=1 keeps the classic form reachable as the break-glass path when the
// identity provider is unreachable or an admin needs a local sign-in.
export const shouldRedirectToDosIdSso = (url: URL) =>
isDosIdProviderConfigured() && isLoginPath(url) && url.searchParams.get("direct") !== "1";
Comment on lines +82 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Infinite Redirect Loop on Authentication Failure

If the DOS ID authentication flow fails, NextAuth typically redirects the user back to the login page with an error query parameter (e.g., /auth/login?error=OAuthSignin).

Without checking for the presence of the error parameter, the middleware will immediately redirect the user back to /auth/sso, which triggers another signIn attempt, leading to an infinite redirect loop.

We should check for !url.searchParams.has("error") before redirecting to the SSO bridge.

export const shouldRedirectToDosIdSso = (url: URL) =>
  isDosIdProviderConfigured() &&
  isLoginPath(url) &&
  url.searchParams.get("direct") !== "1" &&
  !url.searchParams.has("error");


const proxy = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const url = req.nextUrl;
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
Expand All @@ -79,6 +96,15 @@ const proxy = async (req: NextRequest): Promise<NextResponse<unknown>> => {
}
}

// Single-IdP deployments skip the login form entirely: hop to /auth/sso which
// starts the next-auth dos-id flow (CSRF/state handled by next-auth).
if (shouldRedirectToDosIdSso(url)) {
const ssoUrl = new URL("/auth/sso", reqWithEnrichedHeaders.url);
const callbackUrl = url.searchParams.get("callbackUrl");
if (callbackUrl) ssoUrl.searchParams.set("callbackUrl", callbackUrl);
return NextResponse.redirect(ssoUrl);
}

if (url.pathname.startsWith("/apps/installed")) {
const returnTo = reqWithEnrichedHeaders.cookies.get("return-to");

Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,10 @@
"user_creation_error": "Error creating a new user. Please try again.",
"signin_with_google": "Sign in with Google",
"signin_with_dos_id": "Sign in with DOS.Me ID",
"sso_redirecting_title": "Redirecting to DOS.Me ID...",
"sso_redirecting_body": "You will be signed in through the DOS identity provider. If nothing happens, use the button below.",
"sso_redirect_failed": "Could not reach the DOS.Me ID sign-in.",
"sso_use_other_login": "Use another sign-in method",
"signin_with_saml": "Sign in with SAML",
"signin_with_saml_oidc": "Sign in with SAML/OIDC",
"continue_with_email": "Continue with Email",
Expand Down
Loading