From 39590eaff621c00d927f81f5ce1f36039f152c54 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Thu, 20 Aug 2026 08:20:18 +0200 Subject: [PATCH] fix(auth): allow unlinking providers after the session ages past a day (#3364) Better Auth guards `/unlink-account` with `freshSessionMiddleware`, which rejects any session whose `createdAt` is older than `freshAge` (one day by default). Sessions here last a week and there is no re-authentication flow to refresh that timestamp, so disconnecting a provider failed with `SESSION_NOT_FRESH` for every user who signed in more than a day ago. Disable the freshness gate, and teach `getReadableErrorMessage` to read plain error objects: Better Auth client errors are `{ code, message, status }` objects rather than `Error` instances, so every auth toast was collapsing to its generic fallback instead of showing the real reason. --- apps/web/src/libs/error-message.test.ts | 8 ++++++++ apps/web/src/libs/error-message.ts | 5 +++++ packages/auth/src/config.test.ts | 6 ++++++ packages/auth/src/config.ts | 6 ++++++ 4 files changed, 25 insertions(+) diff --git a/apps/web/src/libs/error-message.test.ts b/apps/web/src/libs/error-message.test.ts index 74e676c6b..aa1b1c68e 100644 --- a/apps/web/src/libs/error-message.test.ts +++ b/apps/web/src/libs/error-message.test.ts @@ -11,8 +11,16 @@ describe("getReadableErrorMessage", () => { expect(getReadableErrorMessage(new Error("boom"), "fallback")).toBe("boom"); }); + it("returns the message of a plain error object (Better Auth client errors)", () => { + expect(getReadableErrorMessage({ code: "SESSION_NOT_FRESH", message: "Session is not fresh" }, "fallback")).toBe( + "Session is not fresh", + ); + }); + it("returns fallback for unknown shapes", () => { expect(getReadableErrorMessage({ random: "object" }, "fallback")).toBe("fallback"); + expect(getReadableErrorMessage({ message: "" }, "fallback")).toBe("fallback"); + expect(getReadableErrorMessage({ message: 42 }, "fallback")).toBe("fallback"); expect(getReadableErrorMessage(null, "fallback")).toBe("fallback"); expect(getReadableErrorMessage(undefined, "fallback")).toBe("fallback"); expect(getReadableErrorMessage(42, "fallback")).toBe("fallback"); diff --git a/apps/web/src/libs/error-message.ts b/apps/web/src/libs/error-message.ts index 577f355fd..bb0ffa861 100644 --- a/apps/web/src/libs/error-message.ts +++ b/apps/web/src/libs/error-message.ts @@ -3,6 +3,11 @@ import { ORPCError } from "@orpc/client"; export function getReadableErrorMessage(error: unknown, fallback: string): string { if (typeof error === "string" && error) return error; if (error instanceof Error && error.message) return error.message; + // Better Auth client errors are plain objects ({ code, message, status }), not Error instances. + if (typeof error === "object" && error !== null && "message" in error) { + const { message } = error as { message?: unknown }; + if (typeof message === "string" && message) return message; + } return fallback; } diff --git a/packages/auth/src/config.test.ts b/packages/auth/src/config.test.ts index c274496f4..f59de203d 100644 --- a/packages/auth/src/config.test.ts +++ b/packages/auth/src/config.test.ts @@ -13,3 +13,9 @@ describe("social provider signup policy", () => { }, ); }); + +describe("session freshness", () => { + it("disables the freshness gate so provider unlinking works for week-old sessions", () => { + expect(auth.options.session?.freshAge).toBe(0); + }); +}); diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index be0922ab7..6c6f7e7bb 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -203,6 +203,12 @@ const getAuthConfig = () => { }, }, + // Better Auth gates `/unlink-account` (and `/list-sessions`) behind a "fresh" + // session, which defaults to one day old. Sessions here live for a week and + // there is no re-authentication flow to refresh that timestamp, so disconnecting + // a provider failed with `SESSION_NOT_FRESH` for anyone who signed in yesterday. + session: { freshAge: 0 }, + account: { accountLinking: { enabled: true,