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.
136 lines
3.6 KiB
TypeScript
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,
|
|
}),
|
|
],
|
|
};
|
|
}
|