UNPKG

better-auth

Version:

The most comprehensive authentication library for TypeScript.

1,616 lines (1,607 loc) • 117 kB
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: "