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;
}