Files
veridian/lib/auth.ts
T

140 lines
5.6 KiB
TypeScript

import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
import { db } from "../server/lib/db";
import { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api';
import { betterAuth } from 'better-auth/minimal';
import { createAccessControl } from 'better-auth/plugins/access';
import { admin } from 'better-auth/plugins/admin';
import { defaultStatements } from 'better-auth/plugins/admin/access';
import { twoFactor } from 'better-auth/plugins/two-factor';
import * as schema from '../drizzle/schema';
import { getRegistrationMode } from '../server/utils/instance';
import { claimInvite, INVITE_TOKEN_HEADER, releaseInvite } from '../server/utils/invites';
const MIN_PASSWORD_LENGTH = 8;
const accessControl = createAccessControl(defaultStatements);
// Admins manage accounts, not content. Signing in as someone else and setting
// another user's password or email are left out on purpose: the first would
// show their chats, the others would lock them out of their encrypted API keys.
// Deleting is left out too, because Better Auth would only remove the account
// and leave its agents, topics and files behind. Creating is left out so new
// accounts are always made without a password and with a setup link.
// /api/admin/users does both.
const roles = {
admin: accessControl.newRole({
user: ['list', 'set-role', 'ban', 'get', 'update'],
session: ['list', 'revoke', 'delete'],
}),
user: accessControl.newRole({
user: [],
session: [],
}),
};
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
usePlural: true,
}),
session: {
expiresIn: 60 * 60 * 24 * 30, // 1 month
updateAge: 60 * 60 * 24 * 1, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Who may register is an instance setting admins change at runtime.
if (ctx.path === '/sign-up/email') {
const mode = await getRegistrationMode();
if (mode === 'closed') {
throw new APIError('FORBIDDEN', {
message: 'Registration is closed on this instance',
code: 'REGISTRATION_CLOSED',
});
}
if (mode === 'invite') {
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
if (!token || !await claimInvite(token)) {
throw new APIError('FORBIDDEN', {
message: 'This invite link is not valid anymore',
code: 'INVITE_INVALID',
});
}
}
}
// Creating a user as an admin skips the sign up password rules. No
// password at all is fine: that account can't be signed in to until
// its owner sets one with a setup link.
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
const password = ctx.body.password;
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
throw new APIError('BAD_REQUEST', {
message: 'Password is too short',
code: 'PASSWORD_TOO_SHORT',
});
}
}
// The admin endpoints already stop admins from removing or disabling
// themselves. Giving up their own admin role is the one way left to
// end up with an instance nobody can manage, so that is refused too.
if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return;
const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role;
if (role === undefined) return;
const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim());
if (roleList.includes('admin')) return;
const session = await getSessionFromCtx(ctx);
if (session && String(ctx.body?.userId) === session.user.id) {
throw new APIError('BAD_REQUEST', {
message: 'You cannot remove your own admin role',
code: 'YOU_CANNOT_DEMOTE_YOURSELF',
});
}
}),
// The invite was claimed before the account was made. When making it
// then fails, say because the email is taken, the use is given back.
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-up/email' || !isAPIError(ctx.context.returned)) return;
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
if (token && await getRegistrationMode() === 'invite') {
await releaseInvite(token);
}
}),
},
plugins: [
twoFactor({
issuer: 'Veridian',
}),
admin({
ac: accessControl,
roles,
}),
],
emailAndPassword: {
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== 'true' && process.env.DISABLE_LOCAL_AUTH !== '1',
disableSignUp: process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1',
},
});