@convex-dev/better-auth
Version:
A Better Auth component for Convex.
453 lines (427 loc) • 13.9 kB
text/typescript
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,
});
},
};
};