mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-22 14:21:55 -05:00
Merge branch 'canary' into feat/prisma-custom-relations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
160
packages/better-auth/src/oauth2/utils.test.ts
Normal file
160
packages/better-auth/src/oauth2/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user