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/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 1f7ed9044c..33906c5fe8 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, @@ -534,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, ); } @@ -712,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, ); } 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, 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, 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, diff --git a/packages/better-auth/src/plugins/anonymous/anon.test.ts b/packages/better-auth/src/plugins/anonymous/anon.test.ts index 5e315daea4..18327edf4f 100644 --- a/packages/better-auth/src/plugins/anonymous/anon.test.ts +++ b/packages/better-auth/src/plugins/anonymous/anon.test.ts @@ -10,6 +10,7 @@ import { it, vi, } from "vitest"; +import * as apiModule from "../../api"; import { signJWT } from "../../crypto"; import { getTestInstance } from "../../test-utils/test-instance"; import { DEFAULT_SECRET } from "../../utils/constants"; @@ -58,6 +59,7 @@ beforeAll(async () => { afterEach(() => { server.resetHandlers(); server.use(...handlers); + vi.restoreAllMocks(); }); afterAll(() => server.close()); @@ -371,4 +373,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..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 @@ -314,9 +318,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); }), }, ], diff --git a/packages/better-auth/src/plugins/one-tap/client.ts b/packages/better-auth/src/plugins/one-tap/client.ts index 39a28591d9..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; /** @@ -105,6 +107,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 +252,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 +267,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); 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: {