From fbfecdbc5d974d1891301dc6973f47f6b70f8907 Mon Sep 17 00:00:00 2001 From: JOY Date: Sat, 19 Sep 2026 01:29:24 +0700 Subject: [PATCH] feat(auth): auto-redirect login to the DOS ID provider (single-IdP SSO) When the dos-id provider is configured (same OIDC_CLIENT_ID/SECRET gate as the provider registration in next-auth-options), the middleware now sends /auth/login and /login to a small /auth/sso bridge page that immediately starts the next-auth dos-id sign-in. CSRF, state and the IdP redirect all stay owned by next-auth - nothing is hand-rolled. The classic login form remains the break-glass path at /auth/login?direct=1 (magic-link email + all configured providers) for when the identity provider is unreachable or an admin needs a local sign-in. The logout page keeps its explicit 'go back' button, so an IdP session can never silently log a user back in without a click. In unconfigured environments (no OIDC credentials) behavior is unchanged - the middleware gate mirrors the provider registration exactly, and 6 new proxy tests cover redirect, callbackUrl forwarding, the direct=1 escape hatch, the unconfigured case and the /login alias. --- .../app/(use-page-wrapper)/auth/sso/page.tsx | 26 +++++++ apps/web/modules/auth/sso-view.tsx | 61 ++++++++++++++++ apps/web/proxy.test.ts | 69 ++++++++++++++++++- apps/web/proxy.ts | 26 +++++++ packages/i18n/locales/en/common.json | 4 ++ 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/(use-page-wrapper)/auth/sso/page.tsx create mode 100644 apps/web/modules/auth/sso-view.tsx diff --git a/apps/web/app/(use-page-wrapper)/auth/sso/page.tsx b/apps/web/app/(use-page-wrapper)/auth/sso/page.tsx new file mode 100644 index 00000000000..6be0889e194 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/auth/sso/page.tsx @@ -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 ; +}; + +export default Page; diff --git a/apps/web/modules/auth/sso-view.tsx b/apps/web/modules/auth/sso-view.tsx new file mode 100644 index 00000000000..d68670466fd --- /dev/null +++ b/apps/web/modules/auth/sso-view.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import type { ParsedUrlQuery } from "node:querystring"; +import { useEffect, 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]); + + const fallbackHref = `/auth/login?direct=1&callbackUrl=${encodeURIComponent(callbackUrl)}`; + + return ( + +
+

+ {t(failed ? "sso_redirect_failed" : "sso_redirecting_title")} +

+
+

{t("sso_redirecting_body")}

+
+
+ +
+ ); +} + +export default SsoRedirect; diff --git a/apps/web/proxy.test.ts b/apps/web/proxy.test.ts index 3b9ae13f0b4..5669f707c8e 100644 --- a/apps/web/proxy.test.ts +++ b/apps/web/proxy.test.ts @@ -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(() => { @@ -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`, @@ -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"); + }); + + 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; diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index f656c7be34d..251c7f8bfbc 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -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"; + const proxy = async (req: NextRequest): Promise> => { const url = req.nextUrl; const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req }); @@ -79,6 +96,15 @@ const proxy = async (req: NextRequest): Promise> => { } } + // 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"); diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 3ad1a3d99a4..5d5447a9c97 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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",