feat(identity): configure better auth surfaces
This commit is contained in:
@@ -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<boolean>;
|
||||
|
||||
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;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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<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())
|
||||
.includes("admin");
|
||||
};
|
||||
}
|
||||
|
||||
export interface IdentityAuthServices {
|
||||
pool: Pool;
|
||||
user: ReturnType<typeof betterAuth>;
|
||||
admin: ReturnType<typeof betterAuth>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ const subjectByType: Record<EmailOtpType, string> = {
|
||||
"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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 <login@staging.jyotisha.chat>",
|
||||
};
|
||||
|
||||
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<string, Record<string, unknown>>([
|
||||
["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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user