better-auth
Version:
The most comprehensive authentication library for TypeScript.
1,616 lines (1,607 loc) • 117 kB
JavaScript
import { z } from 'zod';
import { deleteSessionCookie, setSessionCookie, setCookieCache } from '../cookies/index.mjs';
import { createMiddleware, createEndpoint, APIError } from 'better-call';
import '@better-auth/utils/random';
import { g as generateState, p as parseState } from './better-auth.dn8_oqOu.mjs';
import { l as logger } from './better-auth.Cqykj82J.mjs';
import { SocialProviderListEnum } from '../social-providers/index.mjs';
import { s as safeJSONParse } from './better-auth.tB5eU6EY.mjs';
import { g as getDate } from './better-auth.CW6D9eSx.mjs';
import { g as generateId } from './better-auth.BUPPRXfK.mjs';
import '@better-auth/utils/hash';
import '@noble/ciphers/chacha';
import '@noble/ciphers/utils';
import '@noble/ciphers/webcrypto';
import { base64 } from '@better-auth/utils/base64';
import { jwtVerify } from 'jose';
import '@noble/hashes/scrypt';
import '@better-auth/utils';
import '@better-auth/utils/hex';
import '@noble/hashes/utils';
import { g as generateRandomString } from './better-auth.B4Qoxdgc.mjs';
import { f as parseUserInput } from './better-auth.Cc72UxUH.mjs';
import { b as isDevelopment } from './better-auth.8zoxzg-F.mjs';
import { createHMAC } from '@better-auth/utils/hmac';
import { binary } from '@better-auth/utils/binary';
import '@better-fetch/fetch';
import 'defu';
import { s as signJWT } from './better-auth.DDEbWX-S.mjs';
import { b as getHost, c as getProtocol, g as getOrigin } from './better-auth.VTXNLFMT.mjs';
import { JWTExpired } from 'jose/errors';
const optionsMiddleware = createMiddleware(async () => {
return {};
});
const createAuthMiddleware = createMiddleware.create({
use: [
optionsMiddleware,
/**
* Only use for post hooks
*/
createMiddleware(async () => {
return {};
})
]
});
const createAuthEndpoint = createEndpoint.create({
use: [optionsMiddleware]
});
function escapeRegExpChar(char) {
if (char === "-" || char === "^" || char === "$" || char === "+" || char === "." || char === "(" || char === ")" || char === "|" || char === "[" || char === "]" || char === "{" || char === "}" || char === "*" || char === "?" || char === "\\") {
return `\\${char}`;
} else {
return char;
}
}
function escapeRegExpString(str) {
let result = "";
for (let i = 0; i < str.length; i++) {
result += escapeRegExpChar(str[i]);
}
return result;
}
function transform(pattern, separator = true) {
if (Array.isArray(pattern)) {
let regExpPatterns = pattern.map((p) => `^${transform(p, separator)}$`);
return `(?:${regExpPatterns.join("|")})`;
}
let separatorSplitter = "";
let separatorMatcher = "";
let wildcard = ".";
if (separator === true) {
separatorSplitter = "/";
separatorMatcher = "[/\\\\]";
wildcard = "[^/\\\\]";
} else if (separator) {
separatorSplitter = separator;
separatorMatcher = escapeRegExpString(separatorSplitter);
if (separatorMatcher.length > 1) {
separatorMatcher = `(?:${separatorMatcher})`;
wildcard = `((?!${separatorMatcher}).)`;
} else {
wildcard = `[^${separatorMatcher}]`;
}
}
let requiredSeparator = separator ? `${separatorMatcher}+?` : "";
let optionalSeparator = separator ? `${separatorMatcher}*?` : "";
let segments = separator ? pattern.split(separatorSplitter) : [pattern];
let result = "";
for (let s = 0; s < segments.length; s++) {
let segment = segments[s];
let nextSegment = segments[s + 1];
let currentSeparator = "";
if (!segment && s > 0) {
continue;
}
if (separator) {
if (s === segments.length - 1) {
currentSeparator = optionalSeparator;
} else if (nextSegment !== "**") {
currentSeparator = requiredSeparator;
} else {
currentSeparator = "";
}
}
if (separator && segment === "**") {
if (currentSeparator) {
result += s === 0 ? "" : currentSeparator;
result += `(?:${wildcard}*?${currentSeparator})*?`;
}
continue;
}
for (let c = 0; c < segment.length; c++) {
let char = segment[c];
if (char === "\\") {
if (c < segment.length - 1) {
result += escapeRegExpChar(segment[c + 1]);
c++;
}
} else if (char === "?") {
result += wildcard;
} else if (char === "*") {
result += `${wildcard}*?`;
} else {
result += escapeRegExpChar(char);
}
}
result += currentSeparator;
}
return result;
}
function isMatch(regexp, sample) {
if (typeof sample !== "string") {
throw new TypeError(`Sample must be a string, but ${typeof sample} given`);
}
return regexp.test(sample);
}
function wildcardMatch(pattern, options) {
if (typeof pattern !== "string" && !Array.isArray(pattern)) {
throw new TypeError(
`The first argument must be a single pattern string or an array of patterns, but ${typeof pattern} given`
);
}
if (typeof options === "string" || typeof options === "boolean") {
options = { separator: options };
}
if (arguments.length === 2 && !(typeof options === "undefined" || typeof options === "object" && options !== null && !Array.isArray(options))) {
throw new TypeError(
`The second argument must be an options object or a string/boolean separator, but ${typeof options} given`
);
}
options = options || {};
if (options.separator === "\\") {
throw new Error(
"\\ is not a valid separator because it is used for escaping. Try setting the separator to `true` instead"
);
}
let regexpPattern = transform(pattern, options.separator);
let regexp = new RegExp(`^${regexpPattern}$`, options.flags);
let fn = isMatch.bind(null, regexp);
fn.options = options;
fn.pattern = pattern;
fn.regexp = regexp;
return fn;
}
const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
if (ctx.request?.method !== "POST" || !ctx.request) {
return;
}
const { body, query, context } = ctx;
const originHeader = ctx.headers?.get("origin") || ctx.headers?.get("referer") || "";
const callbackURL = body?.callbackURL || query?.callbackURL;
const redirectURL = body?.redirectTo;
const errorCallbackURL = body?.errorCallbackURL;
const newUserCallbackURL = body?.newUserCallbackURL;
const trustedOrigins = Array.isArray(context.options.trustedOrigins) ? context.trustedOrigins : [
...context.trustedOrigins,
...await context.options.trustedOrigins?.(ctx.request) || []
];
const usesCookies = ctx.headers?.has("cookie");
const matchesPattern = (url, pattern) => {
if (url.startsWith("/")) {
return false;
}
if (pattern.includes("*")) {
return wildcardMatch(pattern)(getHost(url));
}
const protocol = getProtocol(url);
return protocol === "http:" || protocol === "https:" || !protocol ? pattern === getOrigin(url) : url.startsWith(pattern);
};
const validateURL = (url, label) => {
if (!url) {
return;
}
const isTrustedOrigin = trustedOrigins.some(
(origin) => matchesPattern(url, origin) || url?.startsWith("/") && label !== "origin" && /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url)
);
if (!isTrustedOrigin) {
ctx.context.logger.error(`Invalid ${label}: ${url}`);
ctx.context.logger.info(
`If it's a valid URL, please add ${url} to trustedOrigins in your auth config
`,
`Current list of trustedOrigins: ${trustedOrigins}`
);
throw new APIError("FORBIDDEN", { message: `Invalid ${label}` });
}
};
if (usesCookies && !ctx.context.options.advanced?.disableCSRFCheck) {
validateURL(originHeader, "origin");
}
callbackURL && validateURL(callbackURL, "callbackURL");
redirectURL && validateURL(redirectURL, "redirectURL");
errorCallbackURL && validateURL(errorCallbackURL, "errorCallbackURL");
newUserCallbackURL && validateURL(newUserCallbackURL, "newUserCallbackURL");
});
const originCheck = (getValue) => createAuthMiddleware(async (ctx) => {
if (!ctx.request) {
return;
}
const { context } = ctx;
const callbackURL = getValue(ctx);
const trustedOrigins = Array.isArray(
context.options.trustedOrigins
) ? context.trustedOrigins : [
...context.trustedOrigins,
...await context.options.trustedOrigins?.(ctx.request) || []
];
const matchesPattern = (url, pattern) => {
if (url.startsWith("/")) {
return false;
}
if (pattern.includes("*")) {
return wildcardMatch(pattern)(getHost(url));
}
return url.startsWith(pattern);
};
const validateURL = (url, label) => {
if (!url) {
return;
}
const isTrustedOrigin = trustedOrigins.some(
(origin) => matchesPattern(url, origin) || url?.startsWith("/") && label !== "origin" && /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(
url
)
);
if (!isTrustedOrigin) {
ctx.context.logger.error(`Invalid ${label}: ${url}`);
ctx.context.logger.info(
`If it's a valid URL, please add ${url} to trustedOrigins in your auth config
`,
`Current list of trustedOrigins: ${trustedOrigins}`
);
throw new APIError("FORBIDDEN", { message: `Invalid ${label}` });
}
};
const callbacks = Array.isArray(callbackURL) ? callbackURL : [callbackURL];
for (const url of callbacks) {
validateURL(url, "callbackURL");
}
});
const BASE_ERROR_CODES = {
USER_NOT_FOUND: "User not found",
FAILED_TO_CREATE_USER: "Failed to create user",
FAILED_TO_CREATE_SESSION: "Failed to create session",
FAILED_TO_UPDATE_USER: "Failed to update user",
FAILED_TO_GET_SESSION: "Failed to get session",
INVALID_PASSWORD: "Invalid password",
INVALID_EMAIL: "Invalid email",
INVALID_EMAIL_OR_PASSWORD: "Invalid email or password",
SOCIAL_ACCOUNT_ALREADY_LINKED: "Social account already linked",
PROVIDER_NOT_FOUND: "Provider not found",
INVALID_TOKEN: "invalid token",
ID_TOKEN_NOT_SUPPORTED: "id_token not supported",
FAILED_TO_GET_USER_INFO: "Failed to get user info",
USER_EMAIL_NOT_FOUND: "User email not found",
EMAIL_NOT_VERIFIED: "Email not verified",
PASSWORD_TOO_SHORT: "Password too short",
PASSWORD_TOO_LONG: "Password too long",
USER_ALREADY_EXISTS: "User already exists",
EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
ACCOUNT_NOT_FOUND: "Account not found",
USER_ALREADY_HAS_PASSWORD: "User already has a password. Provide that to delete the account."
};
const getSession = () => createAuthEndpoint(
"/get-session",
{
method: "GET",
query: z.optional(
z.object({
/**
* If cookie cache is enabled, it will disable the cache
* and fetch the session from the database
*/
disableCookieCache: z.optional(
z.boolean({
description: "Disable cookie cache and fetch session from database"
}).or(z.string().transform((v) => v === "true"))
).optional(),
disableRefresh: z.boolean({
description: "Disable session refresh. Useful for checking session status, without updating the session"
}).or(z.string().transform((v) => v === "true")).optional()
})
),
requireHeaders: true,
metadata: {
openapi: {
description: "Get the current session",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
session: {
$ref: "#/components/schemas/Session"
},
user: {
$ref: "#/components/schemas/User"
}
},
required: ["session", "user"]
}
}
}
}
}
}
}
},
async (ctx) => {
try {
const sessionCookieToken = await ctx.getSignedCookie(
ctx.context.authCookies.sessionToken.name,
ctx.context.secret
);
if (!sessionCookieToken) {
return null;
}
const sessionDataCookie = ctx.getCookie(
ctx.context.authCookies.sessionData.name
);
const sessionDataPayload = sessionDataCookie ? safeJSONParse(binary.decode(base64.decode(sessionDataCookie))) : null;
if (sessionDataPayload) {
const isValid = await createHMAC("SHA-256", "base64urlnopad").verify(
ctx.context.secret,
JSON.stringify({
...sessionDataPayload.session,
expiresAt: sessionDataPayload.expiresAt
}),
sessionDataPayload.signature
);
if (!isValid) {
const dataCookie = ctx.context.authCookies.sessionData.name;
ctx.setCookie(dataCookie, "", {
maxAge: 0
});
return ctx.json(null);
}
}
const dontRememberMe = await ctx.getSignedCookie(
ctx.context.authCookies.dontRememberToken.name,
ctx.context.secret
);
if (sessionDataPayload?.session && ctx.context.options.session?.cookieCache?.enabled && !ctx.query?.disableCookieCache) {
const session2 = sessionDataPayload.session;
const hasExpired = sessionDataPayload.expiresAt < Date.now() || session2.session.expiresAt < /* @__PURE__ */ new Date();
if (!hasExpired) {
return ctx.json(
session2
);
} else {
const dataCookie = ctx.context.authCookies.sessionData.name;
ctx.setCookie(dataCookie, "", {
maxAge: 0
});
}
}
const session = await ctx.context.internalAdapter.findSession(sessionCookieToken);
ctx.context.session = session;
if (!session || session.session.expiresAt < /* @__PURE__ */ new Date()) {
deleteSessionCookie(ctx);
if (session) {
await ctx.context.internalAdapter.deleteSession(
session.session.token
);
}
return ctx.json(null);
}
if (dontRememberMe || ctx.query?.disableRefresh) {
return ctx.json(
session
);
}
const expiresIn = ctx.context.sessionConfig.expiresIn;
const updateAge = ctx.context.sessionConfig.updateAge;
const sessionIsDueToBeUpdatedDate = session.session.expiresAt.valueOf() - expiresIn * 1e3 + updateAge * 1e3;
const shouldBeUpdated = sessionIsDueToBeUpdatedDate <= Date.now();
if (shouldBeUpdated && (!ctx.query?.disableRefresh || !ctx.context.options.session?.disableSessionRefresh)) {
const updatedSession = await ctx.context.internalAdapter.updateSession(
session.session.token,
{
expiresAt: getDate(ctx.context.sessionConfig.expiresIn, "sec"),
updatedAt: /* @__PURE__ */ new Date()
}
);
if (!updatedSession) {
deleteSessionCookie(ctx);
return ctx.json(null, { status: 401 });
}
const maxAge = (updatedSession.expiresAt.valueOf() - Date.now()) / 1e3;
await setSessionCookie(
ctx,
{
session: updatedSession,
user: session.user
},
false,
{
maxAge
}
);
return ctx.json({
session: updatedSession,
user: session.user
});
}
await setCookieCache(ctx, session);
return ctx.json(
session
);
} catch (error) {
ctx.context.logger.error("INTERNAL_SERVER_ERROR", error);
throw new APIError("INTERNAL_SERVER_ERROR", {
message: BASE_ERROR_CODES.FAILED_TO_GET_SESSION
});
}
}
);
const getSessionFromCtx = async (ctx, config) => {
if (ctx.context.session) {
return ctx.context.session;
}
const session = await getSession()({
...ctx,
asResponse: false,
headers: ctx.headers,
returnHeaders: false,
query: {
...config,
...ctx.query
}
}).catch((e) => {
return null;
});
ctx.context.session = session;
return session;
};
const sessionMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session?.session) {
throw new APIError("UNAUTHORIZED");
}
return {
session
};
});
const requestOnlySessionMiddleware = createAuthMiddleware(
async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session?.session && (ctx.request || ctx.headers)) {
throw new APIError("UNAUTHORIZED");
}
return { session };
}
);
const freshSessionMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session?.session) {
throw new APIError("UNAUTHORIZED");
}
if (ctx.context.sessionConfig.freshAge === 0) {
return {
session
};
}
const freshAge = ctx.context.sessionConfig.freshAge;
const lastUpdated = session.session.updatedAt?.valueOf() || session.session.createdAt.valueOf();
const now = Date.now();
const isFresh = now - lastUpdated < freshAge * 1e3;
if (!isFresh) {
throw new APIError("FORBIDDEN", {
message: "Session is not fresh"
});
}
return {
session
};
});
const listSessions = () => createAuthEndpoint(
"/list-sessions",
{
method: "GET",
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
description: "List all active sessions for the user",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "array",
items: {
$ref: "#/components/schemas/Session"
}
}
}
}
}
}
}
}
},
async (ctx) => {
try {
const sessions = await ctx.context.internalAdapter.listSessions(
ctx.context.session.user.id
);
const activeSessions = sessions.filter((session) => {
return session.expiresAt > /* @__PURE__ */ new Date();
});
return ctx.json(
activeSessions
);
} catch (e) {
ctx.context.logger.error(e);
throw ctx.error("INTERNAL_SERVER_ERROR");
}
}
);
const revokeSession = createAuthEndpoint(
"/revoke-session",
{
method: "POST",
body: z.object({
token: z.string({
description: "The token to revoke"
})
}),
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
description: "Revoke a single session",
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
token: {
type: "string",
description: "The token to revoke"
}
},
required: ["token"]
}
}
}
},
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description: "Indicates if the session was revoked successfully"
}
},
required: ["status"]
}
}
}
}
}
}
}
},
async (ctx) => {
const token = ctx.body.token;
const findSession = await ctx.context.internalAdapter.findSession(token);
if (!findSession) {
throw new APIError("BAD_REQUEST", {
message: "Session not found"
});
}
if (findSession.session.userId !== ctx.context.session.user.id) {
throw new APIError("UNAUTHORIZED");
}
try {
await ctx.context.internalAdapter.deleteSession(token);
} catch (error) {
ctx.context.logger.error(
error && typeof error === "object" && "name" in error ? error.name : "",
error
);
throw new APIError("INTERNAL_SERVER_ERROR");
}
return ctx.json({
status: true
});
}
);
const revokeSessions = createAuthEndpoint(
"/revoke-sessions",
{
method: "POST",
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
description: "Revoke all sessions for the user",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description: "Indicates if all sessions were revoked successfully"
}
},
required: ["status"]
}
}
}
}
}
}
}
},
async (ctx) => {
try {
await ctx.context.internalAdapter.deleteSessions(
ctx.context.session.user.id
);
} catch (error) {
ctx.context.logger.error(
error && typeof error === "object" && "name" in error ? error.name : "",
error
);
throw new APIError("INTERNAL_SERVER_ERROR");
}
return ctx.json({
status: true
});
}
);
const revokeOtherSessions = createAuthEndpoint(
"/revoke-other-sessions",
{
method: "POST",
requireHeaders: true,
use: [sessionMiddleware],
metadata: {
openapi: {
description: "Revoke all other sessions for the user except the current one",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description: "Indicates if all other sessions were revoked successfully"
}
},
required: ["status"]
}
}
}
}
}
}
}
},
async (ctx) => {
const session = ctx.context.session;
if (!session.user) {
throw new APIError("UNAUTHORIZED");
}
const sessions = await ctx.context.internalAdapter.listSessions(
session.user.id
);
const activeSessions = sessions.filter((session2) => {
return session2.expiresAt > /* @__PURE__ */ new Date();
});
const otherSessions = activeSessions.filter(
(session2) => session2.token !== ctx.context.session.session.token
);
await Promise.all(
otherSessions.map(
(session2) => ctx.context.internalAdapter.deleteSession(session2.token)
)
);
return ctx.json({
status: true
});
}
);
async function createEmailVerificationToken(secret, email, updateTo, expiresIn = 3600) {
const token = await signJWT(
{
email: email.toLowerCase(),
updateTo
},
secret,
expiresIn
);
return token;
}
async function sendVerificationEmailFn(ctx, user) {
if (!ctx.context.options.emailVerification?.sendVerificationEmail) {
ctx.context.logger.error("Verification email isn't enabled.");
throw new APIError("BAD_REQUEST", {
message: "Verification email isn't enabled"
});
}
const token = await createEmailVerificationToken(
ctx.context.secret,
user.email,
void 0,
ctx.context.options.emailVerification?.expiresIn
);
const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`;
await ctx.context.options.emailVerification.sendVerificationEmail(
{
user,
url,
token
},
ctx.request
);
}
const sendVerificationEmail = createAuthEndpoint(
"/send-verification-email",
{
method: "POST",
body: z.object({
email: z.string({
description: "The email to send the verification email to"
}).email(),
callbackURL: z.string({
description: "The URL to use for email verification callback"
}).optional()
}),
metadata: {
openapi: {
description: "Send a verification email to the user",
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
email: {
type: "string",
description: "The email to send the verification email to",
example: "user@example.com"
},
callbackURL: {
type: "string",
description: "The URL to use for email verification callback",
example: "https://example.com/callback",
nullable: true
}
},
required: ["email"]
}
}
}
},
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description: "Indicates if the email was sent successfully",
example: true
}
}
}
}
}
},
"400": {
description: "Bad Request",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: {
type: "string",
description: "Error message",
example: "Verification email isn't enabled"
}
}
}
}
}
}
}
}
}
},
async (ctx) => {
if (!ctx.context.options.emailVerification?.sendVerificationEmail) {
ctx.context.logger.error("Verification email isn't enabled.");
throw new APIError("BAD_REQUEST", {
message: "Verification email isn't enabled"
});
}
const { email } = ctx.body;
const user = await ctx.context.internalAdapter.findUserByEmail(email);
if (!user) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.USER_NOT_FOUND
});
}
await sendVerificationEmailFn(ctx, user.user);
return ctx.json({
status: true
});
}
);
const verifyEmail = createAuthEndpoint(
"/verify-email",
{
method: "GET",
query: z.object({
token: z.string({
description: "The token to verify the email"
}),
callbackURL: z.string({
description: "The URL to redirect to after email verification"
}).optional()
}),
use: [originCheck((ctx) => ctx.query.callbackURL)],
metadata: {
openapi: {
description: "Verify the email of the user",
parameters: [
{
name: "token",
in: "query",
description: "The token to verify the email",
required: true,
schema: {
type: "string"
}
},
{
name: "callbackURL",
in: "query",
description: "The URL to redirect to after email verification",
required: false,
schema: {
type: "string"
}
}
],
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
user: {
type: "object",
properties: {
id: {
type: "string",
description: "User ID"
},
email: {
type: "string",
description: "User email"
},
name: {
type: "string",
description: "User name"
},
image: {
type: "string",
description: "User image URL"
},
emailVerified: {
type: "boolean",
description: "Indicates if the user email is verified"
},
createdAt: {
type: "string",
description: "User creation date"
},
updatedAt: {
type: "string",
description: "User update date"
}
},
required: [
"id",
"email",
"name",
"image",
"emailVerified",
"createdAt",
"updatedAt"
]
},
status: {
type: "boolean",
description: "Indicates if the email was verified successfully"
}
},
required: ["user", "status"]
}
}
}
}
}
}
}
},
async (ctx) => {
function redirectOnError(error) {
if (ctx.query.callbackURL) {
if (ctx.query.callbackURL.includes("?")) {
throw ctx.redirect(`${ctx.query.callbackURL}&error=${error}`);
}
throw ctx.redirect(`${ctx.query.callbackURL}?error=${error}`);
}
throw new APIError("UNAUTHORIZED", {
message: error
});
}
const { token } = ctx.query;
let jwt;
try {
jwt = await jwtVerify(
token,
new TextEncoder().encode(ctx.context.secret),
{
algorithms: ["HS256"]
}
);
} catch (e) {
if (e instanceof JWTExpired) {
return redirectOnError("token_expired");
}
return redirectOnError("invalid_token");
}
const schema = z.object({
email: z.string().email(),
updateTo: z.string().optional()
});
const parsed = schema.parse(jwt.payload);
const user = await ctx.context.internalAdapter.findUserByEmail(
parsed.email
);
if (!user) {
return redirectOnError("user_not_found");
}
if (parsed.updateTo) {
const session = await getSessionFromCtx(ctx);
if (!session) {
if (ctx.query.callbackURL) {
throw ctx.redirect(`${ctx.query.callbackURL}?error=unauthorized`);
}
return redirectOnError("unauthorized");
}
if (session.user.email !== parsed.email) {
if (ctx.query.callbackURL) {
throw ctx.redirect(`${ctx.query.callbackURL}?error=unauthorized`);
}
return redirectOnError("unauthorized");
}
const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(
parsed.email,
{
email: parsed.updateTo,
emailVerified: false
},
ctx
);
const newToken = await createEmailVerificationToken(
ctx.context.secret,
parsed.updateTo
);
await ctx.context.options.emailVerification?.sendVerificationEmail?.(
{
user: updatedUser,
url: `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${ctx.query.callbackURL || "/"}`,
token: newToken
},
ctx.request
);
await setSessionCookie(ctx, {
session: session.session,
user: {
...session.user,
email: parsed.updateTo,
emailVerified: false
}
});
if (ctx.query.callbackURL) {
throw ctx.redirect(ctx.query.callbackURL);
}
return ctx.json({
status: true,
user: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
image: updatedUser.image,
emailVerified: updatedUser.emailVerified,
createdAt: updatedUser.createdAt,
updatedAt: updatedUser.updatedAt
}
});
}
await ctx.context.options.emailVerification?.onEmailVerification?.(
user.user,
ctx.request
);
await ctx.context.internalAdapter.updateUserByEmail(
parsed.email,
{
emailVerified: true
},
ctx
);
if (ctx.context.options.emailVerification?.autoSignInAfterVerification) {
const currentSession = await getSessionFromCtx(ctx);
if (!currentSession || currentSession.user.email !== parsed.email) {
const session = await ctx.context.internalAdapter.createSession(
user.user.id,
ctx
);
if (!session) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: "Failed to create session"
});
}
await setSessionCookie(ctx, {
session,
user: {
...user.user,
emailVerified: true
}
});
} else {
await setSessionCookie(ctx, {
session: currentSession.session,
user: {
...currentSession.user,
emailVerified: true
}
});
}
}
if (ctx.query.callbackURL) {
throw ctx.redirect(ctx.query.callbackURL);
}
return ctx.json({
status: true,
user: null
});
}
);
const HIDE_METADATA = {
isAction: false
};
async function handleOAuthUserInfo(c, {
userInfo,
account,
callbackURL,
disableSignUp,
overrideUserInfo
}) {
const dbUser = await c.context.internalAdapter.findOAuthUser(
userInfo.email.toLowerCase(),
account.accountId,
account.providerId
).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;
let isRegister = !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
);
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(),
userId: dbUser.user.id,
accessToken: account.accessToken,
idToken: account.idToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: account.scope
},
c
);
} catch (e) {
logger.error("Unable to link account", e);
return {
error: "unable to link account",
data: null
};
}
} else {
if (c.context.options.account?.updateAccountOnSignIn !== false) {
const updateData = Object.fromEntries(
Object.entries({
accessToken: account.accessToken,
idToken: account.idToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: account.scope
}).filter(([_, value]) => value !== void 0)
);
if (Object.keys(updateData).length > 0) {
await c.context.internalAdapter.updateAccount(
hasBeenLinked.id,
updateData,
c
);
}
}
}
if (overrideUserInfo) {
const { id: _, ...restUserInfo } = userInfo;
await c.context.internalAdapter.updateUser(dbUser.user.id, {
...restUserInfo,
email: userInfo.email.toLowerCase(),
emailVerified: userInfo.email.toLowerCase() === dbUser.user.email ? dbUser.user.emailVerified || userInfo.emailVerified : userInfo.emailVerified
});
}
} else {
if (disableSignUp) {
return {
error: "signup disabled",
data: null,
isRegister: false
};
}
try {
const { id: _, ...restUserInfo } = userInfo;
user = await c.context.internalAdapter.createOAuthUser(
{
...restUserInfo,
email: userInfo.email.toLowerCase()
},
{
accessToken: account.accessToken,
idToken: account.idToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: account.scope,
providerId: account.providerId,
accountId: userInfo.id.toString()
},
c
).then((res) => res?.user);
if (!userInfo.emailVerified && user && c.context.options.emailVerification?.sendOnSignUp) {
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=${callbackURL}`;
await c.context.options.emailVerification?.sendVerificationEmail?.(
{
user,
url,
token
},
c.request
);
}
} catch (e) {
logger.error(e);
if (e instanceof APIError) {
return {
error: e.message,
data: null,
isRegister: false
};
}
return {
error: "unable to create user",
data: null,
isRegister: false
};
}
}
if (!user) {
return {
error: "unable to create user",
data: null,
isRegister: false
};
}
const session = await c.context.internalAdapter.createSession(user.id, c);
if (!session) {
return {
error: "unable to create session",
data: null,
isRegister: false
};
}
return {
data: {
session,
user
},
error: null,
isRegister
};
}
const signInSocial = createAuthEndpoint(
"/sign-in/social",
{
method: "POST",
body: z.object({
/**
* Callback URL to redirect to after the user
* has signed in.
*/
callbackURL: z.string({
description: "Callback URL to redirect to after the user has signed in"
}).optional(),
/**
* callback url to redirect if the user is newly registered.
*
* useful if you have different routes for existing users and new users
*/
newUserCallbackURL: z.string().optional(),
/**
* Callback url to redirect to if an error happens
*
* If it's initiated from the client sdk this defaults to
* the current url.
*/
errorCallbackURL: z.string({
description: "Callback URL to redirect to if an error happens"
}).optional(),
/**
* OAuth2 provider to use`
*/
provider: SocialProviderListEnum,
/**
* 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({
description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself"
}).optional(),
/**
* 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({
description: "ID token from the provider"
}),
/**
* The nonce used to generate the token
*/
nonce: z.string({
description: "Nonce used to generate the token"
}).optional(),
/**
* Access token from the provider
*/
accessToken: z.string({
description: "Access token from the provider"
}).optional(),
/**
* Refresh token from the provider
*/
refreshToken: z.string({
description: "Refresh token from the provider"
}).optional(),
/**
* Expiry date of the token
*/
expiresAt: z.number({
description: "Expiry date of the token"
}).optional()
}),
{
description: "ID token from the provider to sign in the user with id token"
}
),
scopes: z.array(z.string(), {
description: "Array of scopes to request from the provider. This will override the default scopes passed."
}).optional(),
/**
* Explicitly request sign-up
*
* Should be used to allow sign up when
* disableImplicitSignUp for this provider is
* true
*/
requestSignUp: z.boolean({
description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"
}).optional(),
/**
* The login hint to use for the authorization code request
*/
loginHint: z.string({
description: "The login hint to use for the authorization code request"
}).optional()
}),
metadata: {
openapi: {
description: "Sign in with a social provider",
operationId: "socialSignIn",
responses: {
"200": {
description: "Success - Returns either session details or redirect URL",
content: {
"application/json": {
schema: {
// todo: we need support for multiple schema
type: "object",
description: "Session response when idToken is provided",
properties: {
redirect: {
type: "boolean",
enum: [false]
},
token: {
type: "string",
description: "Session token",
url: {
type: "null",
nullable: true
},
user: {
type: "object",
properties: {
id: { type: "string" },
email: { type: "string" },
name: {
type: "string",
nullable: true
},
image: {
type: "string",
nullable: true
},
emailVerified: {
type: "boolean"
},
createdAt: {
type: "string",
format: "date-time"
},
updatedAt: {
type: "string",
format: "date-time"
}
},
required: [
"id",
"email",
"emailVerified",
"createdAt",
"updatedAt"
]
}
}
},
required: ["redirect", "token", "user"]
}
}
}
}
}
}
}
},
async (c) => {
const provider = c.context.socialProviders.find(
(p) => p.id === 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 new APIError("NOT_FOUND", {
message: BASE_ERROR_CODES.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: BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED
});
}
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: BASE_ERROR_CODES.INVALID_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: BASE_ERROR_CODES.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: BASE_ERROR_CODES.USER_EMAIL_NOT_FOUND
});
}
const data = await handleOAuthUserInfo(c, {
userInfo: {
...userInfo.user,
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
},
callbackURL: c.body.callbackURL,
disableSignUp: provider.disableImplicitSignUp && !c.body.requestSignUp || provider.disableSignUp
});
if (data.error) {
throw new APIError("UNAUTHORIZED", {
message: data.error
});
}
await setSessionCookie(c, data.data);
return c.json({
redirect: false,
token: data.data.session.token,
url: void 0,
user: {
id: data.data.user.id,
email: data.data.user.email,
name: data.data.user.name,
image: data.data.user.image,
emailVerified: data.data.user.emailVerified,
createdAt: data.data.user.createdAt,
updatedAt: data.data.user.updatedAt
}
});
}
const { codeVerifier, state } = await generateState(c);
const url = await provider.createAuthorizationURL({
state,
codeVerifier,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint
});
return c.json({
url: url.toString(),
redirect: !c.body.disableRedirect
});
}
);
const signInEmail = createAuthEndpoint(
"/sign-in/email",
{
method: "POST",
body: z.object({
/**
* Email of the user
*/
email: z.string({
description: "Email of the user"
}),
/**
* Password of the user
*/
password: z.string({
description: "Password of the user"
}),
/**
* Callback URL to use as a redirect for email
* verification and for possible redirects
*/
callbackURL: z.string({
description: "Callback URL to use as a redirect for email verification"
}).optional(),
/**
* If this is false, the session will not be remembered
* @default true
*/
rememberMe: z.boolean({
description: "If this is false, the session will not be remembered. Default is `true`."
}).default(true).optional()
}),
metadata: {
openapi: {
description: "Sign in with email and password",
responses: {
"200": {
description: "