feat: id token auth (#463)

This commit is contained in:
Bereket Engida
2024-11-09 00:40:14 +03:00
committed by GitHub
parent 345e478797
commit 9cddaad623
13 changed files with 531 additions and 275 deletions

View File

@@ -30,20 +30,41 @@ description: Apple
})
```
</Step>
<Step>
### Signin with Apple
To signin with Apple, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `apple`.
```ts title="client.ts" /
import { createAuthClient } from "better-auth/client"
const client = createAuthClient()
const signin = async () => {
const data = await client.signIn.social({
provider: "apple"
})
}
```
</Step>
</Steps>
## Usage
### Signin with Apple
To signin with Apple, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `apple`.
```ts title="client.ts" /
import { createAuthClient } from "better-auth/client"
const client = createAuthClient()
const signin = async () => {
const data = await client.signIn.social({
provider: "apple"
})
}
```
### Signin with Apple With ID Token
To signin with Apple using the ID Token, you can use the `signIn.social` function to pass the ID Token.
This is useful when you have the ID Token from Apple on the client-side and want to use it to sign in on the server.
```ts title="client.ts"
await client.signIn.social({
provider: "apple",
idToken: {
token: // Apple ID Token,
nonce: // Nonce (optional)
accessToken: // Access Token (optional)
}
})
```

View File

@@ -28,20 +28,42 @@ description: Google Provider
})
```
</Step>
<Step>
### Signin with Google
To signin with Google, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `google`.
```ts title="client.ts" /
import { createAuthClient } from "better-auth/client"
const client = createAuthClient()
const signin = async () => {
const data = await client.signIn.social({
provider: "google"
})
}
```
</Step>
</Steps>
## Usage
### Signin with Google
To signin with Google, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `google`.
```ts title="client.ts" /
import { createAuthClient } from "better-auth/client"
const client = createAuthClient()
const signin = async () => {
const data = await client.signIn.social({
provider: "google"
})
}
```
### Signin with Google With ID Token
To signin with Google using the ID Token, you can use the `signIn.social` function to pass the ID Token.
This is useful when you have the ID Token from Google on the client-side and want to use it to sign in on the server.
```ts title="client.ts"
const data = await client.signIn.social({
provider: "google",
idToken: {
token: // Google ID Token,
accessToken: // Google Access Token
}
})
```
<Callout>
If you want to use google one tap, you can use the [One Tap Plugin](/docs/plugins/one-tap) guide.
</Callout>

View File

