feat(identity): configure better auth surfaces

This commit is contained in:
Jesse_Chen
2026-07-21 17:34:00 +08:00
parent 43213f080a
commit 8121add692
6 changed files with 533 additions and 1 deletions
@@ -0,0 +1,125 @@
import { createHmac } from "node:crypto";
import type { Pool } from "pg";
import type { BetterAuthOptions } from "better-auth";
import { admin, emailOTP, type EmailOTPOptions } from "better-auth/plugins";
import type { SelfHostedIdentityConfig } from "./config.ts";
import type { EmailOtpSender, IdentitySurface } from "./contracts.ts";
import { identityModelMapping } from "./model.ts";
export type AdminUserAuthorizer = (userId: string) => Promise<boolean>;
interface BuildAuthOptionsInput {
surface: IdentitySurface;
config: SelfHostedIdentityConfig;
database: Pool;
emailSender: EmailOtpSender;
authorizeAdminUser?: AdminUserAuthorizer;
}
function otpIdempotencyKey(
secret: string,
email: string,
otp: string,
type: string,
): string {
const digest = createHmac("sha256", secret)
.update(type)
.update("\0")
.update(email.trim().toLowerCase())
.update("\0")
.update(otp)
.digest("hex");
return `otp-${digest}`;
}
export function createEmailOtpOptions(
sender: EmailOtpSender,
secret: string,
disableSignUp: boolean,
): EmailOTPOptions {
return {
otpLength: 6,
expiresIn: 300,
allowedAttempts: 3,
resendStrategy: "rotate",
storeOTP: "hashed",
disableSignUp,
rateLimit: { window: 60, max: 3 },
async sendVerificationOTP({ email, otp, type }) {
await sender.send({
email,
otp,
type,
idempotencyKey: otpIdempotencyKey(secret, email, otp, type),
});
},
};
}
export function buildAuthOptions({
surface,
config,
database,
emailSender,
authorizeAdminUser,
}: BuildAuthOptionsInput): BetterAuthOptions {
if (surface === "admin" && !authorizeAdminUser) {
throw new Error("admin user authorizer is required");
}
const origin = surface === "user" ? config.userOrigin : config.adminOrigin;
const secret = surface === "user" ? config.userSecret : config.adminSecret;
return {
appName: "Jyotisha",
baseURL: origin,
basePath: "/api/auth",
secret,
database,
trustedOrigins: [origin],
telemetry: { enabled: false },
user: identityModelMapping.user,
session: identityModelMapping.session,
account: identityModelMapping.account,
verification: identityModelMapping.verification,
rateLimit: {
storage: "database",
window: 60,
max: 30,
...identityModelMapping.rateLimit,
},
advanced: {
database: { generateId: "uuid" },
cookiePrefix:
surface === "user" ? "jyotisha-user" : "jyotisha-admin",
defaultCookieAttributes: {
secure: true,
httpOnly: true,
sameSite: "lax",
path: "/",
},
},
plugins: [
emailOTP(createEmailOtpOptions(emailSender, secret, surface === "admin")),
admin({
defaultRole: "user",
adminRoles: ["admin"],
schema: identityModelMapping.admin,
}),
],
...(surface === "admin"
? {
databaseHooks: {
session: {
create: {
async before(session: { userId: string }) {
if (!(await authorizeAdminUser!(session.userId))) return false;
},
},
},
},
}
: {}),
};
}
+120
View File
@@ -0,0 +1,120 @@
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { buildAuthOptions, type AdminUserAuthorizer } from "./auth-factory.ts";
import {
readIdentityConfig,
type SelfHostedIdentityConfig,
} from "./config.ts";
import type { EmailOtpSender } from "./contracts.ts";
import { ResendEmailOtpSender } from "./email/resend-email-otp-sender.ts";
interface AdminRoleRow {
role: string;
banned: boolean;
ban_expires: Date | null;
}
export function createIdentityPool(databaseUrl: string): Pool {
return new Pool({
connectionString: databaseUrl,
options: "-c search_path=identity,pg_catalog",
application_name: "jyotisha-identity",
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
}
export function createDatabaseAdminAuthorizer(
pool: Pool,
): AdminUserAuthorizer {
return async (userId) => {
const result = await pool.query<AdminRoleRow>(
`
select role, banned, ban_expires
from identity.users
where id = $1
limit 1
`,
[userId],
);
const user = result.rows[0];
if (!user) return false;
if (user.banned) {
const banExpiry = user.ban_expires?.getTime();
if (banExpiry === undefined || !Number.isFinite(banExpiry)) return false;
if (banExpiry > Date.now()) return false;
}
return user.role
.split(",")
.map((role) => role.trim())
.includes("admin");
};
}
export interface IdentityAuthServices {
pool: Pool;
user: ReturnType<typeof betterAuth>;
admin: ReturnType<typeof betterAuth>;
}
interface IdentityAuthDependencies {
pool?: Pool;
emailSender?: EmailOtpSender;
authorizeAdminUser?: AdminUserAuthorizer;
}
export function createIdentityAuthServices(
config: SelfHostedIdentityConfig,
dependencies: IdentityAuthDependencies = {},
): IdentityAuthServices {
const pool = dependencies.pool ?? createIdentityPool(config.databaseUrl);
const emailSender =
dependencies.emailSender ??
new ResendEmailOtpSender({
apiKey: config.resendApiKey,
from: config.resendFrom,
});
const authorizeAdminUser =
dependencies.authorizeAdminUser ?? createDatabaseAdminAuthorizer(pool);
return {
pool,
user: betterAuth(
buildAuthOptions({
surface: "user",
config,
database: pool,
emailSender,
}),
),
admin: betterAuth(
buildAuthOptions({
surface: "admin",
config,
database: pool,
emailSender,
authorizeAdminUser,
}),
),
};
}
const identityGlobal = globalThis as typeof globalThis & {
jyotishaIdentityAuth?: IdentityAuthServices;
};
export function getIdentityAuthServices(
env: NodeJS.ProcessEnv = process.env,
): IdentityAuthServices {
const config = readIdentityConfig(env);
if (config.provider !== "self-hosted") {
throw new Error("self-hosted identity is not enabled");
}
identityGlobal.jyotishaIdentityAuth ??= createIdentityAuthServices(config);
return identityGlobal.jyotishaIdentityAuth;
}
+2 -1
View File
@@ -3,7 +3,8 @@ export type IdentitySurface = "user" | "admin";
export type EmailOtpType =
| "sign-in"
| "email-verification"
| "forget-password";
| "forget-password"
| "change-email";
export interface EmailOtpMessage {
email: string;
@@ -11,6 +11,7 @@ const subjectByType: Record<EmailOtpType, string> = {
"sign-in": "Your Jyotisha sign-in code",
"email-verification": "Verify your Jyotisha email",
"forget-password": "Reset your Jyotisha password",
"change-email": "Confirm your new Jyotisha email",
};
function escapeHtml(value: string): string {
+65
View File
@@ -0,0 +1,65 @@
export const identityModelMapping = {
user: {
modelName: "users",
fields: {
emailVerified: "email_verified",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
session: {
modelName: "sessions",
fields: {
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
ipAddress: "ip_address",
userAgent: "user_agent",
userId: "user_id",
},
},
account: {
modelName: "accounts",
fields: {
accountId: "account_id",
providerId: "provider_id",
userId: "user_id",
accessToken: "access_token",
refreshToken: "refresh_token",
idToken: "id_token",
accessTokenExpiresAt: "access_token_expires_at",
refreshTokenExpiresAt: "refresh_token_expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
verification: {
modelName: "verifications",
fields: {
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
rateLimit: {
modelName: "otp_rate_limits",
fields: {
lastRequest: "last_request",
},
},
admin: {
user: {
fields: {
role: "role",
banned: "banned",
banReason: "ban_reason",
banExpires: "ban_expires",
},
},
session: {
fields: {
impersonatedBy: "impersonated_by",
},
},
},
} as const;