mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-24 16:11:53 -05:00
feat: id token auth (#463)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
152
packages/better-auth/src/oauth2/link-account.ts
Normal file
152
packages/better-auth/src/oauth2/link-account.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 that’s 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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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
4
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user