UNPKG

@convex-dev/better-auth

Version:
453 lines (427 loc) 13.9 kB
import { httpActionGeneric, internalMutationGeneric, queryGeneric, } from "convex/server"; import type { DataModelFromSchemaDefinition, FunctionReference, GenericDataModel, GenericMutationCtx, GenericSchema, HttpRouter, SchemaDefinition, } from "convex/server"; import { ConvexError, v } from "convex/values"; import type { Infer } from "convex/values"; import { convexAdapter } from "./adapter.js"; import { corsRouter } from "convex-helpers/server/cors"; import type defaultSchema from "../component/schema.js"; import type { ComponentApi } from "../component/_generated/component.js"; import type { CreateAuth, GenericCtx } from "./index.js"; export type AuthFunctions = { onCreate?: FunctionReference<"mutation", "internal", { [key: string]: any }>; onUpdate?: FunctionReference<"mutation", "internal", { [key: string]: any }>; onDelete?: FunctionReference<"mutation", "internal", { [key: string]: any }>; }; export type Triggers< DataModel extends GenericDataModel, Schema extends SchemaDefinition<any, any>, > = { [K in keyof Schema["tables"]]?: { onCreate?: <Ctx extends GenericMutationCtx<DataModel>>( ctx: Ctx, doc: Infer<Schema["tables"][K]["validator"]> & { _id: string; _creationTime: number; } ) => Promise<void>; onUpdate?: <Ctx extends GenericMutationCtx<DataModel>>( ctx: Ctx, newDoc: Infer<Schema["tables"][K]["validator"]> & { _id: string; _creationTime: number; }, oldDoc: Infer<Schema["tables"][K]["validator"]> & { _id: string; _creationTime: number; } ) => Promise<void>; onDelete?: <Ctx extends GenericMutationCtx<DataModel>>( ctx: Ctx, doc: Infer<Schema["tables"][K]["validator"]> & { _id: string; _creationTime: number; } ) => Promise<void>; }; }; type SlimComponentApi = { adapter: { create: FunctionReference<"mutation", "internal">; findOne: FunctionReference<"query", "internal">; findMany: FunctionReference<"query", "internal">; updateOne: FunctionReference<"mutation", "internal">; updateMany: FunctionReference<"mutation", "internal">; deleteOne: FunctionReference<"mutation", "internal">; deleteMany: FunctionReference<"mutation", "internal">; }; adapterTest?: ComponentApi["adapterTest"]; }; /** * Backend API for the Better Auth component. * Responsible for exposing the `client` and `triggers` APIs to the client, http * route registration, and having convenience methods for interacting with the * component from the backend. * * @param component - Generally `components.betterAuth` from * `./_generated/api` once you've configured it in `convex.config.ts`. * @param config - Configuration options for the component. * @param config.local - Local schema configuration. * @param config.verbose - Whether to enable verbose logging. * @param config.triggers - Triggers configuration. * @param config.authFunctions - Authentication functions configuration. */ export const createClient = < DataModel extends GenericDataModel, Schema extends SchemaDefinition<GenericSchema, true> = typeof defaultSchema, Api extends SlimComponentApi = SlimComponentApi, >( component: Api, config?: { local?: { schema?: Schema; }; verbose?: boolean; } & ( | { triggers: Triggers<DataModel, Schema>; authFunctions: AuthFunctions; } | { triggers?: undefined } ) ) => { type BetterAuthDataModel = DataModelFromSchemaDefinition<Schema>; const safeGetAuthUser = async (ctx: GenericCtx<DataModel>) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { return; } const session = (await ctx.runQuery(component.adapter.findOne, { model: "session", where: [ { field: "_id", value: identity.sessionId as string, }, { field: "expiresAt", operator: "gt", value: new Date().getTime(), }, ], })) as BetterAuthDataModel["session"]["document"] | null; if (!session) { return; } const doc = (await ctx.runQuery(component.adapter.findOne, { model: "user", where: [ { field: "_id", value: identity.subject, }, ], })) as BetterAuthDataModel["user"]["document"] | null; if (!doc) { return; } return doc; }; const getAuthUser = async (ctx: GenericCtx<DataModel>) => { const user = await safeGetAuthUser(ctx); if (!user) { throw new ConvexError("Unauthenticated"); } return user; }; const getHeaders = async (ctx: GenericCtx<DataModel>) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { return new Headers(); } // Don't validate the session here, let Better Auth handle that const session = await ctx.runQuery(component.adapter.findOne, { model: "session", where: [ { field: "_id", value: identity.sessionId as string, }, ], }); return new Headers({ ...(session?.token ? { authorization: `Bearer ${session.token}` } : {}), ...(session?.ipAddress ? { "x-forwarded-for": session.ipAddress as string } : {}), }); }; return { /** * Returns the Convex database adapter for use in Better Auth options. * @param ctx - The Convex context * @returns The Convex database adapter */ adapter: (ctx: GenericCtx<DataModel>) => convexAdapter<DataModel, typeof ctx, Schema>(ctx, component, { ...config, debugLogs: config?.verbose, }), /** * Returns the Better Auth auth object and headers for using Better Auth API * methods directly in a Convex mutation or query. Convex functions don't * have access to request headers, so the headers object is created at * runtime with the token for the current session as a Bearer token. * * @param createAuth - The createAuth function * @param ctx - The Convex context * @returns A promise that resolves to the Better Auth `auth` API object and * headers. */ getAuth: async <T extends CreateAuth<DataModel>>( createAuth: T, ctx: GenericCtx<DataModel> ) => ({ auth: createAuth(ctx) as ReturnType<T>, headers: await getHeaders(ctx), }), /** * Returns a Headers object for the current session using the session id * from the current user identity via `ctx.auth.getUserIdentity()`. This is * used to pass the headers to the Better Auth API methods when using the * `getAuth` method. * * @param ctx - The Convex context * @returns The headers */ getHeaders, /** * Returns the current user or null if the user is not found * @param ctx - The Convex context * @returns The user or null if the user is not found */ safeGetAuthUser, /** * Returns the current user or throws an error if the user is not found * * @param ctx - The Convex context * @returns The user or throws an error if the user is not found */ getAuthUser, /** * Returns a user by their Better Auth user id. * @param ctx - The Convex context * @param id - The Better Auth user id * @returns The user or null if the user is not found */ getAnyUserById: async (ctx: GenericCtx<DataModel>, id: string) => { return (await ctx.runQuery(component.adapter.findOne, { model: "user", where: [{ field: "_id", value: id }], })) as BetterAuthDataModel["user"]["document"] | null; }, /** * Replaces 0.7 behavior of returning a new user id from * onCreateUser * @param ctx - The Convex context * @param authId - The Better Auth user id * @param userId - The app user id * @deprecated in 0.9 */ setUserId: async ( ctx: GenericMutationCtx<DataModel>, authId: string, userId: string ) => { await ctx.runMutation(component.adapter.updateOne, { input: { model: "user", where: [{ field: "_id", value: authId }], update: { userId }, }, }); }, /** * Exposes functions for use with the ClientAuthBoundary component. Currently * only contains getAuthUser. * @returns Functions to pass to the ClientAuthBoundary component. */ clientApi: () => ({ /** * Convex query to get the current user. For use with the ClientAuthBoundary component. * * ```ts title="convex/auth.ts" * export const { getAuthUser } = authComponent.clientApi(); * ``` * * @returns The user or throws an error if the user is not found */ getAuthUser: queryGeneric({ args: {}, handler: async (ctx: GenericCtx<DataModel>) => { return await getAuthUser(ctx); }, }), }), /** * Exposes functions for executing trigger callbacks in the app context. * * Callbacks are defined in the `triggers` option to the component client config. * * See {@link createClient} for more information. * * @returns Functions to execute trigger callbacks in the app context. */ triggersApi: () => ({ onCreate: internalMutationGeneric({ args: { doc: v.any(), model: v.string(), }, handler: async (ctx, args) => { await config?.triggers?.[args.model]?.onCreate?.(ctx, args.doc); }, }), onUpdate: internalMutationGeneric({ args: { oldDoc: v.any(), newDoc: v.any(), model: v.string(), }, handler: async (ctx, args) => { await config?.triggers?.[args.model]?.onUpdate?.( ctx, args.newDoc, args.oldDoc ); }, }), onDelete: internalMutationGeneric({ args: { doc: v.any(), model: v.string(), }, handler: async (ctx, args) => { await config?.triggers?.[args.model]?.onDelete?.(ctx, args.doc); }, }), }), registerRoutes: ( http: HttpRouter, createAuth: CreateAuth<DataModel>, opts: { cors?: | boolean | { // These values are appended to the default values allowedOrigins?: string[]; allowedHeaders?: string[]; exposedHeaders?: string[]; }; } = {} ) => { const staticAuth = createAuth({} as any); const path = staticAuth.options.basePath ?? "/api/auth"; const authRequestHandler = httpActionGeneric(async (ctx, request) => { if (config?.verbose) { // eslint-disable-next-line no-console console.log("options.baseURL", staticAuth.options.baseURL); // eslint-disable-next-line no-console console.log("request headers", request.headers); } const auth = createAuth(ctx as any); const response = await auth.handler(request); if (config?.verbose) { // eslint-disable-next-line no-console console.log("response headers", response.headers); } return response; }); const wellKnown = http.lookup("/.well-known/openid-configuration", "GET"); // If registerRoutes is used multiple times, this may already be defined if (!wellKnown) { // Redirect root well-known to api well-known http.route({ path: "/.well-known/openid-configuration", method: "GET", handler: httpActionGeneric(async () => { const url = `${process.env.CONVEX_SITE_URL}${path}/convex/.well-known/openid-configuration`; return Response.redirect(url); }), }); } if (!opts.cors) { http.route({ pathPrefix: `${path}/`, method: "GET", handler: authRequestHandler, }); http.route({ pathPrefix: `${path}/`, method: "POST", handler: authRequestHandler, }); return; } const corsOpts = typeof opts.cors === "boolean" ? { allowedOrigins: [], allowedHeaders: [], exposedHeaders: [] } : opts.cors; let trustedOriginsOption: | string[] | ((request: Request) => string[] | Promise<string[]>) | undefined; const cors = corsRouter(http, { allowedOrigins: async (request) => { trustedOriginsOption = trustedOriginsOption ?? (await staticAuth.$context).options.trustedOrigins ?? []; const trustedOrigins = Array.isArray(trustedOriginsOption) ? trustedOriginsOption : await trustedOriginsOption(request); return trustedOrigins .map((origin) => // Strip trailing wildcards, unsupported for allowedOrigins origin.endsWith("*") && origin.length > 1 ? origin.slice(0, -1) : origin ) .concat(corsOpts.allowedOrigins ?? []); }, allowCredentials: true, allowedHeaders: [ "Content-Type", "Better-Auth-Cookie", "Authorization", ].concat(corsOpts.allowedHeaders ?? []), exposedHeaders: ["Set-Better-Auth-Cookie"].concat( corsOpts.exposedHeaders ?? [] ), debug: config?.verbose, enforceAllowOrigins: false, }); cors.route({ pathPrefix: `${path}/`, method: "GET", handler: authRequestHandler, }); cors.route({ pathPrefix: `${path}/`, method: "POST", handler: authRequestHandler, }); }, }; };