Files
Jyotisha/frontend/src/modules/identity/auth.ts
T
Jesse 2697d16ef1
Staging Backend Quality Gate / validate (push) Has been cancelled
Staging Backend Quality Gate / publish (push) Has been cancelled
feat(identity): test-only fixed OTP channel and rectification candidate cards
- 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.
2026-08-10 19:38:27 +08:00

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;
}