refactor: map auth errors server side
This commit is contained in:
parent
480b2f7ba8
commit
fe65c54250
7 changed files with 145 additions and 58 deletions
|
|
@ -5,43 +5,26 @@ export type LoginErrorCode =
|
|||
| "BANNED_USER"
|
||||
| "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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage error="access_denied" />
|
||||
<LoginPage error="INVITE_REQUIRED" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage error="must%20be%20invited" />
|
||||
<LoginPage error="ACCOUNT_LINK_REQUIRED" />
|
||||
</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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
49
app/server/modules/auth/auth.errors.ts
Normal file
49
app/server/modules/auth/auth.errors.ts
Normal 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.";
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue