refactor: map auth errors server side

This commit is contained in:
Nicolas Meienberger 2026-03-01 14:11:07 +01:00
parent 480b2f7ba8
commit fe65c54250
7 changed files with 145 additions and 58 deletions

View file

@ -5,43 +5,26 @@ export type LoginErrorCode =
| "BANNED_USER" | "BANNED_USER"
| "SSO_LOGIN_FAILED"; | "SSO_LOGIN_FAILED";
const VALID_ERROR_CODES: Set<LoginErrorCode> = new Set([
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
]);
export function decodeLoginError(error?: string): LoginErrorCode | null { export function decodeLoginError(error?: string): LoginErrorCode | null {
if (!error) { if (!error) {
return null; return null;
} }
let decoded = ""; const code = decodeURIComponent(error);
try { if (VALID_ERROR_CODES.has(code as LoginErrorCode)) {
decoded = decodeURIComponent(error); return code as LoginErrorCode;
} catch {
decoded = error;
} }
decoded = decoded.toLowerCase().replace(/[-_\s]+/g, "_"); return null;
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";
} }
export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null { export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null {

View file

@ -33,25 +33,78 @@ const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
describe("LoginPage", () => { 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(); const queryClient = createTestQueryClient();
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<LoginPage error="access_denied" /> <LoginPage error="INVITE_REQUIRED" />
</QueryClientProvider>, </QueryClientProvider>,
); );
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy(); 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(); const queryClient = createTestQueryClient();
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<LoginPage error="must%20be%20invited" /> <LoginPage error="ACCOUNT_LINK_REQUIRED" />
</QueryClientProvider>, </QueryClientProvider>,
); );
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(
<QueryClientProvider client={queryClient}>
<LoginPage error="BANNED_USER" />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<LoginPage error="EMAIL_NOT_VERIFIED" />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<LoginPage error="SSO_LOGIN_FAILED" />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<LoginPage error="some_random_error" />
</QueryClientProvider>,
);
// The error message container should be hidden
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
}); });
}); });

View file

@ -135,7 +135,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const { data, error } = await authClient.signIn.sso({ const { data, error } = await authClient.signIn.sso({
providerId: providerId, providerId: providerId,
callbackURL: callbackPath, callbackURL: callbackPath,
errorCallbackURL: callbackPath, errorCallbackURL: "/api/v1/auth/login-error",
}); });
if (error) throw error; if (error) throw error;

View file

@ -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);
}),
},
],
},
};
}

View file

@ -1,14 +1,30 @@
import type { GenericEndpointContext } from "@better-auth/core"; 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 { export function normalizeEmail(email: string): string {
return email.trim().toLowerCase(); 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 { export function extractProviderIdFromUrl(url: string): string | null {
try { try {
const pathname = new URL(url, "http://localhost").pathname; const pathname = new URL(url, "http://localhost").pathname;
const match = pathname.match(/\/sso\/(?:saml2\/)?callback\/([^/]+)$/); return extractProviderIdFromPathname(pathname);
return match?.[1] ?? null;
} catch { } catch {
return null; return null;
} }

View file

@ -25,6 +25,8 @@ import {
import { authService } from "./auth.service"; import { authService } from "./auth.service";
import { requireAdmin, requireAuth, requireOrgAdmin } from "./auth.middleware"; import { requireAdmin, requireAuth, requireOrgAdmin } from "./auth.middleware";
import { auth } from "~/server/lib/auth"; import { auth } from "~/server/lib/auth";
import { mapAuthErrorToCode } from "./auth.errors";
import { config } from "~/server/core/config";
export const authController = new Hono() export const authController = new Hono()
.get("/status", getStatusDto, async (c) => { .get("/status", getStatusDto, async (c) => {
@ -201,4 +203,9 @@ export const authController = new Hono()
} }
return c.json({ success: true }); 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}`);
}); });

View file

@ -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.";
}
}