feat(identity): isolate auth routes by host
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user