From e4a0a3e011ddbde50a407c28df6e743b30f2d5ef Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Mon, 9 Feb 2026 06:30:55 +0900 Subject: [PATCH 01/13] fix(oauth): handle `refresh_token_expires_in` in `refreshAccessToken` (#7810) --- .../src/oauth2/refresh-access-token.test.ts | 94 +++++++++++++++++++ .../core/src/oauth2/refresh-access-token.ts | 8 ++ 2 files changed, 102 insertions(+) create mode 100644 packages/core/src/oauth2/refresh-access-token.test.ts diff --git a/packages/core/src/oauth2/refresh-access-token.test.ts b/packages/core/src/oauth2/refresh-access-token.test.ts new file mode 100644 index 0000000000..c0314424df --- /dev/null +++ b/packages/core/src/oauth2/refresh-access-token.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@better-fetch/fetch", () => ({ + betterFetch: vi.fn(), +})); + +import { betterFetch } from "@better-fetch/fetch"; +import { refreshAccessToken } from "./refresh-access-token"; + +const mockedBetterFetch = vi.mocked(betterFetch); + +describe("refreshAccessToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should set accessTokenExpiresAt when expires_in is returned", async () => { + const now = Date.now(); + mockedBetterFetch.mockResolvedValueOnce({ + data: { + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + token_type: "Bearer", + }, + error: null, + }); + + const tokens = await refreshAccessToken({ + refreshToken: "old-refresh-token", + options: { clientId: "test-client", clientSecret: "test-secret" }, + tokenEndpoint: "https://example.com/token", + }); + + expect(tokens.accessToken).toBe("new-access-token"); + expect(tokens.refreshToken).toBe("new-refresh-token"); + expect(tokens.accessTokenExpiresAt).toBeInstanceOf(Date); + expect(tokens.accessTokenExpiresAt!.getTime()).toBeGreaterThanOrEqual( + now + 3600 * 1000 - 1000, + ); + expect(tokens.refreshTokenExpiresAt).toBeUndefined(); + }); + + /** + * @see https://github.com/better-auth/better-auth/issues/7682 + */ + it("should set refreshTokenExpiresAt when refresh_token_expires_in is returned", async () => { + const now = Date.now(); + mockedBetterFetch.mockResolvedValueOnce({ + data: { + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + refresh_token_expires_in: 86400, + token_type: "Bearer", + }, + error: null, + }); + + const tokens = await refreshAccessToken({ + refreshToken: "old-refresh-token", + options: { clientId: "test-client", clientSecret: "test-secret" }, + tokenEndpoint: "https://example.com/token", + }); + + expect(tokens.accessToken).toBe("new-access-token"); + expect(tokens.refreshToken).toBe("new-refresh-token"); + expect(tokens.accessTokenExpiresAt).toBeInstanceOf(Date); + expect(tokens.refreshTokenExpiresAt).toBeInstanceOf(Date); + expect(tokens.refreshTokenExpiresAt!.getTime()).toBeGreaterThanOrEqual( + now + 86400 * 1000 - 1000, + ); + }); + + it("should not set refreshTokenExpiresAt when refresh_token_expires_in is not returned", async () => { + mockedBetterFetch.mockResolvedValueOnce({ + data: { + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + token_type: "Bearer", + }, + error: null, + }); + + const tokens = await refreshAccessToken({ + refreshToken: "old-refresh-token", + options: { clientId: "test-client", clientSecret: "test-secret" }, + tokenEndpoint: "https://example.com/token", + }); + + expect(tokens.refreshTokenExpiresAt).toBeUndefined(); + }); +}); diff --git a/packages/core/src/oauth2/refresh-access-token.ts b/packages/core/src/oauth2/refresh-access-token.ts index 0fe48df0d5..de3bf2b27b 100644 --- a/packages/core/src/oauth2/refresh-access-token.ts +++ b/packages/core/src/oauth2/refresh-access-token.ts @@ -119,6 +119,7 @@ export async function refreshAccessToken({ access_token: string; refresh_token?: string | undefined; expires_in?: number | undefined; + refresh_token_expires_in?: number | undefined; token_type?: string | undefined; scope?: string | undefined; id_token?: string | undefined; @@ -145,5 +146,12 @@ export async function refreshAccessToken({ ); } + if (data.refresh_token_expires_in) { + const now = new Date(); + tokens.refreshTokenExpiresAt = new Date( + now.getTime() + data.refresh_token_expires_in * 1000, + ); + } + return tokens; } From dfeefc4f58ee24f1ee6cdbe22d5e720b2ba621b8 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:51:45 +0900 Subject: [PATCH 02/13] fix: standardize `name` field masking as an empty string (#7794) Co-authored-by: Cursor Agent Co-authored-by: Taesu --- packages/better-auth/src/api/routes/callback.ts | 2 +- .../better-auth/src/plugins/oauth-proxy/index.ts | 2 +- packages/better-auth/src/social.test.ts | 8 ++------ packages/core/src/social-providers/apple.ts | 6 +++--- packages/core/src/social-providers/cognito.ts | 11 ++++++----- packages/core/src/social-providers/github.ts | 2 +- packages/core/src/social-providers/gitlab.ts | 2 +- packages/core/src/social-providers/huggingface.ts | 2 +- packages/core/src/social-providers/kakao.ts | 2 +- packages/core/src/social-providers/line.ts | 2 +- packages/core/src/social-providers/naver.ts | 2 +- packages/core/src/social-providers/notion.ts | 2 +- packages/core/src/social-providers/paybin.ts | 6 +----- packages/core/src/social-providers/polar.ts | 2 +- packages/core/src/social-providers/tiktok.ts | 3 ++- packages/core/src/social-providers/vercel.ts | 2 +- packages/sso/src/routes/sso.ts | 12 +++++++----- 17 files changed, 32 insertions(+), 36 deletions(-) diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index 8e2086ea35..8066450255 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -256,7 +256,7 @@ export const callbackOAuth = createAuthEndpoint( ...userInfo, id: String(userInfo.id), email: userInfo.email, - name: userInfo.name || userInfo.email, + name: userInfo.name || "", }, account: accountData, callbackURL, diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index e5fc18bd7f..e1f5831f57 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -407,7 +407,7 @@ export const oAuthProxy = (opts?: O) => { userInfo: { id: String(userInfo.id), email: userInfo.email, - name: userInfo.name || userInfo.email, + name: userInfo.name || "", image: userInfo.image, emailVerified: userInfo.emailVerified, }, diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index a6391bf6e1..8e47f35265 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -1061,12 +1061,8 @@ describe("Apple Provider", async () => { // Name should NOT be the email address expect(session.data?.user.name).not.toBe("user@privaterelay.appleid.com"); - // Name should be undefined, null, or space when not provided - expect( - session.data?.user.name === undefined || - session.data?.user.name === null || - session.data?.user.name === " ", - ).toBe(true); + // Name should be empty string when not provided + expect(session.data?.user.name).toBe(""); }); it("should use firstName and lastName when provided in token.user", async () => { diff --git a/packages/core/src/social-providers/apple.ts b/packages/core/src/social-providers/apple.ts index cb02ad25aa..9067254628 100644 --- a/packages/core/src/social-providers/apple.ts +++ b/packages/core/src/social-providers/apple.ts @@ -158,15 +158,15 @@ export const apple = (options: AppleOptions) => { return null; } - // TODO: " " masking will be removed when the name field is made optional + // TODO: "" masking will be removed when the name field is made optional let name: string; if (token.user?.name) { const firstName = token.user.name.firstName || ""; const lastName = token.user.name.lastName || ""; const fullName = `${firstName} ${lastName}`.trim(); - name = fullName || " "; + name = fullName; } else { - name = profile.name || " "; + name = profile.name || ""; } const emailVerified = diff --git a/packages/core/src/social-providers/cognito.ts b/packages/core/src/social-providers/cognito.ts index 7a55054a7c..456be22fc7 100644 --- a/packages/core/src/social-providers/cognito.ts +++ b/packages/core/src/social-providers/cognito.ts @@ -178,10 +178,7 @@ export const cognito = (options: CognitoOptions) => { return null; } const name = - profile.name || - profile.given_name || - profile.username || - profile.email; + profile.name || profile.given_name || profile.username || ""; const enrichedProfile = { ...profile, name, @@ -220,7 +217,11 @@ export const cognito = (options: CognitoOptions) => { return { user: { id: userInfo.sub, - name: userInfo.name || userInfo.given_name || userInfo.username, + name: + userInfo.name || + userInfo.given_name || + userInfo.username || + "", email: userInfo.email, image: userInfo.picture, emailVerified: userInfo.email_verified, diff --git a/packages/core/src/social-providers/github.ts b/packages/core/src/social-providers/github.ts index 5e5b342e0a..c74d4a4ec0 100644 --- a/packages/core/src/social-providers/github.ts +++ b/packages/core/src/social-providers/github.ts @@ -170,7 +170,7 @@ export const github = (options: GithubOptions) => { return { user: { id: profile.id, - name: profile.name || profile.login, + name: profile.name || profile.login || "", email: profile.email, image: profile.avatar_url, emailVerified, diff --git a/packages/core/src/social-providers/gitlab.ts b/packages/core/src/social-providers/gitlab.ts index 8c694a836e..566b5ce979 100644 --- a/packages/core/src/social-providers/gitlab.ts +++ b/packages/core/src/social-providers/gitlab.ts @@ -141,7 +141,7 @@ export const gitlab = (options: GitlabOptions) => { return { user: { id: profile.id, - name: profile.name ?? profile.username, + name: profile.name ?? profile.username ?? "", email: profile.email, image: profile.avatar_url, emailVerified: profile.email_verified ?? false, diff --git a/packages/core/src/social-providers/huggingface.ts b/packages/core/src/social-providers/huggingface.ts index 774717a223..763f850ef9 100644 --- a/packages/core/src/social-providers/huggingface.ts +++ b/packages/core/src/social-providers/huggingface.ts @@ -104,7 +104,7 @@ export const huggingface = (options: HuggingFaceOptions) => { return { user: { id: profile.sub, - name: profile.name || profile.preferred_username, + name: profile.name || profile.preferred_username || "", email: profile.email, image: profile.picture, emailVerified: profile.email_verified ?? false, diff --git a/packages/core/src/social-providers/kakao.ts b/packages/core/src/social-providers/kakao.ts index f976ccb36d..2b441dc942 100644 --- a/packages/core/src/social-providers/kakao.ts +++ b/packages/core/src/social-providers/kakao.ts @@ -161,7 +161,7 @@ export const kakao = (options: KakaoOptions) => { const kakaoProfile = account.profile || {}; const user = { id: String(profile.id), - name: kakaoProfile.nickname || account.name || undefined, + name: kakaoProfile.nickname || account.name || "", email: account.email, image: kakaoProfile.profile_image_url || kakaoProfile.thumbnail_image_url, diff --git a/packages/core/src/social-providers/line.ts b/packages/core/src/social-providers/line.ts index e77c811644..6c9b154833 100644 --- a/packages/core/src/social-providers/line.ts +++ b/packages/core/src/social-providers/line.ts @@ -147,7 +147,7 @@ export const line = (options: LineOptions) => { const userMap = await options.mapProfileToUser?.(profile as any); // ID preference order const id = (profile as any).sub || (profile as any).userId; - const name = (profile as any).name || (profile as any).displayName; + const name = (profile as any).name || (profile as any).displayName || ""; const image = (profile as any).picture || (profile as any).pictureUrl || undefined; const email = (profile as any).email; diff --git a/packages/core/src/social-providers/naver.ts b/packages/core/src/social-providers/naver.ts index 0f192d7cc1..f9ebcbe5ca 100644 --- a/packages/core/src/social-providers/naver.ts +++ b/packages/core/src/social-providers/naver.ts @@ -96,7 +96,7 @@ export const naver = (options: NaverOptions) => { const res = profile.response || {}; const user = { id: res.id, - name: res.name || res.nickname, + name: res.name || res.nickname || "", email: res.email, image: res.profile_image, emailVerified: false, diff --git a/packages/core/src/social-providers/notion.ts b/packages/core/src/social-providers/notion.ts index d6f4ece80f..7db9c46fb6 100644 --- a/packages/core/src/social-providers/notion.ts +++ b/packages/core/src/social-providers/notion.ts @@ -94,7 +94,7 @@ export const notion = (options: NotionOptions) => { return { user: { id: userProfile.id, - name: userProfile.name || "Notion User", + name: userProfile.name || "", email: userProfile.person?.email || null, image: userProfile.avatar_url, emailVerified: false, diff --git a/packages/core/src/social-providers/paybin.ts b/packages/core/src/social-providers/paybin.ts index 4f357d4285..aca15616f6 100644 --- a/packages/core/src/social-providers/paybin.ts +++ b/packages/core/src/social-providers/paybin.ts @@ -104,11 +104,7 @@ export const paybin = (options: PaybinOptions) => { return { user: { id: user.sub, - name: - user.name || - user.preferred_username || - (user.email ? user.email.split("@")[0] : "User") || - "User", + name: user.name || user.preferred_username || "", email: user.email, image: user.picture, emailVerified: user.email_verified || false, diff --git a/packages/core/src/social-providers/polar.ts b/packages/core/src/social-providers/polar.ts index fe9734ab1a..ae855eda76 100644 --- a/packages/core/src/social-providers/polar.ts +++ b/packages/core/src/social-providers/polar.ts @@ -96,7 +96,7 @@ export const polar = (options: PolarOptions) => { return { user: { id: profile.id, - name: profile.public_name || profile.username, + name: profile.public_name || profile.username || "", email: profile.email, image: profile.avatar_url, emailVerified: profile.email_verified ?? false, diff --git a/packages/core/src/social-providers/tiktok.ts b/packages/core/src/social-providers/tiktok.ts index fd6d86fa6d..b4512d8ec2 100644 --- a/packages/core/src/social-providers/tiktok.ts +++ b/packages/core/src/social-providers/tiktok.ts @@ -197,7 +197,8 @@ export const tiktok = (options: TiktokOptions) => { user: { email: profile.data.user.email || profile.data.user.username, id: profile.data.user.open_id, - name: profile.data.user.display_name || profile.data.user.username, + name: + profile.data.user.display_name || profile.data.user.username || "", image: profile.data.user.avatar_large_url, emailVerified: false, }, diff --git a/packages/core/src/social-providers/vercel.ts b/packages/core/src/social-providers/vercel.ts index 9048fe7c1a..35c41180c6 100644 --- a/packages/core/src/social-providers/vercel.ts +++ b/packages/core/src/social-providers/vercel.ts @@ -73,7 +73,7 @@ export const vercel = (options: VercelOptions) => { return { user: { id: profile.sub, - name: profile.name ?? profile.preferred_username, + name: profile.name ?? profile.preferred_username ?? "", email: profile.email, image: profile.picture, emailVerified: profile.email_verified ?? false, diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index 691cadaae2..bd0d7e9167 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -1674,7 +1674,7 @@ export const callbackSSO = (options?: SSOOptions) => { const linked = await handleOAuthUserInfo(ctx, { userInfo: { email: userInfo.email, - name: userInfo.name || userInfo.email, + name: userInfo.name || "", id: userInfo.id, image: userInfo.image, emailVerified: options?.trustEmailVerified @@ -2216,7 +2216,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => { .filter(Boolean) .join(" ") || attributes[mapping.name || "displayName"] || - extract.nameID, + extract.nameID || + "", emailVerified: options?.trustEmailVerified && mapping.emailVerified ? ((attributes[mapping.emailVerified] || false) as boolean) @@ -2253,7 +2254,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => { const result = await handleOAuthUserInfo(ctx, { userInfo: { email: userInfo.email as string, - name: (userInfo.name || userInfo.email) as string, + name: (userInfo.name || "") as string, id: userInfo.id as string, emailVerified: Boolean(userInfo.emailVerified), }, @@ -2698,7 +2699,8 @@ export const acsEndpoint = (options?: SSOOptions) => { .filter(Boolean) .join(" ") || attributes[mapping.name || "displayName"] || - extract.nameID, + extract.nameID || + "", emailVerified: options?.trustEmailVerified && mapping.emailVerified ? ((attributes[mapping.emailVerified] || false) as boolean) @@ -2736,7 +2738,7 @@ export const acsEndpoint = (options?: SSOOptions) => { const result = await handleOAuthUserInfo(ctx, { userInfo: { email: userInfo.email as string, - name: (userInfo.name || userInfo.email) as string, + name: (userInfo.name || "") as string, id: userInfo.id as string, emailVerified: Boolean(userInfo.emailVerified), }, From fc79753e0fe037e02e9969d058ccf89b07a6f75d Mon Sep 17 00:00:00 2001 From: LovelessCodes Date: Mon, 9 Feb 2026 10:39:22 +0100 Subject: [PATCH 03/13] docs: update TanStack icon (#7841) Co-authored-by: Taesu --- docs/components/icons.tsx | 129 ++------------------------------------ 1 file changed, 5 insertions(+), 124 deletions(-) diff --git a/docs/components/icons.tsx b/docs/components/icons.tsx index e610563be1..516a75bbc2 100644 --- a/docs/components/icons.tsx +++ b/docs/components/icons.tsx @@ -322,131 +322,12 @@ export const Icons = { xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1.2em" - viewBox="0 0 100 100" + viewBox="0 0 663 660" > - - - - - - - - - - - - - - - - - - - - - - - - - - + ), expo: (props?: SVGProps) => ( From b97ba4e5f261a438a5340bc3335aea0e270d7dab Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:58:46 +1000 Subject: [PATCH 04/13] docs: improve community plugins table (#7874) --- docs/components/community-plugins-table.tsx | 66 ++++++++------------- 1 file changed, 26 insertions(+), 40 deletions(-) diff --git a/docs/components/community-plugins-table.tsx b/docs/components/community-plugins-table.tsx index 9fc2e5a079..0d4c07ce68 100644 --- a/docs/components/community-plugins-table.tsx +++ b/docs/components/community-plugins-table.tsx @@ -41,15 +41,33 @@ const columns: ColumnDef[] = [ ); }, cell: ({ row }) => { + const author = row.original.author; + return ( - - {row.original.name} - + ); }, }, @@ -64,38 +82,6 @@ const columns: ColumnDef[] = [ ); }, }, - { - accessorKey: "author.name", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const author = row.original.author; - return ( - - {author.name} - {author.name} - - ); - }, - }, ]; export const communityPlugins: CommunityPlugin[] = [ From 3048c99a946a9c6ab614d585f7ae8926c7488fe5 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:58:51 +1000 Subject: [PATCH 05/13] docs: update comparisons page (#7875) --- docs/content/docs/comparison.mdx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/comparison.mdx b/docs/content/docs/comparison.mdx index 661a1993e1..b8063aa621 100644 --- a/docs/content/docs/comparison.mdx +++ b/docs/content/docs/comparison.mdx @@ -13,21 +13,18 @@ Here are non detailed reasons why you may want to use Better Auth over other aut - **Advanced features built-in** - 2FA, multi-tenancy, multi-session, rate limiting, and many more - **Plugin system** - Extend functionality without forking or complex workarounds - **Full control** - Customize auth flows exactly how you want - -### vs Self-Hosted Auth Servers - -- **No separate infrastructure** - Runs in your app, users stay in your database -- **Zero server maintenance** - No auth servers to deploy, monitor, or update -- **Complete feature set** - Everything you need without the operational overhead +- **Flexible deployment** - Run alongside your app or as a standalone self-hosted auth server ### vs Managed Auth Services - **Keep your data** - Users stay in your database, not a third-party service - **No per-user costs** - Scale without worrying about auth billing - **Single source of truth** - All user data in one place +- **Self-host anywhere** - Deploy on your own infrastructure with full control ### vs Rolling Your Own - **Security handled** - Battle-tested auth flows and security practices - **Focus on your product** - Spend time on features that matter to your business -- **Plugin extensibility** - Add custom features without starting from scratch \ No newline at end of file +- **Plugin extensibility** - Add custom features without starting from scratch +- **Production ready** - Works embedded in your app or as a dedicated auth server \ No newline at end of file From 2d30e9c2664916e8b0eb7a573fce67f21e3fbbf8 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:10:17 +0900 Subject: [PATCH 06/13] feat(i18n): pass `GenericEndpointContext` to `getLocale` callback (#7866) --- docs/content/docs/plugins/i18n.mdx | 14 +++++++------- packages/i18n/src/i18n.test.ts | 7 +++---- packages/i18n/src/index.ts | 29 +++++++++++++---------------- packages/i18n/src/types.ts | 13 +++++++------ 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/docs/content/docs/plugins/i18n.mdx b/docs/content/docs/plugins/i18n.mdx index d08c8ab065..712e94d642 100644 --- a/docs/content/docs/plugins/i18n.mdx +++ b/docs/content/docs/plugins/i18n.mdx @@ -131,16 +131,16 @@ For advanced use cases, you can provide a custom locale detection function: i18n({ translations: { /* ... */ }, detection: ["callback", "header"], - getLocale: (request, ctx) => { + getLocale: (ctx) => { // Custom logic: use query param, custom header, etc. - if (!request) return null; - const url = new URL(request.url); + if (!ctx.request) return null; + const url = new URL(ctx.request.url); return url.searchParams.get("lang"); }, }) ``` -The callback receives the request object and the auth context, and should return the locale code or `null`. +The callback receives the full endpoint context. It should return the locale code or `null`. ## Error Codes @@ -208,12 +208,12 @@ The field name on the user object that stores their locale preference when using ### `getLocale` -**Type:** `(request: Request | undefined, ctx: AuthContext) => string | null | Promise` +**Type:** `(ctx: GenericEndpointContext) => string | null | Promise` -A custom function to detect the locale when using the `"callback"` detection strategy. Receives the request and auth context, returns the locale code or `null`. +A custom function to detect the locale when using the `"callback"` detection strategy. Receives the full endpoint context. Returns the locale code or `null`. -`request` could be undefined for non-HTTP calls. +`ctx.request` could be undefined for non-HTTP calls. ## Fallback Behavior diff --git a/packages/i18n/src/i18n.test.ts b/packages/i18n/src/i18n.test.ts index 60070dbe3d..8651dd86e4 100644 --- a/packages/i18n/src/i18n.test.ts +++ b/packages/i18n/src/i18n.test.ts @@ -187,9 +187,8 @@ describe("i18n plugin", async () => { translations, defaultLocale: "en", detection: ["callback"], - getLocale: (request, _ctx) => { - if (!request) return null; - return request.headers.get("X-Custom-Locale") ?? null; + getLocale: (ctx) => { + return ctx.headers?.get("X-Custom-Locale") ?? null; }, }), ], @@ -218,7 +217,7 @@ describe("i18n plugin", async () => { translations, defaultLocale: "en", detection: ["callback"], - getLocale: (_request, _ctx) => { + getLocale: () => { return "fr"; }, }), diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index c7fb1b37b9..001a1a4602 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,4 +1,7 @@ -import type { AuthContext, BetterAuthPlugin } from "@better-auth/core"; +import type { + BetterAuthPlugin, + GenericEndpointContext, +} from "@better-auth/core"; import { APIError, createAuthMiddleware, isAPIError } from "better-auth/api"; import { parseCookies } from "better-auth/cookies"; import type { I18nOptions, LocaleDetectionStrategy } from "./types"; @@ -89,23 +92,21 @@ export const i18n = ( }; async function detectLocale( - request: Request | undefined, - headers: Headers | undefined, - ctx: AuthContext, + ctx: GenericEndpointContext, ): Promise { for (const strategy of opts.detection) { let locale: Locales[number] | null = null; switch (strategy) { case "header": { - const acceptLang = headers?.get("Accept-Language") ?? null; + const acceptLang = ctx.headers?.get("Accept-Language") ?? null; const preferred = parseAcceptLanguage(acceptLang); locale = preferred.find((l) => availableLocales.includes(l)) ?? null; break; } case "cookie": { - const cookieHeader = headers?.get("Cookie"); + const cookieHeader = ctx.headers?.get("Cookie"); if (cookieHeader) { const cookies = parseCookies(cookieHeader); const cookieLocale = cookies.get(opts.localeCookie); @@ -117,10 +118,10 @@ export const i18n = ( } case "session": { - if (ctx.session?.user) { - const userLocale = (ctx.session.user as Record)[ - opts.userLocaleField - ]; + if (ctx.context.session?.user) { + const userLocale = ( + ctx.context.session.user as Record + )[opts.userLocaleField]; if ( typeof userLocale === "string" && availableLocales.includes(userLocale) @@ -133,7 +134,7 @@ export const i18n = ( case "callback": { if (opts.getLocale) { - const callbackLocale = await opts.getLocale(request, ctx); + const callbackLocale = await opts.getLocale(ctx); if (callbackLocale && availableLocales.includes(callbackLocale)) { locale = callbackLocale; } @@ -167,11 +168,7 @@ export const i18n = ( return; } - const locale = await detectLocale( - ctx.request, - ctx.headers, - ctx.context, - ); + const locale = await detectLocale(ctx); const translation = opts.translations[locale]?.[errorCode]; diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts index 25871bc6ab..a28a773612 100644 --- a/packages/i18n/src/types.ts +++ b/packages/i18n/src/types.ts @@ -1,7 +1,7 @@ import type { - AuthContext, BetterAuthPluginRegistry, BetterAuthPluginRegistryIdentifier, + GenericEndpointContext, UnionToIntersection, } from "@better-auth/core"; @@ -84,14 +84,15 @@ export interface I18nOptions { userLocaleField?: string | undefined; /** - * Custom locale detection function (when "callback" strategy is used) - * Receives a request and an AuthContext, returns a locale code or null. - * Note: `request` may be undefined for non-HTTP calls. + * Custom locale detection function (when "callback" strategy is used). + * @example + * getLocale: (ctx) => { + * return ctx.headers?.get("X-Custom-Locale") ?? null; + * } */ getLocale?: | undefined | (( - request: Request | undefined, - ctx: AuthContext, + ctx: GenericEndpointContext, ) => Promise | Locales[number] | null); } From b0a4f6c8e9c673f8bdb659d8f461942c6a711f2e Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:16:18 +0900 Subject: [PATCH 07/13] feat(email-otp): support name, image and additionalFields in `/sign-in/email-otp` endpoint (#7876) --- docs/content/docs/plugins/email-otp.mdx | 14 ++- .../src/plugins/email-otp/email-otp.test.ts | 107 ++++++++++++++++++ .../src/plugins/email-otp/routes.ts | 50 +++++--- 3 files changed, 155 insertions(+), 16 deletions(-) diff --git a/docs/content/docs/plugins/email-otp.mdx b/docs/content/docs/plugins/email-otp.mdx index 0c89b7aea4..6ce7cfaa85 100644 --- a/docs/content/docs/plugins/email-otp.mdx +++ b/docs/content/docs/plugins/email-otp.mdx @@ -121,19 +121,27 @@ Once the user provides the OTP, you can sign in the user using the `signIn.email ```ts type signInEmailOTP = { /** - * Email address to sign in. + * Email address to sign in. */ email: string = "user@example.com" /** - * OTP sent to the email. + * OTP sent to the email. */ otp: string = "123456" + /** + * User display name. Only used when the user is registering for the first time. + */ + name?: string = "John Doe" + /** + * User profile image URL. Only used when the user is registering for the first time. + */ + image?: string = "https://example.com/image.png" } ``` -If the user is not registered, they'll be automatically registered. If you want to prevent this, you can pass `disableSignUp` as `true` in the [options](#options). +If the user is not registered, they'll be automatically registered. Configured [additional fields](/docs/concepts/typescript#additional-fields) are also accepted for new users. To prevent automatic sign-up, pass `disableSignUp` as `true` in the [options](#options). ### Verify Email with OTP diff --git a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts index 64a3e2313a..157dfe9925 100644 --- a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts +++ b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts @@ -97,6 +97,25 @@ describe("email-otp", async () => { expect(newUser.data?.token).toBeDefined(); }); + it("should sign-up with otp and set name and image", async () => { + const testUser3 = { + email: "test-email-with-name@domain.com", + }; + await client.emailOtp.sendVerificationOtp({ + email: testUser3.email, + type: "sign-in", + }); + const newUser = await client.signIn.emailOtp({ + email: testUser3.email, + otp, + name: "Test User", + image: "https://example.com/avatar.png", + }); + expect(newUser.data?.token).toBeDefined(); + expect(newUser.data?.user.name).toBe("Test User"); + expect(newUser.data?.user.image).toBe("https://example.com/avatar.png"); + }); + it("should sign-up with uppercase email", async () => { const testUser2 = { email: "TEST-EMAIL@DOMAIN.COM", @@ -1204,3 +1223,91 @@ describe("override default email verification", async () => { ); }); }); + +describe("sign-up with additional fields via email-otp", async () => { + let otp = ""; + const { client, auth, sessionSetter } = await getTestInstance( + { + plugins: [ + emailOTP({ + async sendVerificationOTP({ otp: _otp }) { + otp = _otp; + }, + }), + ], + user: { + additionalFields: { + lang: { + type: "string", + required: false, + input: true, + }, + isAdmin: { + type: "boolean", + defaultValue: false, + input: false, + }, + }, + }, + }, + { + clientOptions: { + plugins: [emailOTPClient()], + }, + }, + ); + + it("should sign-up with additional fields", async () => { + const email = "additional-fields@domain.com"; + const headers = new Headers(); + await client.emailOtp.sendVerificationOtp({ + email, + type: "sign-in", + }); + const res = await client.signIn.emailOtp( + { + email, + otp, + name: "AF User", + lang: "ko", + }, + { + onSuccess: sessionSetter(headers), + }, + ); + expect(res.data?.token).toBeDefined(); + expect(res.data?.user.name).toBe("AF User"); + const session = await auth.api.getSession({ headers }); + if (!session) { + throw new Error("session not found"); + } + expect(session.user.name).toBe("AF User"); + expect(session.user.lang).toBe("ko"); + expect(session.user.isAdmin).toBe(false); + }); + + it("should ignore input: false fields and use default value", async () => { + const email = "ignore-input-false@domain.com"; + const headers = new Headers(); + await client.emailOtp.sendVerificationOtp({ + email, + type: "sign-in", + }); + const res = await client.signIn.emailOtp( + { + email, + otp, + isAdmin: true, + }, + { + onSuccess: sessionSetter(headers), + }, + ); + expect(res.data?.token).toBeDefined(); + const session = await auth.api.getSession({ headers }); + if (!session) { + throw new Error("session not found"); + } + expect(session.user.isAdmin).toBe(false); + }); +}); diff --git a/packages/better-auth/src/plugins/email-otp/routes.ts b/packages/better-auth/src/plugins/email-otp/routes.ts index 9e1945efc1..8b695d368b 100644 --- a/packages/better-auth/src/plugins/email-otp/routes.ts +++ b/packages/better-auth/src/plugins/email-otp/routes.ts @@ -5,7 +5,7 @@ import * as z from "zod"; import { APIError, getSessionFromCtx } from "../../api"; import { setCookieCache, setSessionCookie } from "../../cookies"; import { generateRandomString, symmetricDecrypt } from "../../crypto"; -import { parseUserOutput } from "../../db/schema"; +import { parseUserInput, parseUserOutput } from "../../db/schema"; import { getDate } from "../../utils/date"; import { storeOTP, verifyStoredOTP } from "./otp-token"; import type { EmailOTPOptions } from "./types"; @@ -562,15 +562,31 @@ export const verifyEmailOTP = (opts: RequiredEmailOTPOptions) => }, ); -const signInEmailOTPBodySchema = z.object({ - email: z.string({}).meta({ - description: "Email address to sign in", - }), - otp: z.string().meta({ - required: true, - description: "OTP sent to the email", - }), -}); +const signInEmailOTPBodySchema = z + .object({ + email: z.string({}).meta({ + description: "Email address to sign in", + }), + otp: z.string().meta({ + required: true, + description: "OTP sent to the email", + }), + name: z + .string() + .meta({ + description: + 'User display name. Only used if the user is registering for the first time. Eg: "my-name"', + }) + .optional(), + image: z + .string() + .meta({ + description: + "User profile image URL. Only used if the user is registering for the first time.", + }) + .optional(), + }) + .and(z.record(z.string(), z.any())); /** * ### Endpoint @@ -624,7 +640,8 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) => }, }, async (ctx) => { - const email = ctx.body.email.toLowerCase(); + const { email: rawEmail, otp, name, image, ...rest } = ctx.body; + const email = rawEmail.toLowerCase(); const verificationValue = await ctx.context.internalAdapter.findVerificationValue( `sign-in-otp-${email}`, @@ -643,7 +660,7 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) => ); throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS); } - const verified = await verifyStoredOTP(ctx, opts, otpValue, ctx.body.otp); + const verified = await verifyStoredOTP(ctx, opts, otpValue, otp); if (!verified) { await ctx.context.internalAdapter.updateVerificationValue( verificationValue.id, @@ -661,10 +678,17 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) => if (opts.disableSignUp) { throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND); } + const additionalFields = parseUserInput( + ctx.context.options, + rest, + "create", + ); const newUser = await ctx.context.internalAdapter.createUser({ + ...additionalFields, email, emailVerified: true, - name: "", + name: name || "", + image, }); const session = await ctx.context.internalAdapter.createSession( newUser.id, From a50d2ba0c9c25969408eb7db23c0d36d534b8d1e Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:22:35 +0900 Subject: [PATCH 08/13] fix(sso): only call `provisionUser` for new users (#7870) --- packages/sso/src/oidc.test.ts | 138 ++++++++++++++++++++++++++++++++- packages/sso/src/routes/sso.ts | 6 +- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts index f8caec02f8..58e17bb6ba 100644 --- a/packages/sso/src/oidc.test.ts +++ b/packages/sso/src/oidc.test.ts @@ -3,7 +3,7 @@ import { createAuthClient } from "better-auth/client"; import { organization } from "better-auth/plugins"; import { getTestInstance } from "better-auth/test"; import { OAuth2Server } from "oauth2-mock-server"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { sso } from "."; import { ssoClient } from "./client"; @@ -684,3 +684,139 @@ describe("provisioning", async (ctx) => { expect(res.url).toContain("http://localhost:8080/authorize"); }); }); + +/** + * @see https://github.com/better-auth/better-auth/issues/7857 + */ +describe("provisionUser should only be called for new users", async () => { + const provisionUserFn = vi.fn(); + const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = + await getTestInstance({ + trustedOrigins: ["http://localhost:8080"], + plugins: [ + sso({ + provisionUser: provisionUserFn, + }), + organization(), + ], + }); + + const authClient = createAuthClient({ + plugins: [ssoClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + beforeAll(async () => { + await server.issuer.keys.generate("RS256"); + server.service.removeAllListeners("beforeUserinfo"); + server.service.removeAllListeners("beforeTokenSigning"); + server.service.on("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "provision-test@localhost.com", + name: "Provision Test", + sub: "provision-test-sub", + picture: "https://test.com/picture.png", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + server.service.on("beforeTokenSigning", (token) => { + token.payload.email = "provision-test@localhost.com"; + token.payload.email_verified = true; + token.payload.name = "Provision Test"; + token.payload.picture = "https://test.com/picture.png"; + }); + await server.start(8080, "localhost"); + }); + + afterAll(async () => { + await server.stop(); + }); + + async function simulateOAuthFlow(authUrl: string, headers: Headers) { + let location: string | null = null; + await betterFetch(authUrl, { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + + if (!location) throw new Error("No redirect location found"); + + let callbackURL = ""; + const newHeaders = new Headers(); + await betterFetch(location, { + method: "GET", + customFetchImpl, + headers, + onError(context) { + callbackURL = context.response.headers.get("location") || ""; + cookieSetter(newHeaders)(context); + }, + }); + + return { callbackURL, headers: newHeaders }; + } + + it("should call provisionUser only on first sign-in (new user), not on subsequent sign-ins", async () => { + const { headers } = await signInWithTestUser(); + await auth.api.registerSSOProvider({ + body: { + issuer: server.issuer.url!, + domain: "localhost.com", + oidcConfig: { + clientId: "test", + clientSecret: "test", + authorizationEndpoint: `${server.issuer.url}/authorize`, + tokenEndpoint: `${server.issuer.url}/token`, + jwksEndpoint: `${server.issuer.url}/jwks`, + discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`, + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "name", + image: "picture", + }, + }, + providerId: "provision-test", + }, + headers, + }); + + provisionUserFn.mockClear(); + + // First sign-in: new user -> provisionUser should be called + const signInHeaders1 = new Headers(); + const res1 = await authClient.signIn.sso({ + email: "user@localhost.com", + callbackURL: "/dashboard", + fetchOptions: { + throw: true, + onSuccess: cookieSetter(signInHeaders1), + }, + }); + await simulateOAuthFlow(res1.url, signInHeaders1); + expect(provisionUserFn).toHaveBeenCalledTimes(1); + + provisionUserFn.mockClear(); + + // Second sign-in: existing user -> provisionUser should NOT be called + const signInHeaders2 = new Headers(); + const res2 = await authClient.signIn.sso({ + email: "user@localhost.com", + callbackURL: "/dashboard", + fetchOptions: { + throw: true, + onSuccess: cookieSetter(signInHeaders2), + }, + }); + await simulateOAuthFlow(res2.url, signInHeaders2); + expect(provisionUserFn).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index bd0d7e9167..fabf396c11 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -1701,7 +1701,7 @@ export const callbackSSO = (options?: SSOOptions) => { } const { session, user } = linked.data!; - if (options?.provisionUser) { + if (options?.provisionUser && linked.isRegister) { await options.provisionUser({ user, userInfo, @@ -2277,7 +2277,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => { const { session, user } = result.data!; - if (options?.provisionUser) { + if (options?.provisionUser && result.isRegister) { await options.provisionUser({ user: user as User & Record, userInfo, @@ -2761,7 +2761,7 @@ export const acsEndpoint = (options?: SSOOptions) => { const { session, user } = result.data!; - if (options?.provisionUser) { + if (options?.provisionUser && result.isRegister) { await options.provisionUser({ user: user as User & Record, userInfo, From 0a4da9af3e6b092e3b8fe21bde8e47e0037b4600 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:23:49 +0900 Subject: [PATCH 09/13] fix: support all where operators in list filter endpoints (#7859) --- docs/content/docs/plugins/admin.mdx | 6 +-- docs/content/docs/plugins/organization.mdx | 4 +- .../better-auth/src/plugins/admin/routes.ts | 5 ++- .../src/plugins/organization/adapter.ts | 3 +- .../organization/routes/crud-members.test.ts | 45 +++++++++++++++++++ .../organization/routes/crud-members.ts | 5 ++- packages/core/src/db/adapter/index.ts | 32 ++++++------- 7 files changed, 77 insertions(+), 23 deletions(-) diff --git a/docs/content/docs/plugins/admin.mdx b/docs/content/docs/plugins/admin.mdx index d13bedabd7..76b48469b3 100644 --- a/docs/content/docs/plugins/admin.mdx +++ b/docs/content/docs/plugins/admin.mdx @@ -153,11 +153,11 @@ type listUsers = { /** * The value to filter by. */ - filterValue?: string | number | boolean = "hello@example.com" + filterValue?: string | number | boolean | string[] | number[] = "hello@example.com" /** - * The operator to use for the filter. + * The operator to use for the filter. */ - filterOperator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" = "eq" + filterOperator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" = "eq" } ``` diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index b224360328..139852042e 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -1090,11 +1090,11 @@ type listMembers = { /** * The operator to filter by. */ - filterOperator?: "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "nin" | "contains" = "eq" + filterOperator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" = "eq" /** * The value to filter by. */ - filterValue?: string = "value" + filterValue?: string | number | boolean | string[] | number[] = "value" } ``` diff --git a/packages/better-auth/src/plugins/admin/routes.ts b/packages/better-auth/src/plugins/admin/routes.ts index 0a80635b1d..54e4776da8 100644 --- a/packages/better-auth/src/plugins/admin/routes.ts +++ b/packages/better-auth/src/plugins/admin/routes.ts @@ -4,6 +4,7 @@ import { } from "@better-auth/core/api"; import type { Session } from "@better-auth/core/db"; import type { Where } from "@better-auth/core/db/adapter"; +import { whereOperators } from "@better-auth/core/db/adapter"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; import { getSessionFromCtx } from "../../api"; @@ -574,9 +575,11 @@ const listUsersQuerySchema = z.object({ }) .or(z.number()) .or(z.boolean()) + .or(z.array(z.string())) + .or(z.array(z.number())) .optional(), filterOperator: z - .enum(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]) + .enum(whereOperators) .meta({ description: "The operator to use for the filter", }) diff --git a/packages/better-auth/src/plugins/organization/adapter.ts b/packages/better-auth/src/plugins/organization/adapter.ts index bf042d5429..bd676c5dd6 100644 --- a/packages/better-auth/src/plugins/organization/adapter.ts +++ b/packages/better-auth/src/plugins/organization/adapter.ts @@ -1,5 +1,6 @@ import type { AuthContext, GenericEndpointContext } from "@better-auth/core"; import { getCurrentAdapter } from "@better-auth/core/context"; +import type { WhereOperator } from "@better-auth/core/db/adapter"; import { BetterAuthError } from "@better-auth/core/error"; import { filterOutputFields } from "@better-auth/core/utils/db"; import { parseJSON } from "../../client/parser"; @@ -134,7 +135,7 @@ export const getOrgAdapter = ( filter?: | { field: string; - operator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "contains"; + operator?: WhereOperator; value: any; } | undefined; diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts index aca71659be..beb6d6019b 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts @@ -144,6 +144,51 @@ describe("listMembers", async () => { expect(members.data?.total).toBe(10); }); + it("should filter the members with 'in' operator", async () => { + const members = await client.organization.listMembers({ + fetchOptions: { + headers, + }, + query: { + filterField: "role", + filterOperator: "in", + filterValue: ["member", "owner"], + }, + }); + expect(members.data?.members.length).toBe(11); + expect(members.data?.total).toBe(11); + }); + + it("should filter the members with 'not_in' operator", async () => { + const members = await client.organization.listMembers({ + fetchOptions: { + headers, + }, + query: { + filterField: "role", + filterOperator: "not_in", + filterValue: ["owner"], + }, + }); + expect(members.data?.members.length).toBe(10); + expect(members.data?.total).toBe(10); + }); + + it("should filter the members with 'starts_with' operator", async () => { + const members = await client.organization.listMembers({ + fetchOptions: { + headers, + }, + query: { + filterField: "role", + filterOperator: "starts_with", + filterValue: "mem", + }, + }); + expect(members.data?.members.length).toBe(10); + expect(members.data?.total).toBe(10); + }); + it("should sort the members", async () => { const defaultMembers = await client.organization.listMembers({ fetchOptions: { diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.ts index c6dc9022e9..8bc7a8b1fc 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts @@ -1,5 +1,6 @@ import type { LiteralString } from "@better-auth/core"; import { createAuthEndpoint } from "@better-auth/core/api"; +import { whereOperators } from "@better-auth/core/db/adapter"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; import { getSessionFromCtx, sessionMiddleware } from "../../../api"; @@ -879,9 +880,11 @@ export const listMembers = (options: O) => }) .or(z.number()) .or(z.boolean()) + .or(z.array(z.string())) + .or(z.array(z.number())) .optional(), filterOperator: z - .enum(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]) + .enum(whereOperators) .meta({ description: "The operator to use for the filter", }) diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts index c8d59383a6..159aae0dd5 100644 --- a/packages/core/src/db/adapter/index.ts +++ b/packages/core/src/db/adapter/index.ts @@ -301,25 +301,27 @@ export interface DBAdapterFactoryConfig< disableTransformJoin?: boolean | undefined; } +export const whereOperators = [ + "eq", + "ne", + "lt", + "lte", + "gt", + "gte", + "in", + "not_in", + "contains", + "starts_with", + "ends_with", +] as const; + +export type WhereOperator = (typeof whereOperators)[number]; + export type Where = { /** * @default eq */ - operator?: - | ( - | "eq" - | "ne" - | "lt" - | "lte" - | "gt" - | "gte" - | "in" - | "not_in" - | "contains" - | "starts_with" - | "ends_with" - ) - | undefined; + operator?: WhereOperator | undefined; value: string | number | boolean | string[] | number[] | Date | null; field: string; /** From 3d17eaee3f107ff099207340791884e7eff59418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A9l=20Solano?= Date: Tue, 10 Feb 2026 02:04:28 +0100 Subject: [PATCH 10/13] chore(electron) add openapi metadata to routes (#7872) --- .../(auth)/sign-in/_components/electron.tsx | 150 ++++++++++++++++++ demo/nextjs/app/(auth)/sign-in/page.tsx | 71 ++++++--- docs/content/docs/integrations/electron.mdx | 2 + packages/electron/src/error-codes.ts | 1 + packages/electron/src/index.ts | 106 ++++++++----- packages/electron/src/proxy.ts | 5 + packages/electron/src/routes.ts | 145 ++++++++++++++++- packages/electron/test/electron.test.ts | 103 ++++++++++++ 8 files changed, 522 insertions(+), 61 deletions(-) create mode 100644 demo/nextjs/app/(auth)/sign-in/_components/electron.tsx diff --git a/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx b/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx new file mode 100644 index 0000000000..722765f967 --- /dev/null +++ b/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { Loader2, LogInIcon } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState, useTransition } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { buttonVariants } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { authClient } from "@/lib/auth-client"; + +export function ElectronTransferUser({ + session: activeSession, +}: { + session: typeof authClient.$Infer.Session; +}) { + const params = Object.fromEntries(useSearchParams().entries()); + const [isLoading, startTransition] = useTransition(); + const [users, setUsers] = useState<(typeof authClient.$Infer.Session)[]>([ + activeSession, + ]); + + useEffect(() => { + startTransition(async () => { + const { data } = await authClient.multiSession.listDeviceSessions(); + + if (data?.length) { + setUsers((prev) => { + // Filter out unique users from device session + const seenUserIds = new Set(prev.map((s) => s.user.id)); + const users = data.filter((session) => { + if ( + session.user.id === activeSession.user.id || // Skip active user + seenUserIds.has(session.user.id) + ) + return false; + seenUserIds.add(session.user.id); + return true; + }) as (typeof authClient.$Infer.Session)[]; + return [...prev, ...users]; + }); + } + }); + }, []); + + const handleContinueAsUser = useCallback( + (session: typeof authClient.$Infer.Session) => () => + startTransition(async () => { + if (session.user.id === activeSession.user.id) { + // Continue as current user + await authClient.electron.transferUser({ + fetchOptions: { + query: params, + }, + }); + return; + } + + // Switch to the selected user + const originalSessionToken = activeSession.session.token; + await authClient.multiSession.setActive({ + sessionToken: session.session.token, + }); + + const promise = authClient.electron.transferUser({ + fetchOptions: { + query: params, + }, + }); + + // Switch back to the original session + await authClient.multiSession.setActive({ + sessionToken: originalSessionToken, + }); + + // Transfer user + await promise; + }), + [params, activeSession], + ); + + return ( +
+

Continue as:

+ + {users.map((session) => ( + + ))} +
+ ); +} + +function ContinueAsUser({ + session, + isLoading, + onContinue, +}: { + session: typeof authClient.$Infer.Session; + isLoading: boolean; + onContinue: () => void; +}) { + return ( + + ); +} diff --git a/demo/nextjs/app/(auth)/sign-in/page.tsx b/demo/nextjs/app/(auth)/sign-in/page.tsx index 6e710eecfd..032f2f7005 100644 --- a/demo/nextjs/app/(auth)/sign-in/page.tsx +++ b/demo/nextjs/app/(auth)/sign-in/page.tsx @@ -1,15 +1,21 @@ "use client"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import SignIn from "@/app/(auth)/sign-in/_components/sign-in"; import { SignUp } from "@/app/(auth)/sign-in/_components/sign-up"; import { Tabs } from "@/components/ui/tabs2"; import { authClient } from "@/lib/auth-client"; import { getCallbackURL } from "@/lib/shared"; +import { cn } from "@/lib/utils"; +import { ElectronTransferUser } from "./_components/electron"; export default function Page() { + const [isLoading, startTransition] = useTransition(); + const [session, setSession] = useState< + typeof authClient.$Infer.Session | null + >(null); const router = useRouter(); const params = useSearchParams(); @@ -28,25 +34,54 @@ export default function Page() { }); }, []); + useEffect(() => { + if (params.get("client_id") === "electron") { + startTransition(async () => { + const { data: session } = await authClient.getSession(); + if (session) { + setSession(session); + } + }); + } + }, [params]); + return (
-
-
- , - }, - { - title: "Sign Up", - value: "sign-up", - content: , - }, - ]} - /> -
+
+ {!isLoading ? ( + <> + {session !== null ? ( + + ) : ( +
+ , + }, + { + title: "Sign Up", + value: "sign-up", + content: , + }, + ]} + /> +
+ )} + + ) : ( +
+
+

Loading...

+
+ )}
); diff --git a/docs/content/docs/integrations/electron.mdx b/docs/content/docs/integrations/electron.mdx index bbffd4794c..4513158a58 100644 --- a/docs/content/docs/integrations/electron.mdx +++ b/docs/content/docs/integrations/electron.mdx @@ -290,6 +290,8 @@ function SignIn({ } ``` +To handle already signed-in users, you can use the `electron.transferUser` method on the client and `electronTransferUser` on the server to transfer the user to the Electron app. [See Example](https://github.com/better-auth/better-auth/blob/main/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx) + ### Handling Authentication in Electron In your Electron renderer process, you can use the IPC bridges exposed by the preload script. diff --git a/packages/electron/src/error-codes.ts b/packages/electron/src/error-codes.ts index 2ff3950789..07d8d40be6 100644 --- a/packages/electron/src/error-codes.ts +++ b/packages/electron/src/error-codes.ts @@ -1,6 +1,7 @@ import { defineErrorCodes } from "better-auth"; export const ELECTRON_ERROR_CODES = defineErrorCodes({ + INVALID_CLIENT_ID: "Invalid client ID", INVALID_TOKEN: "Invalid or expired token.", STATE_MISMATCH: "state mismatch", MISSING_CODE_CHALLENGE: "missing code challenge", diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts index 36e35a938f..a0d55db920 100644 --- a/packages/electron/src/index.ts +++ b/packages/electron/src/index.ts @@ -1,6 +1,9 @@ /// -import type { HookEndpointContext } from "@better-auth/core"; +import type { + GenericEndpointContext, + HookEndpointContext, +} from "@better-auth/core"; import { createAuthMiddleware } from "@better-auth/core/api"; import { APIError } from "@better-auth/core/error"; import type { BetterAuthPlugin } from "better-auth"; @@ -8,7 +11,11 @@ import { safeJSONParse } from "better-auth"; import { generateRandomString } from "better-auth/crypto"; import * as z from "zod"; import { ELECTRON_ERROR_CODES } from "./error-codes"; -import { electronInitOAuthProxy, electronToken } from "./routes"; +import { + electronInitOAuthProxy, + electronToken, + electronTransferUser, +} from "./routes"; import type { ElectronOptions } from "./types"; declare module "@better-auth/core" { @@ -43,6 +50,58 @@ export const electron = (options?: ElectronOptions | undefined) => { ); }; + const handleTransfer = async ( + ctx: GenericEndpointContext, + payload: { + client_id: string; + state: string; + code_challenge: string; + code_challenge_method?: string | undefined; + }, + ) => { + const { + client_id, + state, + code_challenge, + code_challenge_method = "plain", + } = payload; + const userId = + ctx.context.session?.user.id || ctx.context.newSession?.user.id; + if (!userId || client_id !== opts.clientID) { + return false; + } + if (!state) { + throw APIError.from("BAD_REQUEST", ELECTRON_ERROR_CODES.MISSING_STATE); + } + if (!code_challenge) { + throw APIError.from("BAD_REQUEST", ELECTRON_ERROR_CODES.MISSING_PKCE); + } + + const redirectCookieName = `${opts.cookiePrefix}.${opts.clientID}`; + + const identifier = generateRandomString(32, "a-z", "A-Z", "0-9"); + const codeExpiresInMs = opts.codeExpiresIn * 1000; + const expiresAt = new Date(Date.now() + codeExpiresInMs); + await ctx.context.internalAdapter.createVerificationValue({ + identifier: `electron:${identifier}`, + value: JSON.stringify({ + userId, + codeChallenge: code_challenge, + codeChallengeMethod: code_challenge_method.toLowerCase(), + state, + }), + expiresAt, + }); + + ctx.setCookie(redirectCookieName, identifier, { + ...ctx.context.authCookies.sessionToken.attributes, + maxAge: opts.redirectCookieExpiresIn, + httpOnly: false, + }); + + return true; + }; + return { id: "electron", async onRequest(request, _ctx) { @@ -141,45 +200,7 @@ export const electron = (options?: ElectronOptions | undefined) => { return; } - const { client_id, code_challenge, code_challenge_method, state } = - transferPayload; - if (client_id !== opts.clientID) { - return; - } - if (!state) { - throw APIError.from( - "BAD_REQUEST", - ELECTRON_ERROR_CODES.MISSING_STATE, - ); - } - if (!code_challenge) { - throw APIError.from( - "BAD_REQUEST", - ELECTRON_ERROR_CODES.MISSING_PKCE, - ); - } - - const redirectCookieName = `${opts.cookiePrefix}.${opts.clientID}`; - - const identifier = generateRandomString(32, "a-z", "A-Z", "0-9"); - const codeExpiresInMs = opts.codeExpiresIn * 1000; - const expiresAt = new Date(Date.now() + codeExpiresInMs); - await ctx.context.internalAdapter.createVerificationValue({ - identifier: `electron:${identifier}`, - value: JSON.stringify({ - userId: ctx.context.newSession.user.id, - codeChallenge: code_challenge, - codeChallengeMethod: code_challenge_method.toLowerCase(), - state, - }), - expiresAt, - }); - - ctx.setCookie(redirectCookieName, identifier, { - ...ctx.context.authCookies.sessionToken.attributes, - maxAge: opts.redirectCookieExpiresIn, - httpOnly: false, - }); + await handleTransfer(ctx, transferPayload); return ctx; }), @@ -189,6 +210,9 @@ export const electron = (options?: ElectronOptions | undefined) => { endpoints: { electronToken: electronToken(opts), electronInitOAuthProxy: electronInitOAuthProxy(opts), + electronTransferUser: electronTransferUser(opts, { + handleTransfer, + }), }, options: opts, $ERROR_CODES: ELECTRON_ERROR_CODES, diff --git a/packages/electron/src/proxy.ts b/packages/electron/src/proxy.ts index 5b4586cba2..9971c8cadd 100644 --- a/packages/electron/src/proxy.ts +++ b/packages/electron/src/proxy.ts @@ -1,5 +1,6 @@ import type { BetterAuthClientPlugin } from "@better-auth/core"; import { parseCookies } from "better-auth/cookies"; +import type { electron } from "./index"; import type { ElectronProxyClientOptions } from "./types/client"; import { parseProtocolScheme } from "./utils"; @@ -71,5 +72,9 @@ export const electronProxyClient = (options: ElectronProxyClientOptions) => { }, }; }, + pathMethods: { + "/electron/transfer-user": "POST", + }, + $InferServerPlugin: {} as ReturnType, } satisfies BetterAuthClientPlugin; }; diff --git a/packages/electron/src/routes.ts b/packages/electron/src/routes.ts index 2dffbc8a2d..245c34e0bb 100644 --- a/packages/electron/src/routes.ts +++ b/packages/electron/src/routes.ts @@ -1,12 +1,13 @@ import { Buffer } from "node:buffer"; import { timingSafeEqual } from "node:crypto"; +import type { GenericEndpointContext } from "@better-auth/core"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { SocialProviderListEnum } from "@better-auth/core/social-providers"; import { safeJSONParse } from "@better-auth/core/utils/json"; import { base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; import { betterFetch } from "@better-fetch/fetch"; -import { createAuthEndpoint } from "better-auth/api"; +import { createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import { setSessionCookie } from "better-auth/cookies"; import type { User } from "better-auth/db"; import { parseUserOutput } from "better-auth/db"; @@ -20,7 +21,7 @@ const electronTokenBodySchema = z.object({ code_verifier: z.string().nonempty(), }); -export const electronToken = (opts: ElectronOptions) => +export const electronToken = (_opts: ElectronOptions) => createAuthEndpoint( "/electron/token", { @@ -28,6 +29,32 @@ export const electronToken = (opts: ElectronOptions) => body: electronTokenBodySchema, metadata: { scope: "http", + openapi: { + description: "Exchange the electron token for a session", + operationId: "electronToken", + responses: { + 200: { + description: "Returns the session token and user", + content: { + "application/json": { + schema: { + type: "object", + properties: { + token: { + type: "string", + }, + user: { + type: "object", + $ref: "#/components/schemas/User", + }, + }, + required: ["token", "user"], + }, + }, + }, + }, + }, + }, }, }, async (ctx) => { @@ -129,6 +156,40 @@ export const electronInitOAuthProxy = (opts: ElectronOptions) => query: electronInitOAuthProxyQuerySchema, metadata: { scope: "http", + openapi: { + description: "Initialize the OAuth proxy for the electron app", + operationId: "electronInitOAuthProxy", + responses: { + 200: { + description: + "Returns the URL to redirect to and if the redirect should be performed", + content: { + "application/json": { + schema: { + type: "object", + properties: { + url: { + type: "string", + nullable: true, + }, + redirect: { + type: "boolean", + }, + user: { + type: "object", + $ref: "#/components/schemas/User", + }, + token: { + type: "string", + }, + }, + required: ["url", "redirect"], + }, + }, + }, + }, + }, + }, }, }, async (ctx) => { @@ -189,3 +250,83 @@ export const electronInitOAuthProxy = (opts: ElectronOptions) => return ctx.json(res.data); }, ); + +const electronTransferUserQuerySchema = z.object({ + client_id: z.string(), + state: z.string(), + code_challenge: z.string(), + code_challenge_method: z.string().optional(), +}); +const electronTransferUserBodySchema = z.object({ + callbackURL: z.string().optional(), +}); + +export const electronTransferUser = ( + _opts: ElectronOptions, + { + handleTransfer, + }: { + handleTransfer: ( + ctx: GenericEndpointContext, + payload: { + client_id: string; + state: string; + code_challenge: string; + code_challenge_method?: string | undefined; + }, + ) => Promise; + }, +) => + createAuthEndpoint( + "/electron/transfer-user", + { + method: "POST", + query: electronTransferUserQuerySchema, + body: electronTransferUserBodySchema, + use: [sessionMiddleware], + requireHeaders: true, + metadata: { + openapi: { + description: "Transfer the user to the electron app", + operationId: "electronTransferUser", + responses: { + 200: { + description: + "Returns the URL to redirect to and if the redirect should be performed", + content: { + "application/json": { + schema: { + type: "object", + properties: { + url: { + type: "string", + nullable: true, + }, + redirect: { + type: "boolean", + }, + }, + required: ["url", "redirect"], + }, + }, + }, + }, + }, + }, + }, + }, + async (ctx) => { + const success = await handleTransfer(ctx, ctx.query); + if (!success) { + throw APIError.from( + "BAD_REQUEST", + ELECTRON_ERROR_CODES.INVALID_CLIENT_ID, + ); + } + + return ctx.json({ + url: ctx.body.callbackURL ? ctx.body.callbackURL : null, + redirect: ctx.body.callbackURL ? true : false, + }); + }, + ); diff --git a/packages/electron/test/electron.test.ts b/packages/electron/test/electron.test.ts index 8583cc4d66..4657ab2912 100644 --- a/packages/electron/test/electron.test.ts +++ b/packages/electron/test/electron.test.ts @@ -7,6 +7,7 @@ import { generateRandomString } from "better-auth/crypto"; import { getMigrations } from "better-auth/db/migration"; import { describe, expect, vi } from "vitest"; import { authenticate, kCodeVerifier, kState } from "../src/authenticate"; +import { ELECTRON_ERROR_CODES } from "../src/error-codes"; import { electron } from "../src/index"; import { it, testUtils } from "./utils"; @@ -494,6 +495,108 @@ describe("Electron", () => { spy.mockRestore(); }); + describe("transferUser", () => { + const transferQuery = + "client_id=electron&state=xyz&code_challenge=challenge"; + const post = (cookie: string, body?: object) => + auth.handler( + new Request( + `http://localhost:3000/api/auth/electron/transfer-user?${transferQuery}`, + { + method: "POST", + headers: { + cookie: cookie, + "content-type": "application/json", + }, + body: JSON.stringify(body ?? {}), + }, + ), + ); + + async function getSessionCookie() { + let res = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "transfer-test@test.com", + password: "password", + name: "Transfer Test", + }), + }), + ); + if (res.status !== 200) { + res = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "transfer-test@test.com", + password: "password", + }), + }), + ); + } + const setCookie = res.headers.get("set-cookie") ?? ""; + const parsed = parseSetCookieHeader(setCookie); + const parts: string[] = []; + parsed.forEach((value, name) => { + parts.push(`${name}=${value.value}`); + }); + return parts.join("; "); + } + + it("should return url and redirect from body when callbackURL provided", async () => { + const cookie = await getSessionCookie(); + const res = await post(cookie, { + callbackURL: "https://app.example.com/callback", + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + url: "https://app.example.com/callback", + redirect: true, + }); + }); + + it("should return null url and false redirect when callbackURL omitted", async () => { + const cookie = await getSessionCookie(); + const res = await post(cookie); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ url: null, redirect: false }); + }); + + it("should throw INVALID_CLIENT_ID when client_id does not match", async () => { + const cookie = await getSessionCookie(); + const res = await auth.handler( + new Request( + "http://localhost:3000/api/auth/electron/transfer-user?client_id=wrong&state=xyz&code_challenge=challenge", + { + method: "POST", + headers: { + cookie, + "content-type": "application/json", + }, + body: JSON.stringify({}), + }, + ), + ); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.code).toBe(ELECTRON_ERROR_CODES.INVALID_CLIENT_ID.code); + }); + + it("should set redirect cookie on success", async () => { + const cookie = await getSessionCookie(); + const res = await post(cookie); + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie") ?? ""; + const cookies = parseSetCookieHeader(setCookie); + expect(cookies.has("better-auth.electron")).toBe(true); + }); + }); + it("should register protocol", async ({ setProcessType }) => { setProcessType("browser"); From bf2b2983248f07d3f9b8a39816f8685af37fc1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+Paola3stefania@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:04:48 -0800 Subject: [PATCH 11/13] fix(sso): harden SAML ACS error redirects and add regression test for #7777 (#7815) --- packages/sso/src/routes/sso.ts | 144 ++++++++++++++---------------- packages/sso/src/saml.test.ts | 154 +++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 78 deletions(-) diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index fabf396c11..93596d92b6 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -1,3 +1,4 @@ +import { base64 } from "@better-auth/utils/base64"; import { BetterFetchError, betterFetch } from "@better-fetch/fetch"; import type { User, Verification } from "better-auth"; import { @@ -1949,6 +1950,21 @@ export const callbackSSOSAML = (options?: SSOOptions) => { message: "Invalid SAML configuration", }); } + + const isTrusted = ( + url: string, + settings?: { allowRelativePaths: boolean }, + ) => ctx.context.isTrustedOrigin(url, settings); + + const safeErrorUrl = getSafeRedirectUrl( + relayState?.errorURL || + relayState?.callbackURL || + parsedSamlConfig.callbackUrl, + currentCallbackPath, + appOrigin, + isTrusted, + ); + const idpData = parsedSamlConfig.idpMetadata; let idp: IdentityProvider | null = null; @@ -2021,8 +2037,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => { } catch (error) { ctx.context.logger.error("SAML response validation failed", { error, - decodedResponse: Buffer.from(SAMLResponse, "base64").toString( - "utf-8", + decodedResponse: new TextDecoder().decode( + base64.decode(SAMLResponse), ), }); throw new APIError("BAD_REQUEST", { @@ -2073,12 +2089,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => { "SAML InResponseTo validation failed: unknown or expired request ID", { inResponseTo, providerId: provider.providerId }, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`, + `${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`, ); } @@ -2095,12 +2107,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => { await ctx.context.internalAdapter.deleteVerificationByIdentifier( `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`, + `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`, ); } @@ -2112,12 +2120,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => { "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId }, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`, + `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`, ); } } @@ -2167,12 +2171,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => { providerId: provider.providerId, }, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`, + `${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`, ); } @@ -2246,10 +2246,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => { !!(provider as { domainVerified?: boolean }).domainVerified && validateEmailDomain(userInfo.email as string, provider.domain)); - const callbackUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; + const safeCallbackUrl = getSafeRedirectUrl( + relayState?.callbackURL || parsedSamlConfig.callbackUrl, + currentCallbackPath, + appOrigin, + isTrusted, + ); const result = await handleOAuthUserInfo(ctx, { userInfo: { @@ -2264,14 +2266,14 @@ export const callbackSSOSAML = (options?: SSOOptions) => { accessToken: "", refreshToken: "", }, - callbackURL: callbackUrl, + callbackURL: safeCallbackUrl, disableSignUp: options?.disableImplicitSignUp, isTrustedProvider, }); if (result.error) { throw ctx.redirect( - `${callbackUrl}?error=${result.error.split(" ").join("_")}`, + `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`, ); } @@ -2300,14 +2302,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => { }); await setSessionCookie(ctx, { session, user }); - - const safeRedirectUrl = getSafeRedirectUrl( - relayState?.callbackURL || parsedSamlConfig.callbackUrl, - currentCallbackPath, - appOrigin, - (url, settings) => ctx.context.isTrustedOrigin(url, settings), - ); - throw ctx.redirect(safeRedirectUrl); + throw ctx.redirect(safeCallbackUrl); }, ); }; @@ -2428,6 +2423,23 @@ export const acsEndpoint = (options?: SSOOptions) => { } const parsedSamlConfig = provider.samlConfig; + + const isTrusted = ( + url: string, + settings?: { allowRelativePaths: boolean }, + ) => ctx.context.isTrustedOrigin(url, settings); + + // Compute a safe error redirect URL once, reused by all error paths. + // Prefers errorURL from relay state, falls back to callbackURL, then provider config, then baseURL. + const safeErrorUrl = getSafeRedirectUrl( + relayState?.errorURL || + relayState?.callbackURL || + parsedSamlConfig.callbackUrl, + currentCallbackPath, + appOrigin, + isTrusted, + ); + // Configure SP and IdP const sp = saml.ServiceProvider({ entityID: @@ -2472,16 +2484,12 @@ export const acsEndpoint = (options?: SSOOptions) => { validateSingleAssertion(SAMLResponse); } catch (error) { if (error instanceof APIError) { - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion"; throw ctx.redirect( - `${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`, + `${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`, ); } throw error; @@ -2503,8 +2511,8 @@ export const acsEndpoint = (options?: SSOOptions) => { } catch (error) { ctx.context.logger.error("SAML response validation failed", { error, - decodedResponse: Buffer.from(SAMLResponse, "base64").toString( - "utf-8", + decodedResponse: new TextDecoder().decode( + base64.decode(SAMLResponse), ), }); throw new APIError("BAD_REQUEST", { @@ -2557,12 +2565,8 @@ export const acsEndpoint = (options?: SSOOptions) => { "SAML InResponseTo validation failed: unknown or expired request ID", { inResponseTo: inResponseToAcs, providerId }, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`, + `${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`, ); } @@ -2578,12 +2582,8 @@ export const acsEndpoint = (options?: SSOOptions) => { await ctx.context.internalAdapter.deleteVerificationByIdentifier( `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`, + `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`, ); } @@ -2595,19 +2595,15 @@ export const acsEndpoint = (options?: SSOOptions) => { "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId }, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`, + `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`, ); } } // Assertion Replay Protection - const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString( - "utf-8", + const samlContentAcs = new TextDecoder().decode( + base64.decode(SAMLResponse), ); const assertionIdAcs = extractAssertionId(samlContentAcs); @@ -2650,12 +2646,8 @@ export const acsEndpoint = (options?: SSOOptions) => { providerId, }, ); - const redirectUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; throw ctx.redirect( - `${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`, + `${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`, ); } @@ -2730,10 +2722,12 @@ export const acsEndpoint = (options?: SSOOptions) => { !!(provider as { domainVerified?: boolean }).domainVerified && validateEmailDomain(userInfo.email as string, provider.domain)); - const callbackUrl = - relayState?.callbackURL || - parsedSamlConfig.callbackUrl || - ctx.context.baseURL; + const safeCallbackUrl = getSafeRedirectUrl( + relayState?.callbackURL || parsedSamlConfig.callbackUrl, + currentCallbackPath, + appOrigin, + isTrusted, + ); const result = await handleOAuthUserInfo(ctx, { userInfo: { @@ -2748,14 +2742,14 @@ export const acsEndpoint = (options?: SSOOptions) => { accessToken: "", refreshToken: "", }, - callbackURL: callbackUrl, + callbackURL: safeCallbackUrl, disableSignUp: options?.disableImplicitSignUp, isTrustedProvider, }); if (result.error) { throw ctx.redirect( - `${callbackUrl}?error=${result.error.split(" ").join("_")}`, + `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`, ); } @@ -2784,13 +2778,7 @@ export const acsEndpoint = (options?: SSOOptions) => { }); await setSessionCookie(ctx, { session, user }); - const safeRedirectUrl = getSafeRedirectUrl( - relayState?.callbackURL || parsedSamlConfig.callbackUrl, - currentCallbackPath, - appOrigin, - (url, settings) => ctx.context.isTrustedOrigin(url, settings), - ); - throw ctx.redirect(safeRedirectUrl); + throw ctx.redirect(safeCallbackUrl); }, ); }; diff --git a/packages/sso/src/saml.test.ts b/packages/sso/src/saml.test.ts index 8b586bd11e..c68455efdf 100644 --- a/packages/sso/src/saml.test.ts +++ b/packages/sso/src/saml.test.ts @@ -2111,6 +2111,160 @@ describe("SAML SSO", async () => { const redirectLocation = response.headers.get("location") || ""; expect(redirectLocation).toContain("error=unsolicited_response"); }); + + /** + * @see https://github.com/better-auth/better-auth/issues/7777 + */ + it("should correctly parse verification-ID-based RelayState on ACS route (SP-initiated)", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [sso()], + }); + + const { headers } = await signInWithTestUser(); + + await auth.api.registerSSOProvider({ + body: { + providerId: "saml-acs-relay-provider", + issuer: "http://localhost:8081", + domain: "http://localhost:8081", + samlConfig: { + entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", + cert: certificate, + callbackUrl: "http://localhost:3000/dashboard", + wantAssertionsSigned: false, + signatureAlgorithm: "sha256", + digestAlgorithm: "sha256", + idpMetadata: { + metadata: idpMetadata, + }, + spMetadata: { + metadata: spMetadata, + }, + identifierFormat: + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + }, + }, + headers, + }); + + // SP-initiated: signInSSO returns a URL with a RelayState verification ID + const signInRes = await auth.api.signInSSO({ + body: { + providerId: "saml-acs-relay-provider", + callbackURL: "http://localhost:3000/dashboard", + }, + returnHeaders: true, + }); + + const signInResponse = signInRes.response; + expect(signInResponse).toEqual({ + url: expect.stringContaining("http://localhost:8081"), + redirect: true, + }); + + const samlRedirectUrl = new URL(signInResponse?.url); + const relayStateParam = samlRedirectUrl.searchParams.get("RelayState"); + // RelayState should be a verification ID, not a raw URL + expect(relayStateParam).toBeTruthy(); + expect(relayStateParam).not.toContain("http"); + + let samlResponse: any; + await betterFetch(signInResponse?.url, { + onSuccess: async (context) => { + samlResponse = await context.data; + }, + }); + + // POST to the ACS endpoint with the verification-ID-based RelayState + const acsResponse = await auth.handler( + new Request( + "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-relay-provider", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Cookie: signInRes.headers.get("set-cookie") ?? "", + }, + body: new URLSearchParams({ + SAMLResponse: samlResponse.samlResponse, + RelayState: relayStateParam!, + }), + }, + ), + ); + + expect(acsResponse.status).toBe(302); + const acsRedirectLocation = acsResponse.headers.get("location") || ""; + // Must redirect to the callbackURL from the relay state, not to the verification ID + expect(acsRedirectLocation).toContain("dashboard"); + expect(acsRedirectLocation).not.toContain("error"); + }); + + /** + * @see https://github.com/better-auth/better-auth/issues/7777 + */ + it("should fallback to provider callbackUrl on ACS route when RelayState is invalid", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [sso()], + }); + + const { headers } = await signInWithTestUser(); + + await auth.api.registerSSOProvider({ + body: { + providerId: "saml-acs-bad-relay-provider", + issuer: "http://localhost:8081", + domain: "http://localhost:8081", + samlConfig: { + entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", + cert: certificate, + callbackUrl: "http://localhost:3000/dashboard", + wantAssertionsSigned: false, + signatureAlgorithm: "sha256", + digestAlgorithm: "sha256", + idpMetadata: { + metadata: idpMetadata, + }, + spMetadata: { + metadata: spMetadata, + }, + identifierFormat: + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + }, + }, + headers, + }); + + let samlResponse: any; + await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", { + onSuccess: async (context) => { + samlResponse = await context.data; + }, + }); + + // POST with a garbage RelayState - should fallback to provider callbackUrl + const acsResponse = await auth.handler( + new Request( + "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-bad-relay-provider", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + SAMLResponse: samlResponse.samlResponse, + RelayState: "not-a-valid-relay-state", + }), + }, + ), + ); + + expect(acsResponse.status).toBe(302); + const location = acsResponse.headers.get("location") || ""; + // Should redirect to the provider's callbackUrl, not the garbage RelayState + expect(location).toContain("dashboard"); + expect(location).not.toContain("not-a-valid-relay-state"); + }); }); describe("SAML SSO with custom fields", () => { From d8af7961363d9be2897504ac281f5eb234de7025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A9l=20Solano?= Date: Tue, 10 Feb 2026 02:51:40 +0100 Subject: [PATCH 12/13] chore(electron): add `electron` to peerDependencies (#7858) --- docs/content/docs/integrations/electron.mdx | 5 ++++ packages/electron/package.json | 2 ++ pnpm-lock.yaml | 26 +++++++++++++++------ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/integrations/electron.mdx b/docs/content/docs/integrations/electron.mdx index 4513158a58..406d0643a6 100644 --- a/docs/content/docs/integrations/electron.mdx +++ b/docs/content/docs/integrations/electron.mdx @@ -29,6 +29,11 @@ Better Auth can be integrated into Electron apps to provide secure authenticatio better-auth @better-auth/electron ``` + + We support two major versions behind the latest stable major release of Electron. + This keeps you on versions that receive security updates and aligns with [Electron's version support policy](https://www.electronjs.org/docs/latest/tutorial/electron-timelines#breaking-api-changes). + + diff --git a/packages/electron/package.json b/packages/electron/package.json index 83b26df3de..604a0938c5 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -83,6 +83,7 @@ "@better-fetch/fetch": "catalog:", "better-auth": "workspace:*", "better-call": "catalog:", + "electron": ">=36.0.0", "conf": "^15.0.2" }, "dependencies": { @@ -91,6 +92,7 @@ "devDependencies": { "@better-auth/core": "workspace:*", "@types/better-sqlite3": "^7.6.13", + "electron": "^38.8.0", "better-auth": "workspace:*", "better-sqlite3": "^12.6.2", "tsdown": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de5ad5f42..62cdef206c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1654,6 +1654,9 @@ importers: better-sqlite3: specifier: ^12.6.2 version: 12.6.2 + electron: + specifier: ^38.8.0 + version: 38.8.0 tsdown: specifier: 'catalog:' version: 0.20.1(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.17.0)(publint@0.3.17)(synckit@0.11.11)(typescript@5.9.3) @@ -9944,6 +9947,11 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} + electron@38.8.0: + resolution: {integrity: sha512-cMvNuIFdXXDr4pJd5xD+F8FDjQhin9vyJ5hGSb5cZRIUb1VqfvRyP2nLBzU2Iazj0z3L686AUOkykp6Xd8bT1w==} + engines: {node: '>= 12.20.55'} + hasBin: true + electron@39.5.1: resolution: {integrity: sha512-6s/sBQar+bbW59XSqohZj04MPic+kdVUAWjLbfQB/uLOeNw9jWX5FHaTxpHK29Xp3mKOHef7wErsjwMyCuWltg==} engines: {node: '>= 12.20.55'} @@ -22582,9 +22590,7 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate optional: true '@react-native/metro-config@0.83.1(@babel/core@7.29.0)': @@ -22595,9 +22601,7 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} @@ -24314,14 +24318,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.7(@types/node@25.0.10)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.2.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -26487,6 +26491,14 @@ snapshots: transitivePeerDependencies: - supports-color + electron@38.8.0: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 22.19.8 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + electron@39.5.1: dependencies: '@electron/get': 2.0.3 @@ -34781,7 +34793,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 From 229c6370332f51abbaed28eea9ce33d328b35c9c Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Mon, 9 Feb 2026 19:09:19 -0800 Subject: [PATCH 13/13] test(email-otp): simplify test assertions and improve error handling (#7884) --- .../src/plugins/email-otp/email-otp.test.ts | 95 +++++++------------ 1 file changed, 36 insertions(+), 59 deletions(-) diff --git a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts index 157dfe9925..3cd043bffb 100644 --- a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts +++ b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts @@ -706,23 +706,20 @@ describe("custom storeOTP", async () => { }); it("should not be allowed to get otp if storeOTP is hashed", async () => { - try { - await auth.api.getVerificationOTP({ + await expect( + auth.api.getVerificationOTP({ query: { email: userEmail1, type: "sign-in", }, - }); - } catch (error: any) { - expect(error.statusCode).toBe(400); - expect(error.status).toBe("BAD_REQUEST"); - expect(error.body.code).toBe( - "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP", - ); - return; - } - // Should not reach here given the above should throw and thus return. - expect(true).toBe(false); + }), + ).rejects.toMatchObject({ + statusCode: 400, + status: "BAD_REQUEST", + body: { + code: "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP", + }, + }); }); it("should be able to sign in with normal otp", async () => { @@ -807,22 +804,14 @@ describe("custom storeOTP", async () => { }); it("should be allowed to get otp if storeOTP is encrypted", async () => { - try { - const res = await auth.api.getVerificationOTP({ - query: { - email: userEmail1, - type: "sign-in", - }, - }); - if (!res.otp) { - expect(true).toBe(false); - return; - } - expect(res.otp).toEqual(validOTP); - expect(res.otp.length).toBe(6); - } catch (error: any) { - expect(error).not.toBeDefined(); - } + const res = await auth.api.getVerificationOTP({ + query: { + email: userEmail1, + type: "sign-in", + }, + }); + expect(res.otp).toEqual(validOTP); + expect(res.otp?.length).toBe(6); }); it("should be able to sign in with encrypted otp", async () => { @@ -912,23 +901,14 @@ describe("custom storeOTP", async () => { }); it("should be allowed to get otp if storeOTP is custom encryptor", async () => { - try { - const res = await auth.api.getVerificationOTP({ - query: { - email: userEmail1, - type: "sign-in", - }, - }); - if (!res.otp) { - expect(true).toBe(false); - return; - } - expect(res.otp).toEqual(validOTP); - expect(res.otp.length).toBe(6); - } catch (error: any) { - console.error(error); - expect(error).not.toBeDefined(); - } + const res = await auth.api.getVerificationOTP({ + query: { + email: userEmail1, + type: "sign-in", + }, + }); + expect(res.otp).toEqual(validOTP); + expect(res.otp?.length).toBe(6); }); it("should be able to sign in with custom encryptor otp", async () => { @@ -1015,23 +995,20 @@ describe("custom storeOTP", async () => { }); it("should be allowed to get otp if storeOTP is custom hasher", async () => { - try { - await auth.api.getVerificationOTP({ + await expect( + auth.api.getVerificationOTP({ query: { email: userEmail1, type: "sign-in", }, - }); - } catch (error: any) { - expect(error.statusCode).toBe(400); - expect(error.status).toBe("BAD_REQUEST"); - expect(error.body.code).toBe( - "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP", - ); - return; - } - // Should not reach here given the above should throw and thus return. - expect(true).toBe(false); + }), + ).rejects.toMatchObject({ + statusCode: 400, + status: "BAD_REQUEST", + body: { + code: "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP", + }, + }); }); it("should be able to sign in with custom hasher otp", async () => {