diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts new file mode 100644 index 00000000..1f9693ab --- /dev/null +++ b/frontend/src/modules/identity/auth-factory.ts @@ -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; + +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; + }, + }, + }, + }, + } + : {}), + }; +} diff --git a/frontend/src/modules/identity/auth.ts b/frontend/src/modules/identity/auth.ts new file mode 100644 index 00000000..dcc9a678 --- /dev/null +++ b/frontend/src/modules/identity/auth.ts @@ -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( + ` + 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; + admin: ReturnType; +} + +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; +} diff --git a/frontend/src/modules/identity/contracts.ts b/frontend/src/modules/identity/contracts.ts index b6e7d9e6..c78ce393 100644 --- a/frontend/src/modules/identity/contracts.ts +++ b/frontend/src/modules/identity/contracts.ts @@ -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; diff --git a/frontend/src/modules/identity/email/resend-email-otp-sender.ts b/frontend/src/modules/identity/email/resend-email-otp-sender.ts index 772b4b66..0cac51cc 100644 --- a/frontend/src/modules/identity/email/resend-email-otp-sender.ts +++ b/frontend/src/modules/identity/email/resend-email-otp-sender.ts @@ -11,6 +11,7 @@ const subjectByType: Record = { "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 { diff --git a/frontend/src/modules/identity/model.ts b/frontend/src/modules/identity/model.ts new file mode 100644 index 00000000..59bdbb7a --- /dev/null +++ b/frontend/src/modules/identity/model.ts @@ -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; diff --git a/frontend/tests/identity-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts new file mode 100644 index 00000000..0f8e3aea --- /dev/null +++ b/frontend/tests/identity-auth-factory.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Pool } from "pg"; + +import { + buildAuthOptions, + createEmailOtpOptions, + type AdminUserAuthorizer, +} from "../src/modules/identity/auth-factory.ts"; +import { + createDatabaseAdminAuthorizer, + createIdentityPool, +} from "../src/modules/identity/auth.ts"; +import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; +import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; + +const config: SelfHostedIdentityConfig = { + provider: "self-hosted", + databaseUrl: + "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", + userOrigin: "https://staging.jyotisha.chat", + adminOrigin: "https://admin.staging.jyotisha.chat", + userSecret: "user-secret-that-is-at-least-32-bytes-long", + adminSecret: "admin-secret-that-is-at-least-32-bytes-long", + resendApiKey: "re_test_key", + resendFrom: "Jyotisha ", +}; + +const database = { kind: "pool" } as unknown as Pool; + +test("Better Auth model mappings match the identity migration", () => { + const options = buildAuthOptions({ + surface: "user", + config, + database, + emailSender: new FakeEmailOtpSender(), + }); + + assert.equal(options.database, database); + assert.equal(options.user?.modelName, "users"); + assert.deepEqual(options.user?.fields, { + emailVerified: "email_verified", + createdAt: "created_at", + updatedAt: "updated_at", + }); + assert.equal(options.session?.modelName, "sessions"); + assert.deepEqual(options.session?.fields, { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + ipAddress: "ip_address", + userAgent: "user_agent", + userId: "user_id", + }); + assert.equal(options.account?.modelName, "accounts"); + assert.equal(options.account?.fields?.accountId, "account_id"); + assert.equal(options.account?.fields?.providerId, "provider_id"); + assert.equal(options.account?.fields?.accessTokenExpiresAt, "access_token_expires_at"); + assert.equal(options.verification?.modelName, "verifications"); + assert.equal(options.verification?.fields?.expiresAt, "expires_at"); + assert.equal(options.rateLimit?.modelName, "otp_rate_limits"); + assert.equal(options.rateLimit?.storage, "database"); + assert.equal(options.advanced?.database?.generateId, "uuid"); +}); + +test("OTP policy hashes values, rotates resends, and builds opaque idempotency keys", async () => { + const sender = new FakeEmailOtpSender(); + const otpOptions = createEmailOtpOptions(sender, config.userSecret, false); + + assert.equal(otpOptions.otpLength, 6); + assert.equal(otpOptions.expiresIn, 300); + assert.equal(otpOptions.allowedAttempts, 3); + assert.equal(otpOptions.resendStrategy, "rotate"); + assert.equal(otpOptions.storeOTP, "hashed"); + assert.deepEqual(otpOptions.rateLimit, { window: 60, max: 3 }); + assert.equal(otpOptions.disableSignUp, false); + + await otpOptions.sendVerificationOTP({ + email: "person@example.com", + otp: "123456", + type: "sign-in", + }); + assert.equal(sender.messages.length, 1); + assert.match(sender.messages[0].idempotencyKey, /^otp-[0-9a-f]{64}$/); + assert.doesNotMatch(sender.messages[0].idempotencyKey, /123456|person/); +}); + +test("user and admin auth surfaces have host-only isolated cookies", () => { + const authorizer: AdminUserAuthorizer = async () => true; + const userOptions = buildAuthOptions({ + surface: "user", + config, + database, + emailSender: new FakeEmailOtpSender(), + }); + const adminOptions = buildAuthOptions({ + surface: "admin", + config, + database, + emailSender: new FakeEmailOtpSender(), + authorizeAdminUser: authorizer, + }); + + assert.equal(userOptions.baseURL, config.userOrigin); + assert.equal(adminOptions.baseURL, config.adminOrigin); + assert.equal(userOptions.secret, config.userSecret); + assert.equal(adminOptions.secret, config.adminSecret); + assert.equal(userOptions.advanced?.cookiePrefix, "jyotisha-user"); + assert.equal(adminOptions.advanced?.cookiePrefix, "jyotisha-admin"); + for (const options of [userOptions, adminOptions]) { + const attributes = options.advanced?.defaultCookieAttributes; + assert.equal(attributes?.secure, true); + assert.equal(attributes?.httpOnly, true); + assert.equal(attributes?.sameSite, "lax"); + assert.equal(attributes?.path, "/"); + assert.equal(attributes && "domain" in attributes, false); + assert.equal(options.advanced?.crossSubDomainCookies, undefined); + } +}); + +test("admin surface disables sign-up and rejects non-admin session creation", async () => { + const checkedUserIds: string[] = []; + const options = buildAuthOptions({ + surface: "admin", + config, + database, + emailSender: new FakeEmailOtpSender(), + authorizeAdminUser: async (userId) => { + checkedUserIds.push(userId); + return userId === "admin-user-id"; + }, + }); + const emailPlugin = options.plugins?.find( + (plugin) => plugin.id === "email-otp", + ); + assert.ok(emailPlugin); + + const before = options.databaseHooks?.session?.create?.before; + assert.ok(before); + const session = { + id: "session-id", + token: "session-token", + userId: "ordinary-user-id", + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }; + assert.equal(await before(session, null), false); + assert.equal( + await before({ ...session, userId: "admin-user-id" }, null), + undefined, + ); + assert.deepEqual(checkedUserIds, ["ordinary-user-id", "admin-user-id"]); + + const otpOptions = createEmailOtpOptions( + new FakeEmailOtpSender(), + config.adminSecret, + true, + ); + assert.equal(otpOptions.disableSignUp, true); +}); + +test("admin surface requires a server-side persisted-role authorizer", () => { + assert.throws( + () => + buildAuthOptions({ + surface: "admin", + config, + database, + emailSender: new FakeEmailOtpSender(), + }), + /admin user authorizer is required/, + ); +}); + +test("identity pool forces the identity search path", async () => { + const pool = createIdentityPool(config.databaseUrl); + + try { + assert.equal(pool.options.connectionString, config.databaseUrl); + assert.equal(pool.options.options, "-c search_path=identity,pg_catalog"); + assert.equal(pool.options.max, 10); + } finally { + await pool.end(); + } +}); + +test("database admin authorizer requires a current persisted admin role", async () => { + const rowsByUser = new Map>([ + ["admin", { role: "user,admin", banned: false, ban_expires: null }], + ["user", { role: "user", banned: false, ban_expires: null }], + ["banned", { role: "admin", banned: true, ban_expires: null }], + [ + "expired-ban", + { + role: "admin", + banned: true, + ban_expires: new Date(Date.now() - 60_000), + }, + ], + ]); + const queries: Array<{ sql: string; values: unknown[] }> = []; + const pool = { + async query(sql: string, values: unknown[]) { + queries.push({ sql, values }); + const row = rowsByUser.get(String(values[0])); + return { rows: row ? [row] : [] }; + }, + } as unknown as Pool; + const authorize = createDatabaseAdminAuthorizer(pool); + + assert.equal(await authorize("admin"), true); + assert.equal(await authorize("user"), false); + assert.equal(await authorize("banned"), false); + assert.equal(await authorize("expired-ban"), true); + assert.equal(await authorize("missing"), false); + assert.equal(queries.length, 5); + assert.match(queries[0].sql, /from identity\.users/); + assert.deepEqual(queries[0].values, ["admin"]); +});