Merge branch 'canary' into feat/prisma-custom-relations

This commit is contained in:
Maxwell
2026-01-09 19:58:27 +10:00
committed by GitHub
11 changed files with 371 additions and 20 deletions

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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,
);
}

View File

@@ -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,

View File

@@ -320,6 +320,10 @@ export const signInSocial = <O extends BetterAuthOptions>() =>
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 = <O extends BetterAuthOptions>() =>
},
ctx.body.rememberMe === false,
);
if (ctx.body.callbackURL) {
ctx.setHeader("Location", ctx.body.callbackURL);
}
return ctx.json({
redirect: !!ctx.body.callbackURL,
token: session.token,

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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<string, any>;
deleteUser: ReturnType<typeof vi.fn>;
}) {
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");
});
});
});

View File

@@ -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<string, any>)
| 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);
}),
},
],

View File

@@ -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);

View File

@@ -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: {