From a1a672b672647b3a65b14f04860f930184e99517 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 13 Aug 2026 02:11:15 -0600 Subject: [PATCH] fix(auth): link SCIM-provisioned users through SSO and surface sign-in errors SCIM provisioning created users with emailVerified: false, so better-auth refused to link the matching SSO account (account_not_linked), and the resulting redirect landed on better-auth's bare /error page with no actionable feedback. - Mark SCIM-provisioned users as email-verified at creation time, same trust rationale already applied to admin-invited credential users. - Sync a SCIM user's membership role to the organization's configured default role instead of the hardcoded "member" the SCIM plugin creates. - Set errorCallbackURL/onAPIError.errorURL to the sign-in page so failed SSO/OAuth callbacks land back on Dokploy's UI instead of better-auth's generic error page. - Read the error query param on the sign-in page and show it through the existing AlertBlock instead of failing silently. Fixes #4973 --- .../proprietary/auth/sign-in-with-github.tsx | 1 + .../proprietary/auth/sign-in-with-google.tsx | 1 + .../proprietary/sso/sign-in-with-sso.tsx | 1 + apps/dokploy/pages/index.tsx | 25 ++++++++++++++++++- packages/server/src/lib/auth.ts | 23 +++++++++++++++-- 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx index 347507383..60b525867 100644 --- a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx @@ -13,6 +13,7 @@ export function SignInWithGithub() { try { const { error } = await authClient.signIn.social({ provider: "github", + errorCallbackURL: "/", }); if (error) { toast.error(error.message); diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx index e40d8d9b5..1b62a2bee 100644 --- a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx @@ -13,6 +13,7 @@ export function SignInWithGoogle() { try { const { error } = await authClient.signIn.social({ provider: "google", + errorCallbackURL: "/", }); if (error) { toast.error(error.message); diff --git a/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx b/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx index e589f4df4..4b0ccbf71 100644 --- a/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx +++ b/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx @@ -50,6 +50,7 @@ export function SignInWithSSO({ const { data, error } = await authClient.signIn.sso({ email: values.email, callbackURL: "/dashboard/home", + errorCallbackURL: "/", }); if (error) { toast.error(error.message ?? "Failed to sign in with SSO"); diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index e8381c1cb..8f1adae72 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -10,7 +10,7 @@ import { Fingerprint } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; -import { type ReactElement, useState } from "react"; +import { type ReactElement, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -85,6 +85,29 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) { }, }); + useEffect(() => { + const queryError = router.query.error; + if (!queryError) return; + + const raw = Array.isArray(queryError) ? queryError[0] : queryError; + if (!raw) return; + const normalized = raw.replace(/[+_]/g, " ").toLowerCase(); + + setError( + normalized.includes("account not linked") + ? "This account already exists but isn't linked to that sign-in provider yet. Contact your administrator to link it." + : normalized.includes("access denied") + ? "Access was denied by the identity provider." + : "We couldn't complete sign-in. Please try again or contact your administrator.", + ); + + const { error: _removed, ...rest } = router.query; + router.replace({ pathname: router.pathname, query: rest }, undefined, { + shallow: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.query.error]); + const onSubmit = async (values: LoginForm) => { setIsLoginLoading(true); try { diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 49c7a2964..ab52c6ca2 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -77,6 +77,9 @@ const createBetterAuth = () => ...(!IS_CLOUD ? ["/verify-email"] : []), ], secret: betterAuthSecret, + onAPIError: { + errorURL: "/", + }, ...(!IS_CLOUD ? { advanced: { @@ -165,6 +168,9 @@ const createBetterAuth = () => user: { create: { before: async (_user, context) => { + if (context?.path.includes("/scim")) { + return { data: { emailVerified: true } }; + } if (!IS_CLOUD) { const xDokployToken = context?.request?.headers?.get("x-dokploy-token"); @@ -197,8 +203,7 @@ const createBetterAuth = () => } } else { const isSSORequest = context?.path.includes("/sso"); - const isSCIMRequest = context?.path.includes("/scim"); - if (isSSORequest || isSCIMRequest) { + if (isSSORequest) { return; } const isAdminPresent = await db.query.member.findFirst({ @@ -251,6 +256,20 @@ const createBetterAuth = () => } if (isSCIMRequest) { + const membership = await db.query.member.findFirst({ + where: eq(schema.member.userId, user.id), + }); + if (membership) { + const defaultRole = await resolveOrganizationDefaultRole( + membership.organizationId, + ); + if (defaultRole !== membership.role) { + await db + .update(schema.member) + .set({ role: defaultRole }) + .where(eq(schema.member.id, membership.id)); + } + } return; }