better-auth
Version:
The most comprehensive authentication framework for TypeScript.
626 lines (625 loc) • 23.4 kB
JavaScript
import { shouldBindAccountCookieToSessionUser } from "../../context/store-capabilities.mjs";
import { parseAccountOutput } from "../../db/schema.mjs";
import { getAccountCookie, setAccountCookie } from "../../cookies/session-store.mjs";
import { getAwaitableValue } from "../../context/helpers.mjs";
import { resolveOAuthAccountKeyForAPI } from "../../oauth2/account-key.mjs";
import { missingEmailLogMessage } from "../../oauth2/errors.mjs";
import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils.mjs";
import { applyUpdateUserInfoOnLink } from "../../oauth2/link-account.mjs";
import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
import { freshSessionMiddleware, getSessionFromCtx, isStateful, sessionMiddleware } from "./session.mjs";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
import { SocialProviderListEnum } from "@better-auth/core/social-providers";
import { createAuthEndpoint } from "@better-auth/core/api";
import * as z from "zod";
//#region src/api/routes/account.ts
function parseStoredScopes(scope) {
if (!scope) return [];
return scope.split(",").map((s) => s.trim()).filter(Boolean);
}
const listUserAccounts = createAuthEndpoint("/list-accounts", {
method: "GET",
use: [sessionMiddleware],
metadata: { openapi: {
operationId: "listUserAccounts",
description: "List all accounts linked to the user",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
providerId: { type: "string" },
createdAt: {
type: "string",
format: "date-time"
},
updatedAt: {
type: "string",
format: "date-time"
},
issuer: { type: "string" },
accountId: { type: "string" },
userId: { type: "string" },
scopes: {
type: "array",
items: { type: "string" }
}
},
required: [
"id",
"providerId",
"createdAt",
"updatedAt",
"issuer",
"accountId",
"userId",
"scopes"
]
}
} } }
} }
} }
}, async (c) => {
const session = c.context.session;
const accounts = await c.context.internalAdapter.findAccounts(session.user.id);
return c.json(accounts.map((a) => {
const { scope, ...parsed } = parseAccountOutput(c.context.options, a);
return {
...parsed,
scopes: parseStoredScopes(scope)
};
}));
});
const linkSocialAccount = createAuthEndpoint("/link-social", {
method: "POST",
requireHeaders: true,
body: z.object({
/**
* Callback URL to redirect to after the user has signed in.
*/
callbackURL: z.string().meta({ description: "The URL to redirect to after the user has signed in" }).optional(),
/**
* OAuth2 provider to use
*/
provider: SocialProviderListEnum,
/**
* ID Token for direct authentication without redirect
*/
idToken: z.object({
token: z.string(),
nonce: z.string().optional(),
accessToken: z.string().optional(),
refreshToken: z.string().optional()
}).optional(),
/**
* Whether to allow sign up for new users
*/
requestSignUp: z.boolean().optional(),
/**
* Additional scopes to request when linking the account.
* This is useful for requesting additional permissions when
* linking a social account compared to the initial authentication.
*/
scopes: z.array(z.string()).meta({ description: "Additional scopes to request from the provider" }).optional(),
/**
* The URL to redirect to if there is an error during the link process.
*/
errorCallbackURL: z.string().meta({ description: "The URL to redirect to if there is an error during the link process" }).optional(),
/**
* Disable automatic redirection to the provider
*
* This is useful if you want to handle the redirection
* yourself like in a popup or a different tab.
*/
disableRedirect: z.boolean().meta({ description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself" }).optional(),
/**
* The login hint to forward to the provider authorization endpoint.
*/
loginHint: z.string().meta({ description: "The login hint to use for the authorization code request" }).optional(),
/**
* Extra query parameters to append to the provider authorization URL.
* Reserved OAuth keys (state, client_id, redirect_uri, response_type,
* code_challenge, code_challenge_method, nonce, scope) are rejected.
*/
additionalParams: additionalAuthorizationParamsSchema,
/**
* Any additional data to pass through the oauth flow.
*/
additionalData: z.record(z.string(), z.any()).optional()
}),
use: [sessionMiddleware],
metadata: { openapi: {
description: "Link a social account to the user",
operationId: "linkSocialAccount",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "object",
properties: {
url: {
type: "string",
description: "The authorization URL to redirect the user to"
},
redirect: {
type: "boolean",
description: "Indicates if the user should be redirected to the authorization URL"
},
status: { type: "boolean" }
},
required: ["redirect"]
} } }
} }
} }
}, async (c) => {
const session = c.context.session;
const provider = await getAwaitableValue(c.context.socialProviders, { value: c.body.provider });
if (!provider) {
c.context.logger.error("Provider not found. Make sure to add the provider in your auth config", { provider: c.body.provider });
throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.PROVIDER_NOT_FOUND);
}
if (c.body.idToken) {
if (!supportsIdTokenSignIn(provider)) {
c.context.logger.error("Provider does not support id token verification", { provider: c.body.provider });
throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED);
}
const { token, nonce } = c.body.idToken;
if (!await verifyProviderIdToken(provider, token, nonce, c)) {
c.context.logger.warn("Invalid id token", { provider: c.body.provider });
throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_TOKEN);
}
const linkingUserInfo = await provider.getUserInfo({
idToken: token,
accessToken: c.body.idToken.accessToken,
refreshToken: c.body.idToken.refreshToken
});
if (!linkingUserInfo || !linkingUserInfo?.user) {
c.context.logger.error("Failed to get user info", { provider: c.body.provider });
throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO);
}
if (!linkingUserInfo.user.email) {
c.context.logger.error(missingEmailLogMessage(c.body.provider, { source: "id_token" }), { provider: c.body.provider });
throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.USER_EMAIL_NOT_FOUND);
}
const accountKey = await resolveOAuthAccountKeyForAPI(provider, {
idToken: token,
accessToken: c.body.idToken.accessToken,
refreshToken: c.body.idToken.refreshToken
}, linkingUserInfo.data);
const linkedAccount = await c.context.internalAdapter.findAccountByKey(accountKey);
if (linkedAccount?.userId === session.user.id) {
const updateData = Object.fromEntries(Object.entries({
providerId: provider.id,
accessToken: await setTokenUtil(c.body.idToken.accessToken, c.context),
idToken: token,
refreshToken: await setTokenUtil(c.body.idToken.refreshToken, c.context)
}).filter(([_, value]) => value !== void 0));
await c.context.internalAdapter.updateAccount(linkedAccount.id, updateData);
await applyUpdateUserInfoOnLink(c, session.user.id, linkingUserInfo.user);
return c.json({
url: "",
status: true,
redirect: false
});
}
if (linkedAccount) throw APIError.from("CONFLICT", BASE_ERROR_CODES.SOCIAL_ACCOUNT_ALREADY_LINKED);
if (!c.context.trustedProviders.includes(provider.id) && !linkingUserInfo.user.emailVerified || c.context.options.account?.accountLinking?.enabled === false) throw APIError.from("UNAUTHORIZED", {
message: "Account not linked - linking not allowed",
code: "LINKING_NOT_ALLOWED"
});
if (linkingUserInfo.user.email?.toLowerCase() !== session.user.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) throw APIError.from("UNAUTHORIZED", {
message: "Account not linked - different emails not allowed",
code: "LINKING_DIFFERENT_EMAILS_NOT_ALLOWED"
});
try {
await c.context.internalAdapter.createAccount({
userId: session.user.id,
providerId: provider.id,
...accountKey,
accessToken: await setTokenUtil(c.body.idToken.accessToken, c.context),
idToken: token,
refreshToken: await setTokenUtil(c.body.idToken.refreshToken, c.context)
});
} catch {
throw APIError.from("EXPECTATION_FAILED", {
message: "Account not linked - unable to create account",
code: "LINKING_FAILED"
});
}
await applyUpdateUserInfoOnLink(c, session.user.id, linkingUserInfo.user);
return c.json({
url: "",
status: true,
redirect: false
});
}
const idTokenNonce = generateIdTokenNonce(provider);
const state = await generateState(c, {
link: {
userId: session.user.id,
email: session.user.email
},
additionalData: c.body.additionalData,
idTokenNonce
});
const url = await provider.createAuthorizationURL({
state: state.state,
codeVerifier: state.codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
additionalParams: c.body.additionalParams
});
if (!c.body.disableRedirect) c.setHeader("Location", url.toString());
return c.json({
url: url.toString(),
redirect: !c.body.disableRedirect
});
});
const unlinkAccount = createAuthEndpoint("/unlink-account", {
method: "POST",
body: z.object({ accountId: z.string().meta({ description: "The Better Auth account ID to unlink" }) }),
use: [freshSessionMiddleware],
metadata: { openapi: {
description: "Unlink an account",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "object",
properties: { status: { type: "boolean" } }
} } }
} }
} }
}, async (ctx) => {
const { accountId } = ctx.body;
const accounts = await ctx.context.internalAdapter.findAccounts(ctx.context.session.user.id);
if (accounts.length === 1 && !ctx.context.options.account?.accountLinking?.allowUnlinkingAll) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT);
const accountExist = accounts.find((account) => account.id === accountId);
if (!accountExist) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
await ctx.context.internalAdapter.deleteAccount(accountExist.id);
return ctx.json({ status: true });
});
/**
* Resolves the user id an account-token operation should act on.
*
* A caller reaching the server over HTTP (a request or session headers are
* present) must have a valid session, and that session's user always wins.
* A trusted server-side `auth.api` caller with no session may instead name a
* `userId` directly. Throws `UNAUTHORIZED` when an HTTP caller is
* unauthenticated, and `USER_ID_OR_SESSION_REQUIRED` when neither a session
* nor a `userId` is available.
*
* When a durable store is authoritative, bypasses the cookie cache: these
* routes mint or refresh provider access tokens, so a server-side session
* revocation must take effect immediately rather than waiting for the cached
* cookie to expire. DB-less deployments keep the session in the cookie itself,
* so the cache is left in place for them.
*/
async function resolveUserId(ctx, userId) {
const session = await getSessionFromCtx(ctx, { disableCookieCache: isStateful(ctx) });
if (!session && (ctx.request || ctx.headers)) throw ctx.error("UNAUTHORIZED");
const resolvedUserId = session?.user?.id || userId;
if (!resolvedUserId) throw APIError.from("BAD_REQUEST", {
message: "Either userId or session is required",
code: "USER_ID_OR_SESSION_REQUIRED"
});
return resolvedUserId;
}
const accountSelectionSchema = z.union([z.strictObject({
accountId: z.string().meta({ description: "The Better Auth account ID" }),
userId: z.string().meta({ description: "The user ID associated with the account" }).optional()
}), z.strictObject({
useAccountCookie: z.literal(true).meta({ description: "Select the current OAuth account from its signed cookie" }),
userId: z.string().meta({ description: "The user ID associated with the account" }).optional()
})]);
function matchesAccountSelection(ctx, account, { resolvedUserId, selection }) {
return (!shouldBindAccountCookieToSessionUser(ctx.context.options) || account.userId === resolvedUserId) && ("accountId" in selection ? account.id === selection.accountId : true);
}
/**
* Resolves an account from exactly one explicit source.
*
* A local account ID is resolved from the database. A signed account cookie is
* used only when the caller explicitly selects it, which keeps stateless OAuth
* flows usable without letting cached cookie data satisfy a row-ID lookup.
*/
async function resolveUserAccount(ctx, { resolvedUserId, selection }) {
if ("accountId" in selection) {
const account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((candidate) => candidate.id === selection.accountId);
if (account) return {
account,
accountCookie: null
};
} else if (ctx.context.options.account?.storeAccountCookie) {
const accountCookie = await getAccountCookie(ctx);
if (accountCookie && matchesAccountSelection(ctx, accountCookie, {
resolvedUserId,
selection
})) return {
account: accountCookie,
accountCookie
};
}
throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
}
/**
* Fetches a currently-valid access token for a user's provider account,
* refreshing and persisting it when it is within five seconds of expiry.
* Shared by the `/get-access-token` endpoint and `/account-info` so both
* resolve and refresh tokens through one path.
*/
async function getValidAccessToken(ctx, { resolvedUserId, selection, account: resolvedAccount }) {
const account = resolvedAccount ?? (await resolveUserAccount(ctx, {
resolvedUserId,
selection
})).account;
if (!matchesAccountSelection(ctx, account, {
resolvedUserId,
selection
})) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
const provider = await getAwaitableValue(ctx.context.socialProviders, { value: account.providerId });
if (!provider) throw APIError.from("BAD_REQUEST", {
message: `Provider ${account.providerId} is not supported.`,
code: "PROVIDER_NOT_SUPPORTED"
});
try {
let newTokens = null;
const accessTokenExpired = account.accessTokenExpiresAt && new Date(account.accessTokenExpiresAt).getTime() - Date.now() < 5e3;
if (account.refreshToken && accessTokenExpired && provider.refreshAccessToken) {
const refreshToken = await decryptOAuthToken(account.refreshToken, ctx.context);
newTokens = await provider.refreshAccessToken(refreshToken, ctx);
const updatedData = {
accessToken: await setTokenUtil(newTokens?.accessToken, ctx.context),
accessTokenExpiresAt: newTokens?.accessTokenExpiresAt,
refreshToken: newTokens?.refreshToken ? await setTokenUtil(newTokens.refreshToken, ctx.context) : account.refreshToken,
refreshTokenExpiresAt: newTokens?.refreshTokenExpiresAt ?? account.refreshTokenExpiresAt,
idToken: newTokens?.idToken || account.idToken
};
let updatedAccount = null;
if (account.id) updatedAccount = await ctx.context.internalAdapter.updateAccount(account.id, updatedData);
if (ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, {
...account,
...updatedAccount ?? updatedData
});
}
const accessTokenExpiresAt = (() => {
if (newTokens?.accessTokenExpiresAt) {
if (typeof newTokens.accessTokenExpiresAt === "string") return new Date(newTokens.accessTokenExpiresAt);
return newTokens.accessTokenExpiresAt;
}
if (account.accessTokenExpiresAt) {
if (typeof account.accessTokenExpiresAt === "string") return new Date(account.accessTokenExpiresAt);
return account.accessTokenExpiresAt;
}
})();
return {
accessToken: newTokens?.accessToken ?? await decryptOAuthToken(account.accessToken ?? "", ctx.context),
accessTokenExpiresAt,
scopes: parseStoredScopes(account.scope),
idToken: newTokens?.idToken ?? account.idToken ?? void 0
};
} catch (_error) {
throw APIError.from("BAD_REQUEST", {
message: "Failed to get a valid access token",
code: "FAILED_TO_GET_ACCESS_TOKEN"
});
}
}
const getAccessToken = createAuthEndpoint("/get-access-token", {
method: "POST",
body: accountSelectionSchema,
metadata: { openapi: {
description: "Get a valid access token, doing a refresh if needed",
responses: {
200: {
description: "A Valid access token",
content: { "application/json": { schema: {
type: "object",
properties: {
tokenType: { type: "string" },
idToken: { type: "string" },
accessToken: { type: "string" },
accessTokenExpiresAt: {
type: "string",
format: "date-time"
}
}
} } }
},
400: { description: "Invalid refresh token or provider configuration" }
}
} }
}, async (ctx) => {
const { userId } = ctx.body;
const tokens = await getValidAccessToken(ctx, {
resolvedUserId: await resolveUserId(ctx, userId),
selection: ctx.body
});
return ctx.json(tokens);
});
const refreshToken = createAuthEndpoint("/refresh-token", {
method: "POST",
body: accountSelectionSchema,
metadata: { openapi: {
description: "Refresh the access token using a refresh token",
responses: {
200: {
description: "Access token refreshed successfully",
content: { "application/json": { schema: {
type: "object",
properties: {
tokenType: { type: "string" },
idToken: { type: "string" },
accessToken: { type: "string" },
refreshToken: { type: "string" },
accessTokenExpiresAt: {
type: "string",
format: "date-time"
},
refreshTokenExpiresAt: {
type: "string",
format: "date-time"
}
}
} } }
},
400: { description: "Invalid refresh token or provider configuration" }
}
} }
}, async (ctx) => {
const { userId } = ctx.body;
const { account, accountCookie } = await resolveUserAccount(ctx, {
resolvedUserId: await resolveUserId(ctx, userId),
selection: ctx.body
});
const provider = await getAwaitableValue(ctx.context.socialProviders, { value: account.providerId });
if (!provider) throw APIError.from("BAD_REQUEST", {
message: `Provider ${account.providerId} is not supported.`,
code: "PROVIDER_NOT_SUPPORTED"
});
if (!provider.refreshAccessToken) throw APIError.from("BAD_REQUEST", {
message: `Provider ${account.providerId} does not support token refreshing.`,
code: "TOKEN_REFRESH_NOT_SUPPORTED"
});
const refreshToken = account.refreshToken ?? void 0;
if (!refreshToken) throw APIError.from("BAD_REQUEST", {
message: "Refresh token not found",
code: "REFRESH_TOKEN_NOT_FOUND"
});
try {
const decryptedRefreshToken = await decryptOAuthToken(refreshToken, ctx.context);
const tokens = await provider.refreshAccessToken(decryptedRefreshToken, ctx);
const resolvedRefreshToken = tokens.refreshToken ? await setTokenUtil(tokens.refreshToken, ctx.context) : refreshToken;
const resolvedRefreshTokenExpiresAt = tokens.refreshTokenExpiresAt ?? account.refreshTokenExpiresAt;
const updatedTokenData = {
accessToken: await setTokenUtil(tokens.accessToken, ctx.context),
refreshToken: resolvedRefreshToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: resolvedRefreshTokenExpiresAt,
idToken: tokens.idToken || account.idToken
};
let updatedAccount = null;
if (account.id)
/**
* `scope` intentionally omitted. Refresh response may be narrower.
*
* @see {@link Account.scope}
*/
updatedAccount = await ctx.context.internalAdapter.updateAccount(account.id, updatedTokenData);
if (accountCookie?.id === account.id && ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, {
...accountCookie,
...updatedAccount ?? updatedTokenData
});
const responseScope = updatedAccount?.scope ?? account.scope;
return ctx.json({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? decryptedRefreshToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: resolvedRefreshTokenExpiresAt,
scope: responseScope,
idToken: tokens.idToken || account.idToken,
providerId: account.providerId,
accountId: account.id
});
} catch (_error) {
throw APIError.from("BAD_REQUEST", {
message: "Failed to refresh access token",
code: "FAILED_TO_REFRESH_ACCESS_TOKEN"
});
}
});
const accountInfo = createAuthEndpoint("/account-info", {
method: "GET",
metadata: { openapi: {
description: "Get the account info provided by the provider",
responses: { "200": {
description: "Success",
content: { "application/json": { schema: {
type: "object",
properties: {
user: {
type: "object",
properties: {
name: { type: "string" },
email: {
type: "string",
nullable: true
},
image: { type: "string" },
emailVerified: { type: "boolean" }
},
required: ["emailVerified"]
},
account: {
type: "object",
properties: {
id: { type: "string" },
providerId: { type: "string" },
issuer: { type: "string" },
accountId: { type: "string" }
},
required: [
"id",
"providerId",
"issuer",
"accountId"
],
additionalProperties: false
},
data: {
type: "object",
properties: {},
additionalProperties: true
}
},
required: [
"user",
"data",
"account"
],
additionalProperties: false
} } }
} }
} },
query: accountSelectionSchema
}, async (ctx) => {
const { userId } = ctx.query;
const resolvedUserId = await resolveUserId(ctx, userId);
const { account } = await resolveUserAccount(ctx, {
resolvedUserId,
selection: ctx.query
});
const provider = await getAwaitableValue(ctx.context.socialProviders, { value: account.providerId });
if (!provider) throw APIError.from("BAD_REQUEST", {
message: "Account is not associated with a configured social provider.",
code: "PROVIDER_NOT_CONFIGURED"
});
const tokens = await getValidAccessToken(ctx, {
resolvedUserId,
selection: ctx.query,
account
});
if (!tokens.accessToken) throw APIError.from("BAD_REQUEST", {
message: "Access token not found",
code: "ACCESS_TOKEN_NOT_FOUND"
});
const info = await provider.getUserInfo({
...tokens,
accessToken: tokens.accessToken
});
if (!info) throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO);
return ctx.json({
...info,
account: {
id: account.id,
providerId: account.providerId,
issuer: account.issuer,
accountId: account.accountId
}
});
});
//#endregion
export { accountInfo, getAccessToken, linkSocialAccount, listUserAccounts, refreshToken, unlinkAccount };