@@ -7,8 +7,7 @@ import { HIDE_METADATA } from "../../utils/hide-metadata";
import { setSessionCookie } from "../../cookies";
import { logger } from "../../utils/logger";
import type { OAuth2Tokens } from "../../oauth2";
import { createEmailVerificationToken } from "./email-verification";
import { isDevelopment } from "../../utils/env";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
export const callbackOAuth = createAuthEndpoint(
"/callback/:id",
@@ -112,133 +111,27 @@ export const callbackOAuth = createAuthEndpoint(
throw c.redirect(toRedirectTo);
}
const dbUser = await c.context.internalAdapter
.findUserByEmail(data.email, {
includeAccounts: true,
})
.catch((e) => {
logger.error(
"Better auth was unable to query your database.\nError: ",
e,
);
throw c.redirect(
`${c.context.baseURL}/error?error=internal_server_error`,
);
});
let user = dbUser?.user;
if (dbUser) {
const hasBeenLinked = dbUser.accounts.find(
(a) => a.providerId === provider.id,
);
if (!hasBeenLinked) {
const trustedProviders =
c.context.options.account?.accountLinking?.trustedProviders;
const isTrustedProvider = trustedProviders?.includes(
provider.id as "apple",
);
if (
(!isTrustedProvider && !userInfo.emailVerified) ||
c.context.options.account?.accountLinking?.enabled === false
) {
if (isDevelopment) {
logger.warn(
`User already exist but account isn't linked to ${provider.id}. To read more about how account linking works in Better Auth see https://www.better-auth.com/docs/concepts/users-accounts#account-linking.`,
);
}
redirectOnError("account_not_linked");
}
try {
await c.context.internalAdapter.linkAccount({
providerId: provider.id,
accountId: userInfo.id.toString(),
id: `${provider.id}:${userInfo.id}`,
userId: dbUser.user.id,
accessToken: tokens.accessToken,
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
});
} catch (e) {
logger.error("Unable to link account", e);
redirectOnError("unable_to_link_account");
}
} else {
await c.context.internalAdapter.updateAccount(hasBeenLinked.id, {
accessToken: tokens.accessToken,
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
});
}
} else {
const findAccount = await c.context.internalAdapter.findAccount(data.id);
if (findAccount) {
const accountUser = await c.context.internalAdapter.findUserById(
findAccount.userId,
);
if (!accountUser) {
return redirectOnError("account_linked_to_unknown_user");
}
if (accountUser.email && accountUser.email !== data.email) {
return redirectOnError("account_linked_to_different_email");
}
user = accountUser;
} else {
try {
const emailVerified = userInfo.emailVerified || false;
user = await c.context.internalAdapter
.createOAuthUser(
{
...data,
email: data.email as string,
name: data.name || "",
emailVerified,
},
{
accessToken: tokens.accessToken,
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
providerId: provider.id,
accountId: userInfo.id.toString(),
},
)
.then((res) => res?.user);
if (
!emailVerified &&
user &&
c.context.options.emailVerification?.sendOnSignUp
) {
const token = await createEmailVerificationToken(
c.context.secret,
user.email,
);
const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
await c.context.options.emailVerification?.sendVerificationEmail?.(
user,
url,
token,
);
}
} catch (e) {
logger.error("Unable to create user", e);
redirectOnError("unable_to_create_user");
}
}
}
if (!user) {
return redirectOnError("unable_to_create_user");
}
const session = await c.context.internalAdapter.createSession(
user.id,
c.request,
);
if (!session) {
redirectOnError("unable_to_create_session");
const result = await handleOAuthUserInfo(c, {
userInfo: {
email: data.email,
id: data.id,
name: data.name || "",
image: data.image,
emailVerified: data.emailVerified || false,
},
account: {
providerId: provider.id,
accountId: userInfo.id,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
},
callbackURL,
});
if (result.error) {
return redirectOnError(result.error.split(" ").join("_"));
}
const { session, user } = result.data!;
await setSessionCookie(c, {
session,
user,
@@ -250,7 +143,6 @@ export const callbackOAuth = createAuthEndpoint(
} catch {
toRedirectTo = callbackURL;
}
throw c.redirect(toRedirectTo);
},
);

View File

@@ -5,6 +5,7 @@ import { setSessionCookie } from "../../cookies";
import { socialProviderList } from "../../social-providers";
import { createEmailVerificationToken } from "./email-verification";
import { generateState, logger } from "../../utils";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
export const signInSocial = createAuthEndpoint(
"/sign-in/social",
@@ -28,6 +29,41 @@ export const signInSocial = createAuthEndpoint(
* OAuth2 provider to use`
*/
provider: z.enum(socialProviderList),
/**
* ID token from the provider
*
* This is used to sign in the user
* if the user is already signed in with the
* provider in the frontend.
*
* Only applicable if the provider supports
* it. Currently only `apple` and `google` is
* supported out of the box.
*/
idToken: z.optional(
z.object({
/**
* ID token from the provider
*/
token: z.string(),
/**
* The nonce used to generate the token
*/
nonce: z.string().optional(),
/**
* Access token from the provider
*/
accessToken: z.string().optional(),
/**
* Refresh token from the provider
*/
refreshToken: z.string().optional(),
/**
* Expiry date of the token
*/
expiresAt: z.number().optional(),
}),
),
}),
},
async (c) => {
@@ -45,6 +81,80 @@ export const signInSocial = createAuthEndpoint(
message: "Provider not found",
});
}
if (c.body.idToken) {
if (!provider.verifyIdToken) {
c.context.logger.error(
"Provider does not support id token verification",
{
provider: c.body.provider,
},
);
throw new APIError("NOT_FOUND", {
message: "Provider does not support id token verification",
});
}
const { token, nonce } = c.body.idToken;
const valid = await provider.verifyIdToken(token, nonce);
if (!valid) {
c.context.logger.error("Invalid id token", {
provider: c.body.provider,
});
throw new APIError("UNAUTHORIZED", {
message: "Invalid id token",
});
}
const userInfo = await provider.getUserInfo({
idToken: token,
accessToken: c.body.idToken.accessToken,
refreshToken: c.body.idToken.refreshToken,
});
if (!userInfo || !userInfo?.user) {
c.context.logger.error("Failed to get user info", {
provider: c.body.provider,
});
throw new APIError("UNAUTHORIZED", {
message: "Failed to get user info",
});
}
if (!userInfo.user.email) {
c.context.logger.error("User email not found", {
provider: c.body.provider,
});
throw new APIError("UNAUTHORIZED", {
message: "User email not found",
});
}
const data = await handleOAuthUserInfo(c, {
userInfo: {
email: userInfo.user.email,
id: userInfo.user.id,
name: userInfo.user.name || "",
image: userInfo.user.image,
emailVerified: userInfo.user.emailVerified || false,
},
account: {
providerId: provider.id,
accountId: userInfo.user.id,
accessToken: c.body.idToken.accessToken,
},
});
if (data.error) {
throw new APIError("UNAUTHORIZED", {
message: data.error,
});
}
await setSessionCookie(c, data.data!);
return c.json({
session: data.data!.session,
user: data.data!.user,
url: `${
c.body.callbackURL || c.query?.currentURL || c.context.options.baseURL
}`,
redirect: true,
});
}
const { codeVerifier, state } = await generateState(c);
const url = await provider.createAuthorizationURL({
state,

View File

@@ -0,0 +1,152 @@
import { createEmailVerificationToken } from "../api";
import type { Account } from "../db/schema";
import type { GenericEndpointContext, User } from "../types";
import { logger } from "../utils";
import { isDevelopment } from "../utils/env";
export async function handleOAuthUserInfo(
c: GenericEndpointContext,
{
userInfo,
account,
callbackURL,
}: {
userInfo: Omit<User, "createdAt" | "updatedAt">;
account: Omit<Account, "id" | "userId">;
callbackURL?: string;
},
) {
const dbUser = await c.context.internalAdapter
.findUserByEmail(userInfo.email, {
includeAccounts: true,
})
.catch((e) => {
logger.error(
"Better auth was unable to query your database.\nError: ",
e,
);
throw c.redirect(
`${c.context.baseURL}/error?error=internal_server_error`,
);
});
let user = dbUser?.user;
if (dbUser) {
const hasBeenLinked = dbUser.accounts.find(
(a) => a.providerId === account.providerId,
);
if (!hasBeenLinked) {
const trustedProviders =
c.context.options.account?.accountLinking?.trustedProviders;
const isTrustedProvider = trustedProviders?.includes(
account.providerId as "apple",
);
if (
(!isTrustedProvider && !userInfo.emailVerified) ||
c.context.options.account?.accountLinking?.enabled === false
) {
if (isDevelopment) {
logger.warn(
`User already exist but account isn't linked to ${account.providerId}. To read more about how account linking works in Better Auth see https://www.better-auth.com/docs/concepts/users-accounts#account-linking.`,
);
}
return {
error: "account not linked",
data: null,
};
}
try {
await c.context.internalAdapter.linkAccount({
providerId: account.providerId,
accountId: userInfo.id.toString(),
id: c.context.uuid(),
userId: dbUser.user.id,
accessToken: account.accessToken,
idToken: account.idToken,
refreshToken: account.refreshToken,
expiresAt: account.expiresAt,
});
} catch (e) {
logger.error("Unable to link account", e);
return {
error: "unable to link account",
data: null,
};
}
} else {
await c.context.internalAdapter.updateAccount(hasBeenLinked.id, {
accessToken: account.accessToken,
idToken: account.idToken,
refreshToken: account.refreshToken,
expiresAt: account.expiresAt,
});
}
} else {
try {
const emailVerified = userInfo.emailVerified || false;
user = await c.context.internalAdapter
.createOAuthUser(
{
...userInfo,
emailVerified,
},
{
accessToken: account.accessToken,
idToken: account.idToken,
refreshToken: account.refreshToken,
expiresAt: account.expiresAt,
providerId: account.providerId,
accountId: userInfo.id.toString(),
},
)
.then((res) => res?.user);
if (
!emailVerified &&
user &&
c.context.options.emailVerification?.sendOnSignUp
) {
const token = await createEmailVerificationToken(
c.context.secret,
user.email,
);
const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
await c.context.options.emailVerification?.sendVerificationEmail?.(
user,
url,
token,
);
}
} catch (e) {
logger.error("Unable to create user", e);
return {
error: "unable to create user",
data: null,
};
}
}
if (!user) {
return {
error: "unable to create user",
data: null,
};
}
const session = await c.context.internalAdapter.createSession(
user.id,
c.request,
);
if (!session) {
return {
error: "unable to create session",
data: null,
};
}
return {
data: {
session,
user,
},
error: null,
};
}

