better-auth
Version:
The most comprehensive authentication framework for TypeScript.
336 lines (335 loc) • 15.1 kB
JavaScript
import { isAPIError } from "../utils/is-api-error.mjs";
import { parseAdditionalUserInputFromProviderProfile } from "../db/schema.mjs";
import { setAccountCookie } from "../cookies/session-store.mjs";
import { assertValidUserInfo } from "../utils/validate-user-info.mjs";
import { OAUTH_CALLBACK_ERROR_CODES, redirectOnError } from "./errors.mjs";
import { setTokenUtil } from "./utils.mjs";
import { createEmailVerificationToken } from "../api/routes/email-verification.mjs";
import { queueAfterTransactionHook, runWithTransaction } from "@better-auth/core/context";
import { isDevelopment } from "@better-auth/core/env";
import { APIError } from "@better-auth/core/error";
//#region src/oauth2/link-account.ts
async function handleOAuthUserInfo(c, opts) {
const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = opts;
const source = opts.source ?? {
method: "oauth",
oauth: { providerId: account.providerId }
};
const requireExactAccountBinding = !!opts.selectedUser || opts.requireExactAccountBinding === true;
let pendingAccountCookie = null;
const accountOwner = await c.context.internalAdapter.findAccountOwnerByKey({
issuer: account.issuer,
accountId: account.accountId
}).catch((e) => {
c.context.logger.error("Better auth was unable to query your database.\nError: ", e);
redirectOnError(c, c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`, "internal_server_error");
});
if (accountOwner?.kind === "orphaned") {
c.context.logger.error("OAuth account references a missing user. Repair the account before retrying authentication.");
return {
error: "unable to link account",
data: null,
isRegister: false
};
}
const dbUser = await (async () => {
if (accountOwner?.kind === "owned") {
if (opts.selectedUser && accountOwner.user.id !== opts.selectedUser.userId) throw new APIError("CONFLICT", {
code: "account_ownership_conflict",
message: "Account is already linked to another user"
});
if (requireExactAccountBinding && accountOwner.account.providerId !== account.providerId) throw new APIError("CONFLICT", {
code: "account_provider_conflict",
message: "Account is already linked through another provider"
});
return {
user: accountOwner.user,
linkedAccount: accountOwner.account,
accounts: [accountOwner.account]
};
}
if (opts.selectedUser) {
const selectedUser = await c.context.internalAdapter.findUserById(opts.selectedUser.userId);
if (!selectedUser) throw new APIError("NOT_FOUND", {
code: "user_not_found",
message: "User not found"
});
return {
user: selectedUser,
linkedAccount: null,
accounts: []
};
}
const emailMatch = await c.context.internalAdapter.findUserByEmail(userInfo.email.toLowerCase(), { includeAccounts: true });
if (!emailMatch) return null;
return {
user: emailMatch.user,
linkedAccount: null,
accounts: emailMatch.accounts
};
})().catch((e) => {
if (isAPIError(e)) throw e;
c.context.logger.error("Better auth was unable to query your database.\nError: ", e);
redirectOnError(c, c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`, "internal_server_error");
});
let user = dbUser?.user;
const isRegister = !user;
if (dbUser) {
const linkedAccount = dbUser.linkedAccount ?? dbUser.accounts.find((acc) => acc.issuer === account.issuer && acc.accountId === account.accountId);
if (!linkedAccount) {
const accountLinking = c.context.options.account?.accountLinking;
const isTrustedProvider = opts.isTrustedProvider || opts.trustProviderByName !== false && c.context.trustedProviders.includes(account.providerId);
const requireLocalEmailVerified = accountLinking?.requireLocalEmailVerified ?? true;
if (!opts.selectedUser && (!isTrustedProvider && !userInfo.emailVerified || requireLocalEmailVerified && !dbUser.user.emailVerified || accountLinking?.enabled === false || accountLinking?.disableImplicitLinking === true)) {
if (isDevelopment()) c.context.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 {
const { id: _accountId, ...providerUserInfo } = userInfo;
await assertValidUserInfo(c, {
user: {
...providerUserInfo,
id: dbUser.user.id,
email: userInfo.email.toLowerCase()
},
source: {
...source,
action: "link-account"
}
});
const createdAccount = await c.context.internalAdapter.linkAccount({
providerId: account.providerId,
issuer: account.issuer,
accountId: account.accountId,
userId: dbUser.user.id,
accessToken: await setTokenUtil(account.accessToken, c.context),
refreshToken: await setTokenUtil(account.refreshToken, c.context),
idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: account.scope
});
if (!createdAccount) return {
error: "unable to link account",
data: null
};
if (requireExactAccountBinding && (createdAccount.issuer !== account.issuer || createdAccount.accountId !== account.accountId || createdAccount.providerId !== account.providerId || createdAccount.userId !== dbUser.user.id)) throw new APIError("CONFLICT", {
code: "account_hook_binding_conflict",
message: "Account hook changed the selected authentication binding"
});
if (c.context.options.account?.storeAccountCookie) if (opts.deferNonDatabaseWrites) pendingAccountCookie = createdAccount;
else await setAccountCookie(c, createdAccount);
} catch (e) {
if (isAPIError(e)) throw e;
c.context.logger.error("Unable to link account", e);
return {
error: "unable to link account",
data: null
};
}
if (!opts.selectedUser && userInfo.emailVerified && !dbUser.user.emailVerified && userInfo.email.toLowerCase() === dbUser.user.email) await c.context.internalAdapter.updateUser(dbUser.user.id, { emailVerified: true });
if (!opts.selectedUser) user = await applyUpdateUserInfoOnLink(c, dbUser.user.id, userInfo) ?? user;
} else {
const { id: _accountId, ...providerUserInfo } = userInfo;
await assertValidUserInfo(c, {
user: {
...providerUserInfo,
id: dbUser.user.id,
email: userInfo.email.toLowerCase()
},
source: {
...source,
action: "sign-in"
}
});
/**
* `scope` intentionally omitted. Updated only via linkSocial.
*
* @see {@link Account.scope}
*/
const freshTokens = c.context.options.account?.updateAccountOnSignIn !== false ? Object.fromEntries(Object.entries({
providerId: account.providerId,
idToken: account.idToken,
accessToken: await setTokenUtil(account.accessToken, c.context),
refreshToken: await setTokenUtil(account.refreshToken, c.context),
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt
}).filter(([_, value]) => value !== void 0)) : {};
if (c.context.options.account?.storeAccountCookie) {
const accountCookie = {
...linkedAccount,
...freshTokens
};
if (opts.deferNonDatabaseWrites) pendingAccountCookie = accountCookie;
else await setAccountCookie(c, accountCookie);
}
if (Object.keys(freshTokens).length > 0) {
const updatedAccount = await c.context.internalAdapter.updateAccount(linkedAccount.id, freshTokens);
if (!updatedAccount) return {
error: "unable to update account",
data: null
};
if (requireExactAccountBinding && (updatedAccount.issuer !== account.issuer || updatedAccount.accountId !== account.accountId || updatedAccount.providerId !== account.providerId || updatedAccount.userId !== dbUser.user.id)) throw new APIError("CONFLICT", {
code: "account_hook_binding_conflict",
message: "Account hook changed the selected authentication binding"
});
if (opts.deferNonDatabaseWrites && pendingAccountCookie) pendingAccountCookie = updatedAccount;
}
if (!opts.selectedUser && userInfo.emailVerified && !dbUser.user.emailVerified && userInfo.email.toLowerCase() === dbUser.user.email) await c.context.internalAdapter.updateUser(dbUser.user.id, { emailVerified: true });
}
if (opts.selectedUser ? opts.selectedUser.profile === "update" : overrideUserInfo) {
const { id: _id, email: _email, emailVerified: _emailVerified, name, image, ...providerProfile } = userInfo;
const additionalUserFields = parseAdditionalUserInputFromProviderProfile(c.context.options, providerProfile, "update");
const updatedUser = await c.context.internalAdapter.updateUser(dbUser.user.id, {
name,
image,
...additionalUserFields,
email: userInfo.email.toLowerCase(),
emailVerified: userInfo.email.toLowerCase() === dbUser.user.email ? dbUser.user.emailVerified || userInfo.emailVerified : userInfo.emailVerified
});
if (updatedUser == null) c.context.logger.warn("Could not update user info during OAuth sign in; preserving existing user for session.");
if (opts.selectedUser && updatedUser && updatedUser.id !== opts.selectedUser.userId) throw new APIError("CONFLICT", {
code: "user_hook_selection_conflict",
message: "User hook changed the selected user"
});
user = updatedUser ?? user;
}
} else {
if (disableSignUp) return {
error: "signup disabled",
data: null,
isRegister: false
};
try {
const { id: _id, email: _email, emailVerified: _emailVerified, name, image, ...providerProfile } = userInfo;
const additionalUserFields = parseAdditionalUserInputFromProviderProfile(c.context.options, providerProfile, "create");
const accountData = {
accessToken: await setTokenUtil(account.accessToken, c.context),
refreshToken: await setTokenUtil(account.refreshToken, c.context),
idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: account.scope,
providerId: account.providerId,
issuer: account.issuer,
accountId: account.accountId
};
const { createdUser, createdAccount } = await runWithTransaction(c.context.adapter, async () => {
const createdUser = await c.context.internalAdapter.createUser({
name,
image,
...additionalUserFields,
email: userInfo.email.toLowerCase(),
emailVerified: userInfo.emailVerified
}, source);
return {
createdUser,
createdAccount: await c.context.internalAdapter.createAccount({
...accountData,
userId: createdUser.id
})
};
});
if (requireExactAccountBinding && (createdAccount.issuer !== account.issuer || createdAccount.accountId !== account.accountId || createdAccount.providerId !== account.providerId || createdAccount.userId !== createdUser.id)) throw new APIError("CONFLICT", {
code: "account_hook_binding_conflict",
message: "Account hook changed the selected authentication binding"
});
user = createdUser;
if (c.context.options.account?.storeAccountCookie) if (opts.deferNonDatabaseWrites) pendingAccountCookie = createdAccount;
else await setAccountCookie(c, createdAccount);
} catch (e) {
if (isAPIError(e)) throw e;
c.context.logger.error("Unable to create OAuth user", e);
return {
error: "unable to create user",
data: null,
isRegister: false
};
}
}
if (!user) return {
error: "unable to create user",
data: null,
isRegister: false
};
const requireEmailVerification = c.context.socialProviders.find((p) => p.id === account.providerId)?.options?.requireEmailVerification;
if (isRegister && !user.emailVerified && (c.context.options.emailVerification?.sendOnSignUp ?? requireEmailVerification)) await dispatchVerificationEmail(c, user, callbackURL, opts.deferNonDatabaseWrites);
if (requireEmailVerification && !user.emailVerified) {
if (!isRegister && c.context.options.emailVerification?.sendOnSignIn) await dispatchVerificationEmail(c, user, callbackURL, opts.deferNonDatabaseWrites);
return {
error: OAUTH_CALLBACK_ERROR_CODES.EMAIL_NOT_VERIFIED,
data: null,
isRegister
};
}
const session = await c.context.internalAdapter.createSession(user.id, void 0, void 0, void 0, { deferSecondaryStorageWrites: opts.deferNonDatabaseWrites === true });
if (!session) return {
error: "unable to create session",
data: null,
isRegister: false
};
if (requireExactAccountBinding && session.userId !== (opts.selectedUser?.userId ?? user.id)) throw new APIError("CONFLICT", {
code: "session_hook_user_conflict",
message: "Session hook changed the selected user"
});
return {
data: {
session,
user
},
error: null,
isRegister,
accountCookie: pendingAccountCookie
};
}
async function dispatchVerificationEmail(c, user, callbackURL, deferUntilAfterTransaction) {
const sendVerificationEmail = c.context.options.emailVerification?.sendVerificationEmail;
if (!sendVerificationEmail) return;
const send = async () => {
try {
const token = await createEmailVerificationToken(c.context.secret, user.email, void 0, c.context.options.emailVerification?.expiresIn);
const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(callbackURL || "/")}`;
await c.context.runInBackgroundOrAwait(sendVerificationEmail({
user,
url,
token
}, c.request));
} catch (e) {
c.context.logger.error("Failed to send OAuth verification email", e);
}
};
if (deferUntilAfterTransaction) await queueAfterTransactionHook(send);
else await send();
}
/**
* Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
* copy the freshly linked provider's profile onto the local user, matching the
* field set persisted on sign-up. The local `email` and `emailVerified` are
* never changed, so a link can't rebind the account's identity, and
* `updateUser` drops `undefined` fields, so a provider that omits one leaves
* the existing column intact.
*
* Returns the updated user so a caller that issues a session can seed the
* cookie cache with the fresh row. Returns `undefined` when the policy is
* disabled or the update fails: a failed profile sync must not abort the link.
*/
async function applyUpdateUserInfoOnLink(c, userId, userInfo) {
if (c.context.options.account?.accountLinking?.updateUserInfoOnLink !== true) return;
try {
const { email: _email, emailVerified: _emailVerified, name, image, ...providerProfile } = userInfo;
const additionalUserFields = parseAdditionalUserInputFromProviderProfile(c.context.options, providerProfile, "update");
return await c.context.internalAdapter.updateUser(userId, {
name,
image,
...additionalUserFields
});
} catch (e) {
c.context.logger.warn("Could not update user info on account link", e);
return;
}
}
//#endregion
export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };