UNPKG

@convex-dev/better-auth

Version:
597 lines (587 loc) 20.2 kB
import type { BetterAuthPlugin, Session, User, } from "better-auth"; import type { BetterAuthOptions } from "better-auth/minimal"; import { createAuthMiddleware, sessionMiddleware } from "better-auth/api"; import { createAuthEndpoint, jwt as jwtPlugin, bearer as bearerPlugin, oidcProvider as oidcProviderPlugin, } from "better-auth/plugins"; import type { JwtOptions, Jwk } from "better-auth/plugins"; import { omit } from "convex-helpers"; import type { AuthConfig, AuthProvider } from "convex/server"; export const JWT_COOKIE_NAME = "convex_jwt"; const getJwksAlg = (authProvider: AuthProvider) => { const isCustomJwt = "type" in authProvider && authProvider.type === "customJwt"; if (isCustomJwt && authProvider.algorithm !== "RS256") { throw new Error("Only RS256 is supported for custom JWT with Better Auth"); } return isCustomJwt ? authProvider.algorithm : "EdDSA"; }; const parseAuthConfig = (authConfig: AuthConfig, opts: { jwks?: string }) => { const providerConfigs = authConfig.providers.filter( (provider) => provider.applicationID === "convex" ); if (providerConfigs.length > 1) { throw new Error( "Multiple auth providers with applicationID 'convex' detected. Please use only one." ); } const providerConfig = providerConfigs[0]; if (!providerConfig) { throw new Error( "No auth provider with applicationID 'convex' found. Please add one to your auth config." ); } if (!("type" in providerConfig) || providerConfig.type !== "customJwt") { return providerConfig; } const isDataUriJwks = providerConfig.jwks?.startsWith("data:text/"); if (isDataUriJwks && !opts.jwks) { throw new Error( "Static JWKS detected in auth config, but missing from Convex plugin" ); } if (!isDataUriJwks && opts.jwks) { // eslint-disable-next-line no-console console.warn( "Static JWKS provided to Convex plugin, but not to auth config. This adds an unnecessary network request for token verification." ); } return providerConfig; }; export const convex = (opts: { /** * @param {AuthConfig} authConfig - Auth config from your Convex project. * * Typically found in `convex/auth.config.ts`. * * @example * ```ts * // convex/auth.config.ts * export default { * providers: [getAuthConfigProvider({ jwks: process.env.JWKS })], * } satisfies AuthConfig; * ``` * * @example * ```ts * // convex/auth.ts * import authConfig from './auth.config'; * export const createAuth = (ctx: GenericCtx<DataModel>) => { * return betterAuth({ * // ... * plugins: [convex({ authConfig })], * }); * }; * ``` */ authConfig: AuthConfig; /** * @param {Object} jwt - JWT options. * @param {number} jwt.expirationSeconds - JWT expiration seconds. * @param {Function} jwt.definePayload - Function to define the JWT payload. `sessionId` and `iat` are added automatically. */ jwt?: { expirationSeconds?: number; definePayload?: (session: { user: User & Record<string, any>; session: Session & Record<string, any>; }) => Promise<Record<string, any>> | Record<string, any> | undefined; }; /** * @deprecated Use jwt.expirationSeconds instead. */ jwtExpirationSeconds?: number; /** * @param {string} jwks - Optional static JWKS to avoid fetching from the database. * * This should be a stringified document from the Better Auth JWKS table. You * can create one in the console. * * @example * ```ts * // convex/auth.ts * export const rotateKeys = internalAction({ * args: {}, * handler: async (ctx) => { * const auth = createAuth(ctx) * return await auth.api.rotateKeys() * }, * }) * ``` * Run the action and set the JWKS environment variable * * ```bash * npx convex run auth:rotateKeys | npx convex env set JWKS * ``` * Then use it in your auth config and Better Auth options: * * ```ts * // convex/auth.config.ts * export default { * providers: [getAuthConfigProvider({ jwks: process.env.JWKS })], * } satisfies AuthConfig; * * // convex/auth.ts * export const createAuth = (ctx: GenericCtx<DataModel>) => { * return betterAuth({ * // ... * plugins: [convex({ authConfig, jwks: process.env.JWKS })], * }); * }; * ``` */ jwks?: string; /** * @param {boolean} jwksRotateOnTokenGenerationError - Whether to rotate the JWKS on token generation error. * * Does nothing if a static JWKS is provided. * * Handles error that occurs when existing JWKS key does not match configured * algorithm, which will be common for 0.10 upgrades switching from EdDSA to RS256. * * @default true */ jwksRotateOnTokenGenerationError?: boolean; /** * @param {BetterAuthOptions} options - Better Auth options. Not required, * currently used to pass the basePath to the oidcProvider plugin. */ options?: BetterAuthOptions; }) => { const jwtExpirationSeconds = opts.jwt?.expirationSeconds ?? opts.jwtExpirationSeconds ?? 60 * 15; const oidcProvider = oidcProviderPlugin({ loginPage: "/not-used", metadata: { issuer: `${process.env.CONVEX_SITE_URL}`, jwks_uri: `${process.env.CONVEX_SITE_URL}${opts.options?.basePath ?? "/api/auth"}/convex/jwks`, }, }); const providerConfig = parseAuthConfig(opts.authConfig, opts); const jwtOptions = { jwt: { issuer: `${process.env.CONVEX_SITE_URL}`, audience: "convex", expirationTime: `${jwtExpirationSeconds}s`, definePayload: ({ user, session }) => ({ ...(opts.jwt?.definePayload ? opts.jwt.definePayload({ user, session }) : omit(user, ["id", "image"])), sessionId: session.id, iat: Math.floor(new Date().getTime() / 1000), }), }, jwks: { keyPairConfig: { alg: getJwksAlg(providerConfig), }, }, } satisfies JwtOptions; const jwks = opts.jwks ? JSON.parse(opts.jwks) : undefined; const jwt = jwtPlugin({ ...jwtOptions, adapter: { createJwk: async (webKey, ctx) => { if (opts.jwks) { throw new Error("Not implemented"); } // TODO: remove when date parsing for jwks adapter is fixed upstream return await ctx.context.adapter.create<Omit<Jwk, "id">, Jwk>({ model: "jwks", data: { ...webKey, createdAt: new Date(), }, }); }, getJwks: async (ctx) => { if (opts.jwks) { return jwks; } // TODO: remove when date parsing for jwks adapter is fixed upstream const keys: Jwk[] = await ctx.context.adapter.findMany<Jwk>({ model: "jwks", sortBy: { field: "createdAt", direction: "desc", }, }); return keys.map((key) => ({ ...key, createdAt: new Date(key.createdAt), ...(key.expiresAt ? { expiresAt: new Date(key.expiresAt) } : {}), })); }, }, }); // Bearer plugin converts the session token to a cookie // for cross domain social login after code verification, // and is required for the headers() helper to work. const bearer = bearerPlugin(); const schema = { user: { fields: { userId: { type: "string", required: false, input: false } }, } as const, ...jwt.schema, }; return { id: "convex", init: (ctx) => { const { options, logger: _logger } = ctx; if (options.basePath !== "/api/auth" && !opts.options?.basePath) { // eslint-disable-next-line no-console console.warn( `Better Auth basePath set to ${options.basePath} but no basePath is set in the Convex plugin. This is probably a mistake.` ); } if ( opts.options?.basePath && options.basePath !== opts.options?.basePath ) { // eslint-disable-next-line no-console console.warn( `Better Auth basePath ${options.basePath} does not match Convex plugin basePath ${opts.options?.basePath}. This is probably a mistake.` ); } }, hooks: { before: [ ...bearer.hooks.before, // Don't attempt to refresh the session with a query ctx { matcher: (ctx) => { return ( !ctx.context.adapter.options?.isRunMutationCtx && ctx.path === "/get-session" ); }, handler: createAuthMiddleware(async (ctx) => { ctx.query = { ...ctx.query, disableRefresh: true }; ctx.context.internalAdapter.deleteSession = async ( ..._args: any[] ) => { //skip }; return { context: ctx }; }), }, ], after: [ ...oidcProvider.hooks.after, { matcher: (ctx) => { return Boolean( ctx.path.startsWith("/sign-in") || ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/callback") || ctx.path.startsWith("/oauth2/callback") || ctx.path.startsWith("/magic-link/verify") || ctx.path.startsWith("/email-otp/verify-email") || ctx.path.startsWith("/phone-number/verify") || ctx.path.startsWith("/siwe/verify") || (ctx.path.startsWith("/get-session") && ctx.context.session) ); }, handler: createAuthMiddleware(async (ctx) => { // Set jwt cookie at login for authenticated ssr const originalSession = ctx.context.session; try { ctx.context.session = ctx.context.session ?? ctx.context.newSession; const { token } = await jwt.endpoints.getToken({ ...ctx, headers: {}, method: "GET", returnHeaders: false, returnStatus: false, }); const jwtCookie = ctx.context.createAuthCookie(JWT_COOKIE_NAME, { maxAge: jwtExpirationSeconds, }); ctx.setCookie(jwtCookie.name, token, jwtCookie.attributes); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_err) { // no-op, some sign-in calls (eg., when redirecting to 2fa) // 401 here } ctx.context.session = originalSession; }), }, { matcher: (ctx) => { return ( ctx.path?.startsWith("/sign-out") || ctx.path?.startsWith("/delete-user") || (ctx.path?.startsWith("/get-session") && !ctx.context.session) ); }, handler: createAuthMiddleware(async (ctx) => { const jwtCookie = ctx.context.createAuthCookie(JWT_COOKIE_NAME, { maxAge: 0, }); ctx.setCookie(jwtCookie.name, "", jwtCookie.attributes); }), }, ], }, endpoints: { getOpenIdConfig: createAuthEndpoint( "/convex/.well-known/openid-configuration", { method: "GET", metadata: { isAction: false, }, // TODO: properly type this }, async (ctx) => { const response = await oidcProvider.endpoints.getOpenIdConfig({ ...ctx, returnHeaders: false, returnStatus: false, }); return response; } ), getJwks: createAuthEndpoint( "/convex/jwks", { method: "GET", metadata: { openapi: { description: "Get the JSON Web Key Set", responses: { "200": { description: "JSON Web Key Set retrieved successfully", content: { "application/json": { schema: { type: "object", properties: { keys: { type: "array", description: "Array of public JSON Web Keys", items: { type: "object", properties: { kid: { type: "string", description: "Key ID uniquely identifying the key, corresponds to the 'id' from the stored Jwk", }, kty: { type: "string", description: "Key type (e.g., 'RSA', 'EC', 'OKP')", }, alg: { type: "string", description: "Algorithm intended for use with the key (e.g., 'EdDSA', 'RS256')", }, use: { type: "string", description: "Intended use of the public key (e.g., 'sig' for signature)", enum: ["sig"], nullable: true, }, n: { type: "string", description: "Modulus for RSA keys (base64url-encoded)", nullable: true, }, e: { type: "string", description: "Exponent for RSA keys (base64url-encoded)", nullable: true, }, crv: { type: "string", description: "Curve name for elliptic curve keys (e.g., 'Ed25519', 'P-256')", nullable: true, }, x: { type: "string", description: "X coordinate for elliptic curve keys (base64url-encoded)", nullable: true, }, y: { type: "string", description: "Y coordinate for elliptic curve keys (base64url-encoded)", nullable: true, }, }, required: ["kid", "kty", "alg"], }, }, }, required: ["keys"], }, }, }, }, }, }, }, }, async (ctx) => { const response = await jwt.endpoints.getJwks({ ...ctx, returnHeaders: false, returnStatus: false, }); return response; } ), getLatestJwks: createAuthEndpoint( // This path is inaccessible due to SERVER_ONLY: true, it's here to // avoid errors that occur in all matchers when path is undefined. "/convex/latest-jwks", { isAction: true, method: "POST", metadata: { SERVER_ONLY: true, openapi: { description: "Delete and regenerate JWKS, and return the new JWKS for static usage", }, }, }, async (ctx) => { // Ensure at least one key exists await jwtPlugin(jwtOptions).endpoints.getJwks({ ...ctx, method: "GET", }); const jwks: any[] = await ctx.context.adapter.findMany({ model: "jwks", limit: 1, sortBy: { field: "createdAt", direction: "desc", }, }); // Add alg to jwks, otherwise Better Auth will default to EdDSA jwks[0].alg = jwtOptions.jwks.keyPairConfig.alg; return jwks; } ), rotateKeys: createAuthEndpoint( // This path is inaccessible due to SERVER_ONLY: true, it's here to // avoid errors that occur in all matchers when path is undefined. "/convex/rotate-keys", { isAction: true, method: "POST", metadata: { SERVER_ONLY: true, openapi: { description: "Delete and regenerate JWKS, and return the new JWKS for static usage", }, }, }, async (ctx) => { await ctx.context.adapter.deleteMany({ model: "jwks", where: [], }); await jwtPlugin(jwtOptions).endpoints.getJwks({ ...ctx, method: "GET", }); const jwks: any[] = await ctx.context.adapter.findMany({ model: "jwks", limit: 1, sortBy: { field: "createdAt", direction: "desc", }, }); jwks[0].alg = jwtOptions.jwks.keyPairConfig.alg; return jwks; } ), getToken: createAuthEndpoint( "/convex/token", { method: "GET", requireHeaders: true, use: [sessionMiddleware], metadata: { openapi: { description: "Get a JWT token", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { token: { type: "string", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const runEndpoint = async () => { const response = await jwt.endpoints.getToken({ ...ctx, returnHeaders: false, returnStatus: false, }); const jwtCookie = ctx.context.createAuthCookie(JWT_COOKIE_NAME, { maxAge: jwtExpirationSeconds, }); ctx.setCookie(jwtCookie.name, response.token, jwtCookie.attributes); return response; }; try { return await runEndpoint(); } catch (error: any) { // If alg config has changed and no longer matches one or more keys, // roll the keys if (!opts.jwks && error?.code === "ERR_JOSE_NOT_SUPPORTED") { if (opts.jwksRotateOnTokenGenerationError) { await ctx.context.adapter.deleteMany({ model: "jwks", where: [], }); return await runEndpoint(); } else { // eslint-disable-next-line no-console console.error( "Try temporarily setting jwksRotateOnTokenGenerationError: true on the Convex Better Auth plugin." ); } } throw error; } } ), }, schema, } satisfies BetterAuthPlugin; };