View File

@@ -39,6 +39,7 @@ export interface OAuthProvider<
} | null>;
refreshAccessToken?: (refreshToken: string) => Promise<OAuth2Tokens>;
revokeToken?: (token: string) => Promise<void>;
verifyIdToken?: (token: string, nonce?: string) => Promise<boolean>;
}
export type ProviderOptions = {
@@ -60,4 +61,18 @@ export type ProviderOptions = {
* whitelisted in the provider's dashboard.
*/
redirectURI?: string;
/**
* Disable provider from allowing users to sign in
* with this provider with an id token sent from the
* client.
*/
disableIdTokenSignIn?: boolean;
/**
* verifyIdToken function to verify the id token
*/
verifyIdToken?: (token: string, nonce?: string) => Promise<boolean>;
/**
* Custom function to get user info from the provider
*/
getUserInfo?: (token: OAuth2Tokens) => Promise<any>;
};

View File

@@ -14,6 +14,7 @@ import {
validateAuthorizationCode,
type OAuth2Tokens,
} from "../../oauth2";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
/**
* Configuration interface for generic OAuth providers.
@@ -308,124 +309,60 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
message: "Invalid OAuth configuration.",
});
}
const userInfo = provider.getUserInfo
? await provider.getUserInfo(tokens)
: await getUserInfo(
tokens,
provider.type || "oauth2",
finalUserInfoUrl,
);
const userInfo = (
provider.getUserInfo
? await provider.getUserInfo(tokens)
: await getUserInfo(
tokens,
provider.type || "oauth2",
finalUserInfoUrl,
)
) as {
id: string;
};
const id = generateId();
const user = userInfo
? userSchema.safeParse({
...userInfo,
id,
})
: null;
if (!user?.success) {
throw ctx.redirect(`${errorURL}?error=oauth_user_info_invalid`);
}
const dbUser = await ctx.context.internalAdapter
.findUserByEmail(user.data.email, {
includeAccounts: true,
})
.catch((e) => {
logger.error(
"Better auth was unable to query your database.\nError: ",
e,
);
throw ctx.redirect(`${errorURL}?error=internal_server_error`);
});
const data = userSchema.safeParse({
...userInfo,
id,
});
const userId = dbUser?.user.id || id;
if (dbUser) {
//check if user has already linked this provider
const hasBeenLinked = dbUser.accounts.find(
(a) => a.providerId === provider.providerId,
if (!userInfo || data.success === false) {
logger.error("Unable to get user info", data.error);
throw ctx.redirect(
`${ctx.context.baseURL}/error?error=please_restart_the_process`,
);
const trustedProviders =
ctx.context.options.account?.accountLinking?.trustedProviders;
const isTrustedProvider = trustedProviders
? trustedProviders.includes(provider.providerId as "apple")
: true;
if (
!hasBeenLinked &&
(!user?.data.emailVerified || !isTrustedProvider)
) {
let url: URL;
try {
url = new URL(errorURL!);
url.searchParams.set("error", "account_not_linked");
} catch (e) {
throw ctx.redirect(`${errorURL}?error=account_not_linked`);
}
throw ctx.redirect(url.toString());
}
if (!hasBeenLinked) {
try {
await ctx.context.internalAdapter.linkAccount({
providerId: provider.providerId,
accountId: user.data.id,
id: `${provider.providerId}:${user.data.id}`,
userId: dbUser.user.id,
accessToken: tokens.accessToken,
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
});
} catch (e) {
console.log(e);
throw ctx.redirect(`${errorURL}?error=failed_linking_account`);
}
} else {
await ctx.context.internalAdapter.updateAccount(
hasBeenLinked.id,
{
accessToken: tokens.accessToken,
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
},
);
}
} else {
try {
await ctx.context.internalAdapter.createOAuthUser(user.data, {
id: `${provider.providerId}:${user.data.id}`,
providerId: provider.providerId,
accountId: user.data.id,
accessToken: tokens.accessToken,
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
});
} catch (e) {
const url = new URL(errorURL!);
url.searchParams.set("error", "unable_to_create_user");
ctx.setHeader("Location", url.toString());
throw ctx.redirect(url.toString());
}
}
const result = await handleOAuthUserInfo(ctx, {
userInfo: data.data,
account: {
providerId: provider.providerId,
accountId: userInfo.id,
accessToken: tokens.accessToken,
},
});
function redirectOnError(error: string) {
throw ctx.redirect(
`${
errorURL || callbackURL || `${ctx.context.baseURL}/error`
}?error=${error}`,
);
}
if (result.error) {
return redirectOnError(result.error.split(" ").join("_"));
}
const { session, user } = result.data!;
await setSessionCookie(ctx, {
session,
user,
});
let toRedirectTo: string;
try {
const session = await ctx.context.internalAdapter.createSession(
userId || id,
ctx.request,
);
if (!session) {
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
}
await setSessionCookie(ctx, {
session,
user: user.data,
});
const url = new URL(callbackURL);
toRedirectTo = url.toString();
} catch {
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
toRedirectTo = callbackURL;
}
throw ctx.redirect(callbackURL);
throw ctx.redirect(toRedirectTo);
},
),
},

View File

@@ -1,6 +1,10 @@
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import { parseJWT } from "oslo/jwt";
import { validateAuthorizationCode } from "../oauth2";
import { decodeJwt, importJWK, jwtVerify } from "jose";
import { betterFetch } from "@better-fetch/fetch";
import { APIError } from "better-call";
import { z } from "zod";
export interface AppleProfile {
/**
* The subject registered claim identifies the principal thats the subject
@@ -42,6 +46,10 @@ export interface AppleProfile {
* process.
*/
name: string;
/**
* The URL to the user's profile picture.
*/
picture: string;
}
export interface AppleOptions extends ProviderOptions {}
@@ -71,6 +79,35 @@ export const apple = (options: AppleOptions) => {
tokenEndpoint,
});
},
async verifyIdToken(token, nonce) {
if (options.disableIdTokenSignIn) {
return false;
}
if (options.verifyIdToken) {
return options.verifyIdToken(token, nonce);
}
const decodedHeader = decodeJwt(token);
const { kid, alg: jwtAlg } = decodedHeader.header as {
kid: string;
alg: string;
};
const publicKey = await getApplePublicKey(kid);
const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
algorithms: [jwtAlg],
issuer: "https://appleid.apple.com",
audience: options.clientId,
maxTokenAge: "1h",
});
["email_verified", "is_private_email"].forEach((field) => {
if (jwtClaims[field] !== undefined) {
jwtClaims[field] = Boolean(jwtClaims[field]);
}
});
if (nonce && jwtClaims.nonce !== nonce) {
return false;
}
return !!jwtClaims;
},
async getUserInfo(token) {
if (!token.idToken) {
return null;
@@ -85,9 +122,35 @@ export const apple = (options: AppleOptions) => {
name: data.name,
email: data.email,
emailVerified: data.email_verified === "true",
image: data.picture,
},
data,
};
},
} satisfies OAuthProvider<AppleProfile>;
};
export const getApplePublicKey = async (kid: string) => {
const APPLE_BASE_URL = "https://appleid.apple.com";
const JWKS_APPLE_URI = "/auth/keys";
const { data } = await betterFetch<{
keys: Array<{
kid: string;
alg: string;
kty: string;
use: string;
n: string;
e: string;
}>;
}>(`${APPLE_BASE_URL}${JWKS_APPLE_URI}`);
if (!data?.keys) {
throw new APIError("BAD_REQUEST", {
message: "Keys not found",
});
}
const jwk = data.keys.find((key) => key.kid === kid);
if (!jwk) {
throw new Error(`JWK with kid ${kid} not found`);
}
return await importJWK(jwk, jwk.alg);
};

