2697d16ef1
- IDENTITY_TEST_OTP env: when set to a 6-digit code, no real email is delivered; login page surfaces the fixed code so testers can register and sign in without a mailbox. Opt-in, never set in production. - email-otp-login: test-channel notice with the pinned code. - Rectification candidate list restyled with the project design system (warm canvas, action color, display serif, soft shadow, hover lift). - Regression tests for config parsing, pinned OTP generation, login UI notice, and candidate card styles.
175 lines
4.5 KiB
TypeScript
175 lines
4.5 KiB
TypeScript
import { betterAuth } from "better-auth";
|
|
import { Pool } from "pg";
|
|
|
|
import { buildAuthOptions, type AdminUserAuthorizer } from "./auth-factory.ts";
|
|
import {
|
|
isSelfHostedIdentityEnabled,
|
|
readSelfHostedIdentityConfig,
|
|
type SelfHostedIdentityConfig,
|
|
} from "./config.ts";
|
|
import type { EmailOtpSender } from "./contracts.ts";
|
|
import { FakeEmailOtpSender } from "./email/fake-email-otp-sender.ts";
|
|
import { ResendEmailOtpSender } from "./email/resend-email-otp-sender.ts";
|
|
|
|
interface AdminRoleRow {
|
|
role: string;
|
|
banned: boolean;
|
|
ban_expires: Date | null;
|
|
}
|
|
|
|
export type IdentityAdminSurfaceRole = "admin";
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
function createDatabaseRoleAuthorizer(
|
|
pool: Pool,
|
|
allowedRoles: ReadonlySet<string>,
|
|
): 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())
|
|
.some((role) => allowedRoles.has(role));
|
|
};
|
|
}
|
|
|
|
export function createDatabaseAdminAuthorizer(
|
|
pool: Pool,
|
|
): AdminUserAuthorizer {
|
|
return createDatabaseRoleAuthorizer(pool, new Set(["admin"]));
|
|
}
|
|
|
|
export function createDatabaseAdminSurfaceAuthorizer(
|
|
pool: Pool,
|
|
): AdminUserAuthorizer {
|
|
return createDatabaseAdminAuthorizer(pool);
|
|
}
|
|
|
|
export interface IdentityAuthServices {
|
|
pool: Pool;
|
|
user: ReturnType<typeof betterAuth>;
|
|
}
|
|
|
|
interface IdentityAuthDependencies {
|
|
pool?: Pool;
|
|
emailSender?: EmailOtpSender;
|
|
}
|
|
|
|
export function createIdentityAuthServices(
|
|
config: SelfHostedIdentityConfig,
|
|
dependencies: IdentityAuthDependencies = {},
|
|
): IdentityAuthServices {
|
|
const pool = dependencies.pool ?? createIdentityPool(config.databaseUrl);
|
|
const emailSender =
|
|
dependencies.emailSender ??
|
|
(config.testOtp
|
|
? new FakeEmailOtpSender()
|
|
: new ResendEmailOtpSender({
|
|
apiKey: config.resendApiKey,
|
|
from: config.resendFrom,
|
|
}));
|
|
return {
|
|
pool,
|
|
user: betterAuth(
|
|
buildAuthOptions({
|
|
config,
|
|
database: pool,
|
|
emailSender,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
|
|
export interface IdentityEmailOtpApi {
|
|
sendVerificationOTP(input: {
|
|
body: { email: string; type: "email-verification" };
|
|
}): Promise<{ success: boolean }>;
|
|
verifyEmailOTP(input: {
|
|
body: { email: string; otp: string };
|
|
}): Promise<unknown>;
|
|
}
|
|
|
|
export function getIdentityEmailOtpApi(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): IdentityEmailOtpApi {
|
|
return getIdentityAuthServices(env).user.api as unknown as IdentityEmailOtpApi;
|
|
}
|
|
|
|
export interface IdentityTwoFactorApi {
|
|
enableTwoFactor(input: {
|
|
body: { password: string };
|
|
headers: Headers;
|
|
asResponse: true;
|
|
}): Promise<Response>;
|
|
verifyTOTP(input: {
|
|
body: { code: string; trustDevice?: boolean };
|
|
headers: Headers;
|
|
asResponse: true;
|
|
}): Promise<Response>;
|
|
verifyBackupCode(input: {
|
|
body: { code: string; disableSession?: boolean };
|
|
headers: Headers;
|
|
asResponse: true;
|
|
}): Promise<Response>;
|
|
generateBackupCodes(input: {
|
|
body: { password: string };
|
|
headers: Headers;
|
|
asResponse: true;
|
|
}): Promise<Response>;
|
|
disableTwoFactor(input: {
|
|
body: { password: string };
|
|
headers: Headers;
|
|
asResponse: true;
|
|
}): Promise<Response>;
|
|
}
|
|
|
|
export function getIdentityTwoFactorApi(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): IdentityTwoFactorApi {
|
|
return getIdentityAuthServices(env).user.api as unknown as IdentityTwoFactorApi;
|
|
}
|
|
|
|
const identityGlobal = globalThis as typeof globalThis & {
|
|
jyotishaIdentityAuth?: IdentityAuthServices;
|
|
};
|
|
|
|
export function getIdentityAuthServices(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): IdentityAuthServices {
|
|
if (!isSelfHostedIdentityEnabled(env)) {
|
|
throw new Error("self-hosted identity is not enabled");
|
|
}
|
|
const config = readSelfHostedIdentityConfig(env);
|
|
|
|
identityGlobal.jyotishaIdentityAuth ??= createIdentityAuthServices(config);
|
|
return identityGlobal.jyotishaIdentityAuth;
|
|
}
|