@convex-dev/better-auth
Version:
A Better Auth component for Convex.
348 lines • 17 kB
JavaScript
import { createAuthMiddleware, sessionMiddleware } from "better-auth/api";
import { createAuthEndpoint, customSession as customSessionPlugin, jwt as jwtPlugin, bearer as bearerPlugin, oidcProvider as oidcProviderPlugin, } from "better-auth/plugins";
import { omit } from "convex-helpers";
import { z } from "zod";
export const JWT_COOKIE_NAME = "convex_jwt";
export const convex = (opts = {}) => {
const { jwtExpirationSeconds = 60 * 15, deleteExpiredSessionsOnLogin = false, } = opts;
const customSession = customSessionPlugin(async ({ user, session }) => {
// Doing terrible things with types because user and session aren't actually
// objects and we need plugin inference to work
const { userId, ...userData } = omit(user, ["id"]);
return {
user: { ...userData, id: userId },
session: {
...session,
userId,
},
};
}, opts.options);
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 jwt = jwtPlugin({
jwt: {
issuer: `${process.env.CONVEX_SITE_URL}`,
audience: "convex",
expirationTime: `${jwtExpirationSeconds}s`,
getSubject: (session) => {
// Return the userId from the app user table
return session.user.userId;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
definePayload: ({ user: { id, userId, image, ...user }, session }) => ({
...user,
sessionId: session.id,
}),
},
});
// 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 } },
},
...jwt.schema,
};
return {
id: "convex",
hooks: {
before: [
{
matcher: (ctx) => {
return !!ctx.body?.userId;
},
handler: createAuthMiddleware(async (ctx) => {
const user = await ctx.context.adapter.findOne({
model: "user",
where: [
{
field: "userId",
operator: "eq",
value: ctx.body?.userId,
},
],
});
if (!user) {
throw new Error("User not found");
}
ctx.body.userId = user.id;
return {
context: ctx,
};
}),
},
...bearer.hooks.before,
],
after: [
...oidcProvider.hooks.after,
{
matcher: (ctx) => {
return (deleteExpiredSessionsOnLogin &&
(ctx.path?.startsWith("/sign-in") ||
ctx.path?.startsWith("/callback")));
},
handler: createAuthMiddleware(async (ctx) => {
// Delete expired sessions at login
const userId = ctx.context.newSession?.user.id;
if (!userId) {
return;
}
await ctx.context.adapter.deleteMany({
model: "session",
where: [
{
field: "userId",
operator: "eq",
value: userId,
connector: "AND",
},
{
operator: "lte",
field: "expiresAt",
value: new Date().getTime(),
},
],
});
}),
},
{
matcher: (ctx) => {
return (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"));
},
handler: createAuthMiddleware(async (ctx) => {
// Set jwt cookie at login for ssa
const cookie = ctx.context.responseHeaders?.get("set-cookie") ?? "";
if (!cookie) {
return;
}
try {
const { token } = await jwt.endpoints.getToken({
...ctx,
method: "GET",
headers: {
cookie,
},
returnHeaders: false,
});
const jwtCookie = ctx.context.createAuthCookie(JWT_COOKIE_NAME, {
maxAge: jwtExpirationSeconds,
});
ctx.setCookie(jwtCookie.name, token, jwtCookie.attributes);
}
catch (_err) {
// no-op, some sign-in calls (eg., when redirecting to 2fa)
// 401 here
}
}),
},
{
matcher: (ctx) => {
return (ctx.path?.startsWith("/sign-out") ||
ctx.path?.startsWith("/delete-user"));
},
handler: createAuthMiddleware(async (ctx) => {
const jwtCookie = ctx.context.createAuthCookie(JWT_COOKIE_NAME, {
maxAge: 0,
});
ctx.setCookie(jwtCookie.name, "", jwtCookie.attributes);
}),
},
],
},
endpoints: {
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
.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",
})
.optional(),
})),
metadata: {
CUSTOM_SESSION: true,
openapi: {
description: "Get custom session data",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "array",
nullable: true,
items: {
$ref: "#/components/schemas/Session",
},
},
},
},
},
},
},
},
requireHeaders: true,
}, async (ctx) => {
const response = await customSession.endpoints.getSession({
...ctx,
returnHeaders: false,
});
return response;
}),
getOpenIdConfig: createAuthEndpoint("/convex/.well-known/openid-configuration", {
method: "GET",
metadata: {
isAction: false,
},
}, async (ctx) => {
const response = await oidcProvider.endpoints.getOpenIdConfig({
...ctx,
returnHeaders: 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,
});
return response;
}),
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 response = await jwt.endpoints.getToken({
...ctx,
returnHeaders: false,
});
const jwtCookie = ctx.context.createAuthCookie(JWT_COOKIE_NAME, {
maxAge: jwtExpirationSeconds,
});
ctx.setCookie(jwtCookie.name, response.token, jwtCookie.attributes);
return response;
}),
},
schema,
};
};
//# sourceMappingURL=index.js.map