View File

@@ -3,6 +3,7 @@ import type { OAuthProvider, ProviderOptions } from "../oauth2";
import { BetterAuthError } from "../error";
import { logger } from "../utils/logger";
import { createAuthorizationURL, validateAuthorizationCode } from "../oauth2";
import { betterFetch } from "@better-fetch/fetch";
export interface GoogleProfile {
aud: string;
@@ -76,6 +77,31 @@ export const google = (options: GoogleOptions) => {
tokenEndpoint: "https://oauth2.googleapis.com/token",
});
},
async verifyIdToken(token, nonce) {
if (options.disableIdTokenSignIn) {
return false;
}
if (options.verifyIdToken) {
return options.verifyIdToken(token, nonce);
}
const googlePublicKeyUrl = `https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=${token}`;
const { data: tokenInfo } = await betterFetch<{
aud: string;
iss: string;
email: string;
email_verified: boolean;
name: string;
picture: string;
sub: string;
}>(googlePublicKeyUrl);
if (!tokenInfo) {
return false;
}
const isValid =
tokenInfo.aud === options.clientId &&
tokenInfo.iss === "https://accounts.google.com";
return isValid;
},
async getUserInfo(token) {
if (!token.idToken) {
return null;

View File

@@ -25,8 +25,8 @@
"author": "",
"license": "ISC",
"devDependencies": {
"better-auth": "workspace:*",
"@better-fetch/fetch": "1.1.12",
"better-auth": "workspace:*",
"better-sqlite3": "^11.5.0",
"expo-constants": "~16.0.2",
"expo-crypto": "^13.0.2",
@@ -37,5 +37,8 @@
},
"peerDependencies": {
"better-auth": "workspace:*"
},
"dependencies": {
"zod": "^3.23.8"
}
}

View File

@@ -104,7 +104,7 @@ export const expoClient = (opts?: ExpoClientOptions) => {
}
return {
id: "expo",
getActions(_, $store) {
getActions($fetch, $store) {
if (Platform.OS === "web") return {};
store = $store;
const localSession = storage.getItem(cookieName);
@@ -114,7 +114,15 @@ export const expoClient = (opts?: ExpoClientOptions) => {
error: null,
isPending: false,
});
return {};
return {
signInWithIdToken: async (idToken: string) => {
const { data, error } = await $fetch("/sign-in/id-token", {
method: "POST",
body: JSON.stringify({ idToken }),
});
return { data, error };
},
};
},
fetchPlugins: [
{

View File

@@ -1,4 +1,7 @@
import { betterFetch } from "@better-fetch/fetch";
import type { BetterAuthPlugin } from "better-auth";
import { createAuthEndpoint } from "better-auth/api";
import { z } from "zod";
export const expo = () => {
return {

4
pnpm-lock.yaml generated
View File

@@ -1756,6 +1756,10 @@ importers:
version: 1.6.0(@types/node@22.8.6)(happy-dom@15.8.0)(lightningcss@1.27.0)(terser@5.36.0)
packages/expo:
dependencies:
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies:
'@better-fetch/fetch':
specifier: 1.1.12