better-auth
Version:
The most comprehensive authentication framework for TypeScript.
264 lines (263 loc) • 10.3 kB
JavaScript
import { isAPIError } from "../../utils/is-api-error.mjs";
import { setSessionCookie } from "../../cookies/index.mjs";
import { assertValidUserInfo } from "../../utils/validate-user-info.mjs";
import { getAwaitableValue } from "../../context/helpers.mjs";
import { resolveOAuthAccountKey, toOAuthProfileRecord } from "../../oauth2/account-key.mjs";
import { OAUTH_CALLBACK_ERROR_CODES, missingEmailLogMessage } from "../../oauth2/errors.mjs";
import { getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils.mjs";
import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
import { generateIdTokenNonce, generateState, parseState } from "../../oauth2/state.mjs";
import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
import { mergeScopes } from "@better-auth/core/oauth2";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { appendQueryParams } from "@better-auth/core/utils/url";
import { createAuthEndpoint } from "@better-auth/core/api";
import * as z from "zod";
//#region src/api/routes/callback.ts
const schema = z.object({
code: z.string().optional(),
error: z.string().optional(),
device_id: z.string().optional(),
error_description: z.string().optional(),
state: z.string().optional(),
user: z.string().optional(),
iss: z.string().optional()
});
const callbackOAuth = createAuthEndpoint("/callback/:id", {
method: ["GET", "POST"],
operationId: "handleOAuthCallback",
body: schema.optional(),
query: schema.optional(),
metadata: {
...HIDE_METADATA,
allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
}
}, async (c) => {
let queryOrBody;
const defaultErrorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
if (c.method === "POST") {
const postData = c.body ? schema.parse(c.body) : {};
const queryData = c.query ? schema.parse(c.query) : {};
const mergedData = schema.parse({
...postData,
...queryData
});
const params = new URLSearchParams();
for (const [key, value] of Object.entries(mergedData)) if (value !== void 0 && value !== null) params.set(key, String(value));
const redirectURL = `${c.context.baseURL}/callback/${c.params.id}?${params.toString()}`;
throw c.redirect(redirectURL);
}
try {
if (c.method === "GET") queryOrBody = schema.parse(c.query);
else if (c.method === "POST") queryOrBody = schema.parse(c.body);
else throw new Error("Unsupported method");
} catch (e) {
c.context.logger.error("INVALID_CALLBACK_REQUEST", e);
const redirectURL = appendQueryParams(defaultErrorURL, new URLSearchParams({ error: "invalid_callback_request" }));
throw c.redirect(redirectURL);
}
const { code, error, state, error_description, device_id, user: userData, iss } = queryOrBody;
if (state === void 0 && code) {
const provider = await getAwaitableValue(c.context.socialProviders, { value: c.params.id });
if (provider?.allowIdpInitiated) {
const idTokenNonce = generateIdTokenNonce(provider);
const { state: freshState, codeVerifier } = await generateState(c, { idTokenNonce });
const authUrl = await provider.createAuthorizationURL({
state: freshState,
codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`
});
throw c.redirect(authUrl.toString());
}
}
if (!state) {
c.context.logger.error("State not found", error);
const redirectURL = appendQueryParams(defaultErrorURL, new URLSearchParams({ error: "state_not_found" }));
throw c.redirect(redirectURL);
}
const { codeVerifier, callbackURL, link, errorURL, newUserURL, requestSignUp, idTokenNonce } = await parseState(c);
function redirectOnError(error, description) {
const baseURL = errorURL ?? defaultErrorURL;
const params = new URLSearchParams({ error });
if (description) params.set("error_description", description);
const redirectURL = appendQueryParams(baseURL, params);
throw c.redirect(redirectURL);
}
if (error) redirectOnError(error, error_description);
if (!code) {
c.context.logger.warn("Code not found");
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.NO_CODE);
}
const provider = await getAwaitableValue(c.context.socialProviders, { value: c.params.id });
if (!provider) {
c.context.logger.warn("OAuth provider not found", { providerId: c.params.id });
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.PROVIDER_NOT_FOUND);
}
if (iss && provider.issuer && iss !== provider.issuer) {
c.context.logger.error("OAuth issuer mismatch", {
expected: provider.issuer,
received: iss
});
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.ISSUER_MISMATCH);
}
if (provider.requiresIdTokenNonce && !idTokenNonce) {
c.context.logger.error("OAuth id_token nonce binding required but no expected nonce was found in state", { providerId: provider.id });
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.NONCE_BINDING_MISSING);
}
let tokens;
try {
tokens = await provider.validateAuthorizationCode({
code,
codeVerifier,
deviceId: device_id,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`
});
} catch (e) {
c.context.logger.error("", e);
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.INVALID_CODE);
}
if (!tokens) throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.INVALID_CODE);
const parsedUserData = userData ? safeJSONParse(userData) : null;
const providerResult = await provider.getUserInfo({
...tokens,
...idTokenNonce ? { expectedIdTokenNonce: idTokenNonce } : {},
/**
* The user object from the provider
* This is only available for some providers like Apple
*/
user: parsedUserData ?? void 0
});
if (!providerResult?.user) {
c.context.logger.error("Unable to get user info");
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_GET_USER_INFO);
}
const userInfo = providerResult.user;
const providerProfile = toOAuthProfileRecord(providerResult.data);
let accountKey;
try {
accountKey = await resolveOAuthAccountKey(provider, tokens, providerResult.data);
} catch (error) {
c.context.logger.error("Unable to derive provider account identity", {
providerId: provider.id,
error
});
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_GET_USER_INFO);
}
const { accountId } = accountKey;
if (!callbackURL) {
c.context.logger.error("No callback URL found");
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.NO_CALLBACK_URL);
}
if (link) {
try {
await assertValidUserInfo(c, {
user: {
...userInfo,
id: link.userId,
email: userInfo.email ?? void 0
},
source: {
action: "link-account",
method: "oauth",
oauth: {
providerId: provider.id,
profile: providerProfile
}
}
});
} catch (e) {
if (isAPIError(e) && e.body?.code) throw redirectOnError(e.body.code, e.body.message);
throw e;
}
if (!c.context.trustedProviders.includes(provider.id) && !userInfo.emailVerified || c.context.options.account?.accountLinking?.enabled === false) {
c.context.logger.error("Unable to link account - untrusted provider");
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
}
if (userInfo.email?.toLowerCase() !== link.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH);
const existingAccount = await c.context.internalAdapter.findAccountByKey(accountKey);
if (existingAccount) {
if (existingAccount.userId.toString() !== link.userId.toString()) return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER);
const mergedScope = mergeScopes(existingAccount.scope, tokens.scopes);
const updateData = Object.fromEntries(Object.entries({
providerId: provider.id,
accessToken: await setTokenUtil(tokens.accessToken, c.context),
refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
idToken: tokens.idToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
scope: mergedScope || void 0
}).filter(([_, value]) => value !== void 0));
await c.context.internalAdapter.updateAccount(existingAccount.id, updateData);
} else if (!await c.context.internalAdapter.createAccount({
userId: link.userId,
providerId: provider.id,
...accountKey,
...tokens,
accessToken: await setTokenUtil(tokens.accessToken, c.context),
refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
scope: tokens.scopes?.join(",")
})) return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
let toRedirectTo;
try {
toRedirectTo = callbackURL.toString();
} catch {
toRedirectTo = callbackURL;
}
throw c.redirect(toRedirectTo);
}
if (!userInfo.email) {
c.context.logger.error(missingEmailLogMessage(provider.id));
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_NOT_FOUND);
}
const accountData = {
providerId: provider.id,
...accountKey,
...tokens,
scope: tokens.scopes?.join(",")
};
let result;
try {
result = await handleOAuthUserInfo(c, {
userInfo: {
...userInfo,
id: accountId,
email: userInfo.email,
name: userInfo.name || ""
},
account: accountData,
callbackURL,
disableSignUp: provider.disableImplicitSignUp && !requestSignUp || provider.options?.disableSignUp,
overrideUserInfo: provider.options?.overrideUserInfoOnSignIn,
source: {
method: "oauth",
oauth: {
providerId: provider.id,
profile: providerProfile
}
}
});
} catch (e) {
if (isAPIError(e) && e.body?.code) redirectOnError(e.body.code, e.body.message);
throw e;
}
if (result.error) {
c.context.logger.error(result.error.split(" ").join("_"));
return redirectOnError(result.error.split(" ").join("_"));
}
const { session, user } = result.data;
await setSessionCookie(c, {
session,
user
});
let toRedirectTo;
try {
toRedirectTo = (result.isRegister ? newUserURL || callbackURL : callbackURL).toString();
} catch {
toRedirectTo = result.isRegister ? newUserURL || callbackURL : callbackURL;
}
throw c.redirect(toRedirectTo);
});
//#endregion
export { callbackOAuth };