Files
Jyotisha/frontend/src/modules/identity/auth-factory.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

136 lines
3.6 KiB
TypeScript

import { createHmac } from "node:crypto";
import type { Pool } from "pg";
import type { BetterAuthOptions } from "better-auth";
import { admin, emailOTP, twoFactor, type EmailOTPOptions } from "better-auth/plugins";
import type { SelfHostedIdentityConfig } from "./config.ts";
import type { EmailOtpSender } from "./contracts.ts";
import { identityModelMapping } from "./model.ts";
export type AdminUserAuthorizer = (userId: string) => Promise<boolean>;
interface BuildAuthOptionsInput {
config: SelfHostedIdentityConfig;
database: Pool;
emailSender: EmailOtpSender;
}
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,
testOtp: string | null = null,
): EmailOTPOptions {
return {
otpLength: 6,
expiresIn: 300,
allowedAttempts: 3,
resendStrategy: "rotate",
storeOTP: "hashed",
disableSignUp,
rateLimit: { window: 60, max: 3 },
...(testOtp
? {
generateOTP: () => testOtp,
async sendVerificationOTP({ email, otp, type }) {
// Test channel: no real email is delivered; the fixed code is
// surfaced by the login UI when IDENTITY_TEST_OTP is configured.
await sender.send({
email,
otp,
type,
idempotencyKey: otpIdempotencyKey(secret, email, otp, type),
});
},
}
: {
async sendVerificationOTP({ email, otp, type }) {
await sender.send({
email,
otp,
type,
idempotencyKey: otpIdempotencyKey(secret, email, otp, type),
});
},
}),
};
}
export function buildAuthOptions({
config,
database,
emailSender,
}: BuildAuthOptionsInput): BetterAuthOptions {
return {
appName: "Jyotisha",
baseURL: config.userOrigin,
basePath: "/api/auth",
secret: config.userSecret,
database,
trustedOrigins: [config.userOrigin, config.adminOrigin],
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: "jyotisha-user",
defaultCookieAttributes: {
secure: true,
httpOnly: true,
sameSite: "lax",
path: "/",
},
},
emailAndPassword: {
enabled: true,
disableSignUp: true,
minPasswordLength: 8,
maxPasswordLength: 128,
revokeSessionsOnPasswordReset: true,
},
plugins: [
emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false, config.testOtp)),
twoFactor({
issuer: "Jyotisha Admin",
twoFactorTable: "two_factors",
twoFactorCookieMaxAge: 300,
accountLockout: {
enabled: true,
maxFailedAttempts: 5,
durationSeconds: 900,
},
schema: identityModelMapping.twoFactor,
}),
admin({
defaultRole: "user",
adminRoles: ["admin"],
schema: identityModelMapping.admin,
}),
],
};
}