diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..b7f0834d --- /dev/null +++ b/frontend/src/app/api/auth/[...all]/route.ts @@ -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 { + 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 { + return dispatch("GET", request); +} + +export function POST(request: Request): Promise { + return dispatch("POST", request); +} diff --git a/frontend/src/modules/identity/auth-factory.ts b/frontend/src/modules/identity/auth-factory.ts index 1f9693ab..dbd9f8ec 100644 --- a/frontend/src/modules/identity/auth-factory.ts +++ b/frontend/src/modules/identity/auth-factory.ts @@ -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", + }); + } }, }, }, diff --git a/frontend/src/modules/identity/host.ts b/frontend/src/modules/identity/host.ts new file mode 100644 index 00000000..7a7435da --- /dev/null +++ b/frontend/src/modules/identity/host.ts @@ -0,0 +1,74 @@ +import type { SelfHostedIdentityConfig } from "./config.ts"; +import type { IdentitySurface } from "./contracts.ts"; + +export type IdentityRequestHandler = ( + request: Request, +) => Response | Promise; + +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, +): 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") }; +} diff --git a/frontend/src/modules/identity/session.ts b/frontend/src/modules/identity/session.ts new file mode 100644 index 00000000..0c2fd6e1 --- /dev/null +++ b/frontend/src/modules/identity/session.ts @@ -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; +} + +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 { + 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 { + 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 { + const user = await requireIdentityUser(reader, requestHeaders); + if (!user.role.includes("admin")) { + throw new IdentityAuthorizationError("Administrator access required", 403); + } + return user; +} diff --git a/frontend/tests/identity-auth-factory.test.ts b/frontend/tests/identity-auth-factory.test.ts index 0f8e3aea..5e4bf342 100644 --- a/frontend/tests/identity-auth-factory.test.ts +++ b/frontend/tests/identity-auth-factory.test.ts @@ -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, diff --git a/frontend/tests/identity-auth-integration.test.ts b/frontend/tests/identity-auth-integration.test.ts new file mode 100644 index 00000000..1021f569 --- /dev/null +++ b/frontend/tests/identity-auth-integration.test.ts @@ -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, +): 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 ", + }; + 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(); + } +}); diff --git a/frontend/tests/identity-host-routing.test.ts b/frontend/tests/identity-host-routing.test.ts new file mode 100644 index 00000000..3cca107f --- /dev/null +++ b/frontend/tests/identity-host-routing.test.ts @@ -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 ", +}; + +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); +}); diff --git a/frontend/tests/identity-session.test.ts b/frontend/tests/identity-session.test.ts new file mode 100644 index 00000000..162bf43f --- /dev/null +++ b/frontend/tests/identity-session.test.ts @@ -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"]); +});