// Accounts, sessions and proxy sign-in, by Better Auth: email and password with usernames, roles and bans (admin), // device sign-in for T5DE's proxy (RFC 8628), bearer tokens for the proxy's requests, and JWTs with published keys // (for catalogs later). import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins"; import { count, schema, type Database } from "@t5de/db"; import { Settings } from "./settings"; export type AuthConfig = { /** The API's own address, e.g. https://hub-api.t5de.dev. */ apiURL: string; /** The website's address, e.g. https://hub.t5de.dev: trusted origin, and where /device is. */ webURL: string; /** At least 32 random characters; signs sessions. */ secret: string; /** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */ rateLimit?: boolean; /** Whether new accounts wait for approval (default: the hub's settings, with no environment defaults). */ settings?: Settings; }; /** The proxy's client ID in device sign-in. */ export const proxyClientID = "t5de-proxy"; export function createAuth(db: Database, config: AuthConfig) { const settings = config.settings ?? new Settings(db); return betterAuth({ baseURL: config.apiURL, basePath: "/api/auth", secret: config.secret, trustedOrigins: [config.webURL], database: drizzleAdapter(db, { provider: "pg", schema }), emailAndPassword: { enabled: true, minPasswordLength: 10, maxPasswordLength: 256 }, user: { additionalFields: { linkCap: { type: "number", defaultValue: 10, input: false }, approved: { type: "boolean", defaultValue: true, input: false }, }, }, // Proxies stay signed in for months; a session is extended when it's used. session: { expiresIn: 60 * 60 * 24 * 60, updateAge: 60 * 60 * 24 }, rateLimit: { enabled: config.rateLimit !== false, window: 60, max: 100 }, advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip", "x-forwarded-for"] } }, databaseHooks: { user: { create: { // The first account runs the hub; with registration closed, the others wait for approval. before: async (u) => { const [row] = await db.select({ n: count() }).from(schema.user); const first = (row?.n ?? 0) === 0; const approved = first || (await settings.registration()) === "open"; return { data: { ...u, role: first ? "admin" : "user", approved } }; }, }, }, }, plugins: [ username({ minUsernameLength: 3, maxUsernameLength: 32, usernameValidator: (name) => /^[A-Za-z0-9_]+$/.test(name), }), admin({ defaultRole: "user", adminRoles: ["admin"] }), deviceAuthorization({ verificationUri: `${config.webURL}/device`, expiresIn: "10m", interval: "3s", validateClient: (clientId) => clientId === proxyClientID, }), bearer(), jwt({ jwks: { keyPairConfig: { alg: "EdDSA", crv: "Ed25519" } } }), ], }); } export type Auth = ReturnType;