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;
|
||||
}
|
||||
Reference in New Issue
Block a user