-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): auto-redirect login to the DOS ID provider (single-IdP SSO) #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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"; | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent Duplicate
|
||
|
|
||
| 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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"); | ||
| }); | ||
|
Comment on lines
+478
to
+483
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a test case to verify that the middleware does not redirect to the SSO bridge when an 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; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Infinite Redirect Loop on Authentication FailureIf the DOS ID authentication flow fails, NextAuth typically redirects the user back to the login page with an Without checking for the presence of the We should check for 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 }); | ||
|
|
@@ -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"); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import
useReffrom React to track whether the sign-in flow has already been initiated, preventing duplicate execution in Strict Mode.