From 064ee71b7f1d96cbd7855d2407ece8f31d347e09 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Thu, 8 Jan 2026 19:58:36 +0530 Subject: [PATCH 01/10] fix: set Location header on redirected responses (#6232) Co-authored-by: Maxwell <145994855+ping-maxwell@users.noreply.github.com> --- packages/better-auth/src/api/routes/account.ts | 4 ++++ packages/better-auth/src/api/routes/sign-in.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/better-auth/src/api/routes/account.ts b/packages/better-auth/src/api/routes/account.ts index 1f7ed9044c..93efb4b728 100644 --- a/packages/better-auth/src/api/routes/account.ts +++ b/packages/better-auth/src/api/routes/account.ts @@ -373,6 +373,10 @@ export const linkSocialAccount = createAuthEndpoint( scopes: c.body.scopes, }); + if (!c.body.disableRedirect) { + c.setHeader("Location", url.toString()); + } + return c.json({ url: url.toString(), redirect: !c.body.disableRedirect, diff --git a/packages/better-auth/src/api/routes/sign-in.ts b/packages/better-auth/src/api/routes/sign-in.ts index e622d4ee6f..dbd099f0ea 100644 --- a/packages/better-auth/src/api/routes/sign-in.ts +++ b/packages/better-auth/src/api/routes/sign-in.ts @@ -320,6 +320,10 @@ export const signInSocial = () => loginHint: c.body.loginHint, }); + if (!c.body.disableRedirect) { + c.setHeader("Location", url.toString()); + } + return c.json({ url: url.toString(), redirect: !c.body.disableRedirect, @@ -554,6 +558,11 @@ export const signInEmail = () => }, ctx.body.rememberMe === false, ); + + if (ctx.body.callbackURL) { + ctx.setHeader("Location", ctx.body.callbackURL); + } + return ctx.json({ redirect: !!ctx.body.callbackURL, token: session.token, From bdd3062b30ba09a4fdd0ed5922afb4366324a91d Mon Sep 17 00:00:00 2001 From: Rodrigo Santos Date: Thu, 8 Jan 2026 15:25:17 +0000 Subject: [PATCH 02/10] fix(anonymous): prevent Convex cleanup from deleting fresh sessions (#5825) Co-authored-by: Maxwell <145994855+ping-maxwell@users.noreply.github.com> --- .../src/plugins/anonymous/anon.test.ts | 110 ++++++++++++++++++ .../src/plugins/anonymous/index.ts | 14 ++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/better-auth/src/plugins/anonymous/anon.test.ts b/packages/better-auth/src/plugins/anonymous/anon.test.ts index 5e315daea4..c3a038eb2f 100644 --- a/packages/better-auth/src/plugins/anonymous/anon.test.ts +++ b/packages/better-auth/src/plugins/anonymous/anon.test.ts @@ -10,6 +10,8 @@ import { it, vi, } from "vitest"; +import * as apiModule from "../../api"; +import { createAuthClient } from "../../client"; import { signJWT } from "../../crypto"; import { getTestInstance } from "../../test-utils/test-instance"; import { DEFAULT_SECRET } from "../../utils/constants"; @@ -58,6 +60,7 @@ beforeAll(async () => { afterEach(() => { server.resetHandlers(); server.use(...handlers); + vi.restoreAllMocks(); }); afterAll(() => server.close()); @@ -371,4 +374,111 @@ describe("anonymous", async () => { "Anonymous users cannot sign in again anonymously", ); }); + + describe("anonymous cleanup safeguards", () => { + function createMiddlewareContext({ + newSessionUser, + deleteUser, + }: { + newSessionUser: Record; + deleteUser: ReturnType; + }) { + return { + path: "/sign-in/anonymous", + context: { + responseHeaders: new Headers({ + "set-cookie": + "better-auth.session_token=new-token.value; Path=/; HttpOnly", + }), + authCookies: { + sessionToken: { + name: "better-auth.session_token", + options: {}, + }, + sessionData: { + name: "better-auth.session_data", + options: {}, + }, + dontRememberToken: { + name: "better-auth.dont_remember", + options: {}, + }, + }, + newSession: { + user: newSessionUser, + session: { + token: "new-token", + }, + }, + internalAdapter: { + deleteUser, + }, + options: {}, + secret: "secret", + setNewSession: vi.fn(), + }, + headers: new Headers(), + query: {}, + error: vi.fn(), + json: vi.fn(), + getSignedCookie: vi.fn(), + setCookie: vi.fn(), + setSignedCookie: vi.fn(), + } as any; + } + + it("does not delete when the new session is still anonymous", async () => { + const plugin = anonymous(); + const handler = plugin.hooks?.after?.[0]?.handler; + const deleteUser = vi.fn(); + const ctx = createMiddlewareContext({ + newSessionUser: { + id: "anon-user", + isAnonymous: true, + }, + deleteUser, + }); + + vi.spyOn(apiModule, "getSessionFromCtx").mockResolvedValue({ + user: { + id: "anon-user", + isAnonymous: true, + }, + session: { + token: "old-token", + }, + } as any); + + await handler?.(ctx); + + expect(deleteUser).not.toHaveBeenCalled(); + }); + + it("deletes the previous anonymous user when linking a new account", async () => { + const plugin = anonymous(); + const handler = plugin.hooks?.after?.[0]?.handler; + const deleteUser = vi.fn(); + const ctx = createMiddlewareContext({ + newSessionUser: { + id: "linked-user", + isAnonymous: false, + }, + deleteUser, + }); + + vi.spyOn(apiModule, "getSessionFromCtx").mockResolvedValue({ + user: { + id: "anon-user", + isAnonymous: true, + }, + session: { + token: "old-token", + }, + } as any); + + await handler?.(ctx); + + expect(deleteUser).toHaveBeenCalledWith("anon-user"); + }); + }); }); diff --git a/packages/better-auth/src/plugins/anonymous/index.ts b/packages/better-auth/src/plugins/anonymous/index.ts index 7c0a88b279..09976ad550 100644 --- a/packages/better-auth/src/plugins/anonymous/index.ts +++ b/packages/better-auth/src/plugins/anonymous/index.ts @@ -314,9 +314,19 @@ export const anonymous = (options?: AnonymousOptions | undefined) => { ctx, }); } - if (!options?.disableDeleteAnonymousUser) { - await ctx.context.internalAdapter.deleteUser(session.user.id); + const newSessionUser = newSession.user as + | (UserWithAnonymous & Record) + | undefined; + const isSameUser = newSessionUser?.id === session.user.id; + const newSessionIsAnonymous = Boolean(newSessionUser?.isAnonymous); + if ( + options?.disableDeleteAnonymousUser || + isSameUser || + newSessionIsAnonymous + ) { + return; } + await ctx.context.internalAdapter.deleteUser(session.user.id); }), }, ], From 870acfcd5c1548ac2e4da5f284c53231749b1075 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Fri, 9 Jan 2026 03:23:47 +1000 Subject: [PATCH 03/10] chore: fix missing AnonymousSession import (#7203) --- packages/better-auth/src/plugins/anonymous/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/better-auth/src/plugins/anonymous/index.ts b/packages/better-auth/src/plugins/anonymous/index.ts index 09976ad550..aef8e405da 100644 --- a/packages/better-auth/src/plugins/anonymous/index.ts +++ b/packages/better-auth/src/plugins/anonymous/index.ts @@ -18,7 +18,11 @@ import { import { mergeSchema } from "../../db/schema"; import { ANONYMOUS_ERROR_CODES } from "./error-codes"; import { schema } from "./schema"; -import type { AnonymousOptions, AnonymousSession } from "./types"; +import type { + AnonymousOptions, + AnonymousSession, + UserWithAnonymous, +} from "./types"; declare module "@better-auth/core" { // biome-ignore lint/correctness/noUnusedVariables: Auth and Context need to be same as declared in the module From d7621ccaf5bc51b7e49898b72747c694b9c8c89c Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Fri, 9 Jan 2026 04:16:13 +1000 Subject: [PATCH 04/10] fix(passkey): add error logs during client verification error (#7193) --- packages/passkey/src/client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/passkey/src/client.ts b/packages/passkey/src/client.ts index 74903f01ab..10922321cf 100644 --- a/packages/passkey/src/client.ts +++ b/packages/passkey/src/client.ts @@ -70,7 +70,9 @@ export const getPasskeyActions = ( $store.notify("$sessionSignal"); return verified; - } catch { + } catch (err) { + // Error logs ran on the front-end + console.error(`[Better Auth] Error verifying passkey`, err); return { data: null, error: { From 6ac030c3c57cc1f4c5149fd900681672eb6337b0 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:40:26 +0900 Subject: [PATCH 05/10] chore: clean up unused import (#7212) --- packages/better-auth/src/plugins/anonymous/anon.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/better-auth/src/plugins/anonymous/anon.test.ts b/packages/better-auth/src/plugins/anonymous/anon.test.ts index c3a038eb2f..18327edf4f 100644 --- a/packages/better-auth/src/plugins/anonymous/anon.test.ts +++ b/packages/better-auth/src/plugins/anonymous/anon.test.ts @@ -11,7 +11,6 @@ import { vi, } from "vitest"; import * as apiModule from "../../api"; -import { createAuthClient } from "../../client"; import { signJWT } from "../../crypto"; import { getTestInstance } from "../../test-utils/test-instance"; import { DEFAULT_SECRET } from "../../utils/constants"; From ca9dfd78e6770fcfd0b299228c971dc70de7453a Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:41:11 +0900 Subject: [PATCH 06/10] fix(one-tap): respect user dismiss actions in prompt retry logic (#7211) --- .../better-auth/src/plugins/one-tap/client.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/better-auth/src/plugins/one-tap/client.ts b/packages/better-auth/src/plugins/one-tap/client.ts index 39a28591d9..f0dd5bf10c 100644 --- a/packages/better-auth/src/plugins/one-tap/client.ts +++ b/packages/better-auth/src/plugins/one-tap/client.ts @@ -105,6 +105,15 @@ function isFedCMSupported() { return typeof window !== "undefined" && "IdentityCredential" in window; } +/** + * Reasons that should NOT trigger a retry. + * @see https://developers.google.com/identity/gsi/web/reference/js-reference + */ +const noRetryReasons = { + dismissed: ["credential_returned", "cancel_called"], + skipped: ["user_cancel", "tap_outside"], +} as const; + export const oneTapClient = (options: GoogleOneTapOptions) => { return { id: "one-tap", @@ -241,6 +250,11 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { notification.isDismissedMoment && notification.isDismissedMoment() ) { + const reason = notification.getDismissedReason?.(); + if (noRetryReasons.dismissed.includes(reason)) { + opts?.onPromptNotification?.(notification); + return; + } if (attempt < maxAttempts) { const delay = Math.pow(2, attempt) * baseDelay; setTimeout(() => handlePrompt(attempt + 1), delay); @@ -251,6 +265,11 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { notification.isSkippedMoment && notification.isSkippedMoment() ) { + const reason = notification.getSkippedReason?.(); + if (noRetryReasons.skipped.includes(reason)) { + opts?.onPromptNotification?.(notification); + return; + } if (attempt < maxAttempts) { const delay = Math.pow(2, attempt) * baseDelay; setTimeout(() => handlePrompt(attempt + 1), delay); From d3c5de4a5f05f92e44c47a6a1dacadc422d1162e Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:41:32 +1000 Subject: [PATCH 07/10] fix(email-verification): sending email verification of another user fails with EMAIL_ALREADY_VERIFIED (#7215) --- packages/better-auth/src/api/routes/email-verification.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/better-auth/src/api/routes/email-verification.ts b/packages/better-auth/src/api/routes/email-verification.ts index 3ce6e2e1fd..615d9b10e4 100644 --- a/packages/better-auth/src/api/routes/email-verification.ts +++ b/packages/better-auth/src/api/routes/email-verification.ts @@ -187,15 +187,15 @@ export const sendVerificationEmail = createAuthEndpoint( status: true, }); } + if (session?.user.email !== email) { + throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.EMAIL_MISMATCH); + } if (session?.user.emailVerified) { throw APIError.from( "BAD_REQUEST", BASE_ERROR_CODES.EMAIL_ALREADY_VERIFIED, ); } - if (session?.user.email !== email) { - throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.EMAIL_MISMATCH); - } await sendVerificationEmailFn(ctx, session.user); return ctx.json({ status: true, From 706a422f09799e79819cfb8f5cdc991b3c278d51 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:41:48 +0900 Subject: [PATCH 08/10] docs(one-tap): add missing option and update to clearer description (#7214) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/content/docs/plugins/one-tap.mdx | 23 ++++++++++--------- .../better-auth/src/plugins/one-tap/client.ts | 2 ++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/plugins/one-tap.mdx b/docs/content/docs/plugins/one-tap.mdx index 81505f2c92..2a0ac46f7a 100644 --- a/docs/content/docs/plugins/one-tap.mdx +++ b/docs/content/docs/plugins/one-tap.mdx @@ -107,20 +107,21 @@ await authClient.oneTap({ ### Client Options -- **clientId**: The client ID for your Google One Tap API. -- **autoSelect**: Automatically select the account if the user is already signed in. Default is false. -- **context**: The context in which the One Tap API should be used (e.g., "signin"). Default is "signin". -- **cancelOnTapOutside**: Cancel the One Tap popup when the user taps outside it. Default is true. -- additionalOptions: Extra options to pass to Google's initialize method as per the [Google Identity Services docs](https://developers.google.com/identity/gsi/web/reference/js-reference#google.accounts.id.prompt). -- **promptOptions**: Configuration for the prompt behavior and exponential backoff: -- **baseDelay**: Base delay in milliseconds for retries. Default is 1000. -- **maxAttempts**: Maximum number of prompt attempts before invoking the onPromptNotification callback. Default is 5. -- **fedCM**: Whether to enable [Federated Credential Management](https://developer.mozilla.org/en-US/docs/Web/API/FedCM_API) (FedCM) support. Default is true. +- `clientId`: The client ID for your Google One Tap API. +- `autoSelect`: Automatically select the account if the user is already signed in. Default is false. +- `cancelOnTapOutside`: Cancel the One Tap popup when the user taps outside it. To use this option, disable `promptOptions.fedCM`. Default is true. +- `uxMode`: The mode to use for the Google One Tap flow. Can be "popup" or "redirect". Default is "popup". +- `context`: The context in which the One Tap API should be used. Can be "signin", "signup", or "use". Default is "signin". +- `additionalOptions`: Extra options to pass to Google's initialize method as per the [Google Identity Services docs](https://developers.google.com/identity/gsi/web/reference/js-reference#google.accounts.id.prompt). +- `promptOptions`: Configuration for the prompt behavior and exponential backoff: + - `baseDelay`: Base delay in milliseconds for retries. Default is 1000. + - `maxAttempts`: Maximum number of prompt attempts before invoking the `onPromptNotification` callback. Default is 5. + - `fedCM`: Whether to enable [Federated Credential Management](https://developer.mozilla.org/en-US/docs/Web/API/FedCM_API) (FedCM) support. Default is true. ### Server Options -- **disableSignUp**: Disable the sign-up option, allowing only existing users to sign in. Default is `false`. -- **ClientId**: Optionally, pass a client ID here if it is not provided in your social provider configuration. +- `disableSignUp`: Disable the sign-up option, allowing only existing users to sign in. Default is false. +- `clientId`: Optionally, pass a client ID here if it is not provided in your social provider configuration. ### Authorized JavaScript origins Ensure you have configured the Authorized JavaScript origins (e.g., http://localhost:3000, https://example.com) for your Client ID in the Google Cloud Console. This is a required step for the Google One Tap API, and it will not function correctly unless your origins are correctly set. diff --git a/packages/better-auth/src/plugins/one-tap/client.ts b/packages/better-auth/src/plugins/one-tap/client.ts index f0dd5bf10c..e8b9a20683 100644 --- a/packages/better-auth/src/plugins/one-tap/client.ts +++ b/packages/better-auth/src/plugins/one-tap/client.ts @@ -30,6 +30,8 @@ export interface GoogleOneTapOptions { autoSelect?: boolean | undefined; /** * Cancel the flow when the user taps outside the prompt + * + * Note: To use this option, disable `promptOptions.fedCM` */ cancelOnTapOutside?: boolean | undefined; /** From a0f4ff1ea24d76369e7ed380c4ddc139c68aa9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+Paola3stefania@users.noreply.github.com> Date: Fri, 9 Jan 2026 05:42:19 +0100 Subject: [PATCH 09/10] fix(oauth): handle unencrypted tokens when encryptOAuthTokens is enabled (#7210) --- packages/better-auth/src/oauth2/utils.test.ts | 160 ++++++++++++++++++ packages/better-auth/src/oauth2/utils.ts | 10 ++ 2 files changed, 170 insertions(+) create mode 100644 packages/better-auth/src/oauth2/utils.test.ts diff --git a/packages/better-auth/src/oauth2/utils.test.ts b/packages/better-auth/src/oauth2/utils.test.ts new file mode 100644 index 0000000000..000fa0717c --- /dev/null +++ b/packages/better-auth/src/oauth2/utils.test.ts @@ -0,0 +1,160 @@ +import type { AuthContext } from "@better-auth/core"; +import { describe, expect, it } from "vitest"; +import { symmetricEncrypt } from "../crypto"; +import { decryptOAuthToken, setTokenUtil } from "./utils"; + +// Mock minimal AuthContext for testing +function createMockContext(encryptOAuthTokens: boolean): AuthContext { + return { + secret: "test-secret-key-for-encryption", + options: { + account: { + encryptOAuthTokens, + }, + }, + } as unknown as AuthContext; +} + +describe("decryptOAuthToken", () => { + it("should return empty token as-is", async () => { + const ctx = createMockContext(true); + const result = await decryptOAuthToken("", ctx); + expect(result).toBe(""); + }); + + it("should return token as-is when encryption is disabled", async () => { + const ctx = createMockContext(false); + const plainToken = "ya29.a0ARW5m7hQ_some_oauth_token"; + const result = await decryptOAuthToken(plainToken, ctx); + expect(result).toBe(plainToken); + }); + + it("should decrypt encrypted token when encryption is enabled", async () => { + const ctx = createMockContext(true); + const originalToken = "test-access-token"; + + // Encrypt the token first + const encryptedToken = await symmetricEncrypt({ + key: ctx.secret, + data: originalToken, + }); + + // Decrypt should return original + const result = await decryptOAuthToken(encryptedToken, ctx); + expect(result).toBe(originalToken); + }); + + it("should handle migration: return unencrypted token as-is when encryption is enabled", async () => { + const ctx = createMockContext(true); + + // Simulate a token that was stored before encryption was enabled + // OAuth tokens typically contain dots, underscores, hyphens - not valid hex + const plainOAuthToken = "ya29.a0ARW5m7hQ_some_oauth_token_with-dashes"; + + // This should NOT throw, and should return the token as-is + const result = await decryptOAuthToken(plainOAuthToken, ctx); + expect(result).toBe(plainOAuthToken); + }); + + it("should handle migration: JWT-style tokens should be returned as-is", async () => { + const ctx = createMockContext(true); + + // JWT tokens contain dots which are not valid hex characters + const jwtToken = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"; + + const result = await decryptOAuthToken(jwtToken, ctx); + expect(result).toBe(jwtToken); + }); + + it("should handle migration: token with odd length should be returned as-is", async () => { + const ctx = createMockContext(true); + + // Odd length hex-like string cannot be valid encrypted data + const oddLengthToken = "abc"; + + const result = await decryptOAuthToken(oddLengthToken, ctx); + expect(result).toBe(oddLengthToken); + }); +}); + +describe("migration scenario - issue #6018", () => { + it("should handle Google OAuth token stored before encryption was enabled", async () => { + // Simulate the exact bug scenario from issue #6018: + // 1. User logs in with Google OAuth when encryptOAuthTokens: false + // 2. Access token stored as plain text: "ya29.a0ARW5m7..." + // 3. User enables encryptOAuthTokens: true + // 4. Access token expires, system tries to decrypt the plain text token + // 5. Previously: "hex string expected, got unpadded hex of length 253" /* cspell:disable-line */ + // 6. Now: should return the token as-is + + const ctx = createMockContext(true); // encryption now enabled + + // Real-world Google OAuth access token format (contains non-hex chars) + const googleAccessToken = + "ya29.a0ARW5m7hQ_test-token_with.dots-and_underscores"; + + // This should NOT throw "hex string expected, got unpadded hex of length X" /* cspell:disable-line */ + const result = await decryptOAuthToken(googleAccessToken, ctx); + expect(result).toBe(googleAccessToken); + }); + + it("should handle refresh token that was stored unencrypted", async () => { + const ctx = createMockContext(true); + + // Google refresh tokens have this format + const googleRefreshToken = + "1//0gxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; // cspell:disable-line + + const result = await decryptOAuthToken(googleRefreshToken, ctx); + expect(result).toBe(googleRefreshToken); + }); + + it("should still decrypt properly encrypted tokens", async () => { + const ctx = createMockContext(true); + const originalToken = "ya29.newToken_after_encryption_enabled"; + + // Simulate a token that was stored AFTER encryption was enabled + const encryptedToken = await setTokenUtil(originalToken, ctx); + + // Should decrypt correctly + const result = await decryptOAuthToken(encryptedToken as string, ctx); + expect(result).toBe(originalToken); + }); +}); + +describe("setTokenUtil", () => { + it("should return null/undefined as-is", async () => { + const ctx = createMockContext(true); + expect(await setTokenUtil(null, ctx)).toBe(null); + expect(await setTokenUtil(undefined, ctx)).toBe(undefined); + }); + + it("should return token as-is when encryption is disabled", async () => { + const ctx = createMockContext(false); + const token = "test-token"; + const result = await setTokenUtil(token, ctx); + expect(result).toBe(token); + }); + + it("should encrypt token when encryption is enabled", async () => { + const ctx = createMockContext(true); + const token = "test-token"; + const result = await setTokenUtil(token, ctx); + + // Result should be hex-encoded encrypted data + expect(result).not.toBe(token); + expect(result).toMatch(/^[0-9a-f]+$/i); + expect((result as string).length % 2).toBe(0); + }); + + it("should produce tokens that can be decrypted", async () => { + const ctx = createMockContext(true); + const originalToken = "my-secret-access-token"; + + const encrypted = await setTokenUtil(originalToken, ctx); + const decrypted = await decryptOAuthToken(encrypted as string, ctx); + + expect(decrypted).toBe(originalToken); + }); +}); diff --git a/packages/better-auth/src/oauth2/utils.ts b/packages/better-auth/src/oauth2/utils.ts index f324e8e64d..bb152ca2da 100644 --- a/packages/better-auth/src/oauth2/utils.ts +++ b/packages/better-auth/src/oauth2/utils.ts @@ -1,9 +1,19 @@ import type { AuthContext } from "@better-auth/core"; import { symmetricDecrypt, symmetricEncrypt } from "../crypto"; +/** + * Check if a string looks like encrypted data + */ +function isLikelyEncrypted(token: string): boolean { + return token.length % 2 === 0 && /^[0-9a-f]+$/i.test(token); +} + export function decryptOAuthToken(token: string, ctx: AuthContext) { if (!token) return token; if (ctx.options.account?.encryptOAuthTokens) { + if (!isLikelyEncrypted(token)) { + return token; + } return symmetricDecrypt({ key: ctx.secret, data: token, From f869c4874b5aa263b292c899b4a430bca30ebc58 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:36:23 +0900 Subject: [PATCH 10/10] fix: use accountId instead of id when looking up accounts (#7216) --- .../src/api/routes/account.test.ts | 21 +++++++++++++++++++ .../better-auth/src/api/routes/account.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/better-auth/src/api/routes/account.test.ts b/packages/better-auth/src/api/routes/account.test.ts index b93f3f5007..b71213f9f8 100644 --- a/packages/better-auth/src/api/routes/account.test.ts +++ b/packages/better-auth/src/api/routes/account.test.ts @@ -170,6 +170,27 @@ describe("account", async () => { }); }); + it("should get access token using accountId from listAccounts", async () => { + const { runWithUser: runWithClient2 } = await signInWithTestUser(); + await runWithClient2(async () => { + const accounts = await client.listAccounts(); + const googleAccount = accounts.data?.find( + (a) => a.providerId === "google", + ); + expect(googleAccount).toBeDefined(); + expect(googleAccount?.accountId).toBeDefined(); + + // Use accountId from listAccounts to get access token + const accessToken = await client.getAccessToken({ + providerId: "google", + accountId: googleAccount!.accountId, + }); + + expect(accessToken.error).toBeNull(); + expect(accessToken.data?.accessToken).toBe("test"); + }); + }); + it("should pass custom scopes to authorization URL", async () => { const { runWithUser: runWithClient2 } = await signInWithTestUser(); await runWithClient2(async () => { diff --git a/packages/better-auth/src/api/routes/account.ts b/packages/better-auth/src/api/routes/account.ts index 93efb4b728..33906c5fe8 100644 --- a/packages/better-auth/src/api/routes/account.ts +++ b/packages/better-auth/src/api/routes/account.ts @@ -538,7 +538,7 @@ export const getAccessToken = createAuthEndpoint( await ctx.context.internalAdapter.findAccounts(resolvedUserId); account = accounts.find((acc) => accountId - ? acc.id === accountId && acc.providerId === providerId + ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId, ); } @@ -716,7 +716,7 @@ export const refreshToken = createAuthEndpoint( await ctx.context.internalAdapter.findAccounts(resolvedUserId); account = accounts.find((acc) => accountId - ? acc.id === accountId && acc.providerId === providerId + ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId, ); }