From fe65c542501300a80483685b942adbd82cec82dc Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 1 Mar 2026 14:11:07 +0100 Subject: [PATCH] refactor: map auth errors server side --- app/client/lib/auth-errors.ts | 41 ++++-------- .../auth/routes/__tests__/login.test.tsx | 63 +++++++++++++++++-- app/client/modules/auth/routes/login.tsx | 2 +- .../plugins/sso-trusted-provider-linking.ts | 21 ------- app/server/lib/auth/utils/sso-context.ts | 20 +++++- app/server/modules/auth/auth.controller.ts | 7 +++ app/server/modules/auth/auth.errors.ts | 49 +++++++++++++++ 7 files changed, 145 insertions(+), 58 deletions(-) delete mode 100644 app/server/lib/auth/plugins/sso-trusted-provider-linking.ts create mode 100644 app/server/modules/auth/auth.errors.ts diff --git a/app/client/lib/auth-errors.ts b/app/client/lib/auth-errors.ts index 025ac021..a2f0f4c6 100644 --- a/app/client/lib/auth-errors.ts +++ b/app/client/lib/auth-errors.ts @@ -5,43 +5,26 @@ export type LoginErrorCode = | "BANNED_USER" | "SSO_LOGIN_FAILED"; +const VALID_ERROR_CODES: Set = new Set([ + "ACCOUNT_LINK_REQUIRED", + "EMAIL_NOT_VERIFIED", + "INVITE_REQUIRED", + "BANNED_USER", + "SSO_LOGIN_FAILED", +]); + export function decodeLoginError(error?: string): LoginErrorCode | null { if (!error) { return null; } - let decoded = ""; + const code = decodeURIComponent(error); - try { - decoded = decodeURIComponent(error); - } catch { - decoded = error; + if (VALID_ERROR_CODES.has(code as LoginErrorCode)) { + return code as LoginErrorCode; } - decoded = decoded.toLowerCase().replace(/[-_\s]+/g, "_"); - - if (decoded.includes("account_not_linked")) { - return "ACCOUNT_LINK_REQUIRED"; - } - - if (decoded.includes("email_not_verified")) { - return "EMAIL_NOT_VERIFIED"; - } - - if (decoded.includes("banned_user") || decoded.includes("banned")) { - return "BANNED_USER"; - } - - if ( - decoded.includes("access_denied") || - decoded.includes("must_be_invited") || - decoded.includes("unable_to_create_session") || - decoded.includes("invite") - ) { - return "INVITE_REQUIRED"; - } - - return "SSO_LOGIN_FAILED"; + return null; } export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null { diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index f8f56678..ecf0ec18 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -33,25 +33,78 @@ const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; describe("LoginPage", () => { - test("shows an invite-only message when SSO returns access_denied", async () => { + test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => { const queryClient = createTestQueryClient(); render( - + , ); expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy(); }); - test("shows an invite-only message for URL-encoded invitation errors", async () => { + test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => { const queryClient = createTestQueryClient(); render( - + , ); - expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy(); + expect( + await screen.findByText( + "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.", + ), + ).toBeTruthy(); + }); + + test("shows banned message when SSO returns BANNED_USER code", async () => { + const queryClient = createTestQueryClient(); + render( + + + , + ); + + expect( + await screen.findByText( + "You have been banned from this application. Please contact support if you believe this is an error.", + ), + ).toBeTruthy(); + }); + + test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => { + const queryClient = createTestQueryClient(); + render( + + + , + ); + + expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy(); + }); + + test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => { + const queryClient = createTestQueryClient(); + render( + + + , + ); + + expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy(); + }); + + test("does not show error message for invalid error codes", async () => { + const queryClient = createTestQueryClient(); + render( + + + , + ); + + // The error message container should be hidden + expect(screen.queryByText(inviteOnlyMessage)).toBeNull(); }); }); diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index e9f05d80..b4b01cd6 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -135,7 +135,7 @@ export function LoginPage({ error }: LoginPageProps = {}) { const { data, error } = await authClient.signIn.sso({ providerId: providerId, callbackURL: callbackPath, - errorCallbackURL: callbackPath, + errorCallbackURL: "/api/v1/auth/login-error", }); if (error) throw error; diff --git a/app/server/lib/auth/plugins/sso-trusted-provider-linking.ts b/app/server/lib/auth/plugins/sso-trusted-provider-linking.ts deleted file mode 100644 index 196dc03b..00000000 --- a/app/server/lib/auth/plugins/sso-trusted-provider-linking.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { BetterAuthPlugin } from "better-auth"; -import { createAuthMiddleware } from "better-auth/api"; -import { isSsoCallbackPath, trustSsoProviderForLinking } from "../middlewares/trust-sso-provider-for-linking"; - -export function ssoTrustedProviderLinkingPlugin(): BetterAuthPlugin { - return { - id: "sso-trusted-provider-linking", - hooks: { - before: [ - { - matcher(context) { - return isSsoCallbackPath(context.path); - }, - handler: createAuthMiddleware(async (ctx) => { - await trustSsoProviderForLinking(ctx); - }), - }, - ], - }, - }; -} diff --git a/app/server/lib/auth/utils/sso-context.ts b/app/server/lib/auth/utils/sso-context.ts index ab36b14a..7c8edac1 100644 --- a/app/server/lib/auth/utils/sso-context.ts +++ b/app/server/lib/auth/utils/sso-context.ts @@ -1,14 +1,30 @@ import type { GenericEndpointContext } from "@better-auth/core"; +const SSO_CALLBACK_PATH_SEGMENTS = ["/sso/callback/", "/sso/saml2/callback/", "/sso/saml2/sp/acs/"] as const; + +const SSO_CALLBACK_PATH_PATTERN = /\/sso\/(?:callback|saml2\/callback|saml2\/sp\/acs)\/([^/]+)$/; + export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } +export function isSsoCallbackPath(pathname: string): boolean { + return SSO_CALLBACK_PATH_SEGMENTS.some((segment) => pathname.includes(segment)); +} + +export function extractProviderIdFromPathname(pathname: string): string | null { + if (!isSsoCallbackPath(pathname)) { + return null; + } + + const match = pathname.match(SSO_CALLBACK_PATH_PATTERN); + return match?.[1] ?? null; +} + export function extractProviderIdFromUrl(url: string): string | null { try { const pathname = new URL(url, "http://localhost").pathname; - const match = pathname.match(/\/sso\/(?:saml2\/)?callback\/([^/]+)$/); - return match?.[1] ?? null; + return extractProviderIdFromPathname(pathname); } catch { return null; } diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index dac195f9..476c66a2 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -25,6 +25,8 @@ import { import { authService } from "./auth.service"; import { requireAdmin, requireAuth, requireOrgAdmin } from "./auth.middleware"; import { auth } from "~/server/lib/auth"; +import { mapAuthErrorToCode } from "./auth.errors"; +import { config } from "~/server/core/config"; export const authController = new Hono() .get("/status", getStatusDto, async (c) => { @@ -201,4 +203,9 @@ export const authController = new Hono() } return c.json({ success: true }); + }) + .get("/login-error", async (c) => { + const error = c.req.query("error"); + const errorCode = error ? mapAuthErrorToCode(error) : "SSO_LOGIN_FAILED"; + return c.redirect(`${config.baseUrl}/login?error=${errorCode}`); }); diff --git a/app/server/modules/auth/auth.errors.ts b/app/server/modules/auth/auth.errors.ts new file mode 100644 index 00000000..65a139e8 --- /dev/null +++ b/app/server/modules/auth/auth.errors.ts @@ -0,0 +1,49 @@ +export type LoginErrorCode = + | "ACCOUNT_LINK_REQUIRED" + | "EMAIL_NOT_VERIFIED" + | "INVITE_REQUIRED" + | "BANNED_USER" + | "SSO_LOGIN_FAILED"; + +const INVITE_REQUIRED_ERRORS = new Set([ + "Access denied. You must be invited to this organization before you can sign in with SSO.", + "SSO sign-in is invite-only for this organization", + "unable to create session", +]); + +export function mapAuthErrorToCode(error: string): LoginErrorCode { + const decoded = decodeURIComponent(error); + + if (decoded === "account not linked") { + return "ACCOUNT_LINK_REQUIRED"; + } + + if (decoded === "EMAIL_NOT_VERIFIED") { + return "EMAIL_NOT_VERIFIED"; + } + + if (decoded === "banned") { + return "BANNED_USER"; + } + + if (INVITE_REQUIRED_ERRORS.has(decoded)) { + return "INVITE_REQUIRED"; + } + + return "SSO_LOGIN_FAILED"; +} + +export function getLoginErrorDescription(errorCode: LoginErrorCode): string { + switch (errorCode) { + case "ACCOUNT_LINK_REQUIRED": + return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator."; + case "EMAIL_NOT_VERIFIED": + return "Your identity provider did not mark your email as verified."; + case "INVITE_REQUIRED": + return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; + case "BANNED_USER": + return "You have been banned from this application. Please contact support if you believe this is an error."; + case "SSO_LOGIN_FAILED": + return "SSO authentication failed. Please try again."; + } +}