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"
|
| "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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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