feat(identity): isolate auth routes by host

This commit is contained in:
Jesse_Chen
2026-07-21 17:38:29 +08:00
parent 8121add692
commit cf42430244
8 changed files with 561 additions and 3 deletions
@@ -0,0 +1,35 @@
import { toNextJsHandler } from "better-auth/next-js";
import { getIdentityAuthServices } from "@/modules/identity/auth";
import { readIdentityConfig } from "@/modules/identity/config";
import {
createHostIsolatedAuthHandlers,
type IdentityAuthHandlers,
} from "@/modules/identity/host";
export const dynamic = "force-dynamic";
async function dispatch(
method: keyof IdentityAuthHandlers,
request: Request,
): Promise<Response> {
const config = readIdentityConfig(process.env);
if (config.provider !== "self-hosted") {
return new Response("Not found", { status: 404 });
}
const services = getIdentityAuthServices();
const handlers = createHostIsolatedAuthHandlers(config, {
user: toNextJsHandler(services.user),
admin: toNextJsHandler(services.admin),
});
return handlers[method](request);
}
export function GET(request: Request): Promise<Response> {
return dispatch("GET", request);
}
export function POST(request: Request): Promise<Response> {
return dispatch("POST", request);
}
@@ -1,6 +1,6 @@
import { createHmac } from "node:crypto";
import type { Pool } from "pg";
import type { BetterAuthOptions } from "better-auth";
import { APIError, type BetterAuthOptions } from "better-auth";
import { admin, emailOTP, type EmailOTPOptions } from "better-auth/plugins";
import type { SelfHostedIdentityConfig } from "./config.ts";
@@ -114,7 +114,11 @@ export function buildAuthOptions({
session: {
create: {
async before(session: { userId: string }) {
if (!(await authorizeAdminUser!(session.userId))) return false;
if (!(await authorizeAdminUser!(session.userId))) {
throw new APIError("FORBIDDEN", {
message: "Administrator access required",
});
}
},
},
},
+74
View File
@@ -0,0 +1,74 @@
import type { SelfHostedIdentityConfig } from "./config.ts";
import type { IdentitySurface } from "./contracts.ts";
export type IdentityRequestHandler = (
request: Request,
) => Response | Promise<Response>;
export interface IdentityAuthHandlers {
GET: IdentityRequestHandler;
POST: IdentityRequestHandler;
}
function normalizeHost(value: string | null): string | null {
if (!value || value !== value.trim() || /[\s,@/\\]/.test(value)) return null;
try {
const url = new URL(`https://${value}`);
if (
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
return null;
}
return url.host.toLowerCase();
} catch {
return null;
}
}
export function resolveIdentitySurface(
hostHeader: string | null,
config: SelfHostedIdentityConfig,
): IdentitySurface | null {
const host = normalizeHost(hostHeader);
if (!host) return null;
const userHost = new URL(config.userOrigin).host.toLowerCase();
const adminHost = new URL(config.adminOrigin).host.toLowerCase();
if (host === userHost) return "user";
if (host === adminHost) return "admin";
return null;
}
function isAdminEndpoint(request: Request): boolean {
try {
const path = decodeURIComponent(new URL(request.url).pathname);
return /^\/api\/auth\/+admin(?:\/|$)/i.test(path);
} catch {
return true;
}
}
export function createHostIsolatedAuthHandlers(
config: SelfHostedIdentityConfig,
handlers: Record<IdentitySurface, IdentityAuthHandlers>,
): IdentityAuthHandlers {
const dispatch =
(method: keyof IdentityAuthHandlers): IdentityRequestHandler =>
async (request) => {
const surface = resolveIdentitySurface(request.headers.get("host"), config);
if (!surface) {
return new Response("Unrecognized identity host", { status: 421 });
}
if (surface === "user" && isAdminEndpoint(request)) {
return new Response("Not found", { status: 404 });
}
return handlers[surface][method](request);
};
return { GET: dispatch("GET"), POST: dispatch("POST") };
}
+80
View File
@@ -0,0 +1,80 @@
import type { IdentitySession, IdentityUser } from "./contracts.ts";
interface RawIdentitySession {
session: { expiresAt: Date | string };
user: {
id: string;
email: string;
emailVerified: boolean;
name: string;
image?: string | null;
role?: string | null;
};
}
export interface IdentitySessionReader {
getSession(input: { headers: Headers }): Promise<RawIdentitySession | null>;
}
export class IdentityAuthorizationError extends Error {
constructor(
message: string,
readonly status: 401 | 403,
) {
super(message);
this.name = "IdentityAuthorizationError";
}
}
function parseRoles(role: string | null | undefined): string[] {
const roles = (role ?? "user")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return [...new Set(roles.length ? roles : ["user"])];
}
export async function readIdentitySession(
reader: IdentitySessionReader,
requestHeaders: Headers,
): Promise<IdentitySession | null> {
const value = await reader.getSession({ headers: requestHeaders });
if (!value) return null;
const expiresAt = new Date(value.session.expiresAt);
if (!Number.isFinite(expiresAt.getTime())) {
throw new Error("identity session has an invalid expiry");
}
return {
expiresAt,
user: {
id: value.user.id,
email: value.user.email.trim().toLowerCase(),
emailVerified: value.user.emailVerified,
name: value.user.name,
image: value.user.image ?? null,
role: parseRoles(value.user.role),
},
};
}
export async function requireIdentityUser(
reader: IdentitySessionReader,
requestHeaders: Headers,
): Promise<IdentityUser> {
const session = await readIdentitySession(reader, requestHeaders);
if (!session) throw new IdentityAuthorizationError("Authentication required", 401);
return session.user;
}
export async function requireIdentityAdmin(
reader: IdentitySessionReader,
requestHeaders: Headers,
): Promise<IdentityUser> {
const user = await requireIdentityUser(reader, requestHeaders);
if (!user.role.includes("admin")) {
throw new IdentityAuthorizationError("Administrator access required", 403);
}
return user;
}
+4 -1
View File
@@ -145,7 +145,10 @@ test("admin surface disables sign-up and rejects non-admin session creation", as
createdAt: new Date(),
updatedAt: new Date(),
};
assert.equal(await before(session, null), false);
await assert.rejects(
before(session, null),
/Administrator access required/,
);
assert.equal(
await before({ ...session, userId: "admin-user-id" }, null),
undefined,
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { toNextJsHandler } from "better-auth/next-js";
import {
createIdentityAuthServices,
createIdentityPool,
} from "../src/modules/identity/auth.ts";
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
import { createHostIsolatedAuthHandlers } from "../src/modules/identity/host.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
);
const migrationsDirectory = fileURLToPath(
new URL("../db/migrations", import.meta.url),
);
function request(
host: string,
path: string,
body: Record<string, unknown>,
): Request {
return new Request(`https://${host}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
host,
origin: `https://${host}`,
},
body: JSON.stringify(body),
});
}
test("Better Auth completes OTP sign-in against the migrated identity schema with isolated cookies", async () => {
const fixture = startPostgresFixture();
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: {
...process.env,
MIGRATIONS_DIRECTORY: migrationsDirectory,
SCHEMA_DATABASE_URL: fixture.connectionUrl(
"schema_owner",
"schema-owner-test-password",
),
},
});
assert.equal(migration.status, 0, migration.stderr);
const config: SelfHostedIdentityConfig = {
provider: "self-hosted",
databaseUrl: fixture.connectionUrl(
"identity_runtime",
"identity-runtime-test-password",
),
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",
resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
};
const sender = new FakeEmailOtpSender();
const pool = createIdentityPool(config.databaseUrl);
const services = createIdentityAuthServices(config, {
pool,
emailSender: sender,
});
const handlers = createHostIsolatedAuthHandlers(config, {
user: toNextJsHandler(services.user),
admin: toNextJsHandler(services.admin),
});
try {
const userSend = await handlers.POST(
request(
"staging.jyotisha.chat",
"/api/auth/email-otp/send-verification-otp",
{ email: "person@example.com", type: "sign-in" },
),
);
assert.equal(userSend.status, 200, await userSend.text());
assert.equal(sender.messages.length, 1);
const userSignIn = await handlers.POST(
request("staging.jyotisha.chat", "/api/auth/sign-in/email-otp", {
email: "person@example.com",
otp: sender.messages[0].otp,
}),
);
const userCookie = userSignIn.headers.get("set-cookie") ?? "";
assert.equal(userSignIn.status, 200, await userSignIn.text());
assert.match(userCookie, /jyotisha-user\.session_token=/);
assert.doesNotMatch(userCookie, /jyotisha-admin/);
assert.match(userCookie, /HttpOnly/i);
assert.match(userCookie, /Secure/i);
assert.match(userCookie, /SameSite=Lax/i);
assert.doesNotMatch(userCookie, /Domain=/i);
assert.equal(fixture.psql("select count(*) from identity.users"), "1");
assert.equal(fixture.psql("select count(*) from identity.sessions"), "1");
const adminSend = await handlers.POST(
request(
"admin.staging.jyotisha.chat",
"/api/auth/email-otp/send-verification-otp",
{ email: "person@example.com", type: "sign-in" },
),
);
assert.equal(adminSend.status, 200, await adminSend.text());
const deniedAdminSignIn = await handlers.POST(
request(
"admin.staging.jyotisha.chat",
"/api/auth/sign-in/email-otp",
{ email: "person@example.com", otp: sender.messages[1].otp },
),
);
assert.equal(deniedAdminSignIn.status, 403);
assert.equal(deniedAdminSignIn.headers.has("set-cookie"), false);
assert.equal(fixture.psql("select count(*) from identity.sessions"), "1");
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
"update identity.users set role = 'user,admin' where email = 'person@example.com'",
);
const promotedSend = await handlers.POST(
request(
"admin.staging.jyotisha.chat",
"/api/auth/email-otp/send-verification-otp",
{ email: "person@example.com", type: "sign-in" },
),
);
assert.equal(promotedSend.status, 200, await promotedSend.text());
const adminSignIn = await handlers.POST(
request(
"admin.staging.jyotisha.chat",
"/api/auth/sign-in/email-otp",
{ email: "person@example.com", otp: sender.messages[2].otp },
),
);
const adminCookie = adminSignIn.headers.get("set-cookie") ?? "";
assert.equal(adminSignIn.status, 200, await adminSignIn.text());
assert.match(adminCookie, /jyotisha-admin\.session_token=/);
assert.doesNotMatch(adminCookie, /jyotisha-user/);
assert.doesNotMatch(adminCookie, /Domain=/i);
assert.equal(fixture.psql("select count(*) from identity.sessions"), "2");
} finally {
await pool.end();
fixture.stop();
}
});
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
import {
createHostIsolatedAuthHandlers,
resolveIdentitySurface,
} from "../src/modules/identity/host.ts";
const config: SelfHostedIdentityConfig = {
provider: "self-hosted",
databaseUrl: "postgresql://identity_runtime:test@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",
resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
};
test("identity surface matching is exact, case-insensitive, and port-normalized", () => {
assert.equal(resolveIdentitySurface("staging.jyotisha.chat", config), "user");
assert.equal(resolveIdentitySurface("STAGING.JYOTISHA.CHAT:443", config), "user");
assert.equal(
resolveIdentitySurface("admin.staging.jyotisha.chat", config),
"admin",
);
for (const host of [
null,
"",
"evil-staging.jyotisha.chat",
"staging.jyotisha.chat.evil.example",
"staging.jyotisha.chat,evil.example",
"staging.jyotisha.chat/path",
"user@staging.jyotisha.chat",
" staging.jyotisha.chat",
]) {
assert.equal(resolveIdentitySurface(host, config), null, String(host));
}
});
test("auth route dispatches only to the exact matching host", async () => {
const calls: string[] = [];
const handlers = createHostIsolatedAuthHandlers(config, {
user: {
GET: async () => {
calls.push("user:get");
return new Response("user");
},
POST: async () => {
calls.push("user:post");
return new Response("user");
},
},
admin: {
GET: async () => {
calls.push("admin:get");
return new Response("admin");
},
POST: async () => {
calls.push("admin:post");
return new Response("admin");
},
},
});
const userResponse = await handlers.POST(
new Request("https://internal/api/auth/email-otp/send-verification-otp", {
method: "POST",
headers: { host: "staging.jyotisha.chat" },
}),
);
assert.equal(await userResponse.text(), "user");
const adminResponse = await handlers.GET(
new Request("https://internal/api/auth/get-session", {
headers: { host: "admin.staging.jyotisha.chat" },
}),
);
assert.equal(await adminResponse.text(), "admin");
assert.deepEqual(calls, ["user:post", "admin:get"]);
});
test("unknown hosts fail closed before an auth handler reads cookies", async () => {
let calls = 0;
const handler = async () => {
calls += 1;
return new Response("unexpected");
};
const handlers = createHostIsolatedAuthHandlers(config, {
user: { GET: handler, POST: handler },
admin: { GET: handler, POST: handler },
});
const response = await handlers.GET(
new Request("https://internal/api/auth/get-session", {
headers: {
host: "staging.jyotisha.chat.evil.example",
cookie: "jyotisha-admin.session_token=attacker-controlled",
},
}),
);
assert.equal(response.status, 421);
assert.equal(calls, 0);
assert.equal(response.headers.has("set-cookie"), false);
});
test("user host cannot reach Better Auth admin endpoints", async () => {
let calls = 0;
const handler = async () => {
calls += 1;
return new Response("unexpected");
};
const handlers = createHostIsolatedAuthHandlers(config, {
user: { GET: handler, POST: handler },
admin: { GET: handler, POST: handler },
});
const response = await handlers.POST(
new Request("https://internal/api/auth/admin/set-role", {
method: "POST",
headers: { host: "staging.jyotisha.chat" },
}),
);
assert.equal(response.status, 404);
assert.equal(calls, 0);
});
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
IdentityAuthorizationError,
readIdentitySession,
requireIdentityAdmin,
requireIdentityUser,
type IdentitySessionReader,
} from "../src/modules/identity/session.ts";
function readerFor(role: string | null): IdentitySessionReader {
return {
async getSession() {
if (role === null) return null;
return {
session: { expiresAt: new Date("2030-01-01T00:00:00.000Z") },
user: {
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "Person@Example.com",
emailVerified: true,
name: "Person",
image: null,
role,
},
};
},
};
}
test("identity session mapper returns a narrow normalized DTO", async () => {
const session = await readIdentitySession(readerFor("user,admin"), new Headers());
assert.deepEqual(session, {
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
user: {
id: "018f4e6d-7a11-7000-8000-000000000001",
email: "person@example.com",
emailVerified: true,
name: "Person",
image: null,
role: ["user", "admin"],
},
});
assert.equal("token" in (session ?? {}), false);
});
test("require user rejects a missing server-side session", async () => {
await assert.rejects(
requireIdentityUser(readerFor(null), new Headers({ cookie: "present=1" })),
(error: unknown) => {
assert.ok(error instanceof IdentityAuthorizationError);
assert.equal(error.status, 401);
return true;
},
);
});
test("require admin checks persisted session roles, not cookie presence", async () => {
await assert.rejects(
requireIdentityAdmin(
readerFor("user"),
new Headers({ cookie: "jyotisha-admin.session_token=present" }),
),
(error: unknown) => {
assert.ok(error instanceof IdentityAuthorizationError);
assert.equal(error.status, 403);
return true;
},
);
const admin = await requireIdentityAdmin(
readerFor("user,admin"),
new Headers(),
);
assert.deepEqual(admin.role, ["user", "admin"]);
});