Files
Jyotisha/frontend/src/lib/admin/auth-policy.ts
T
Jesse_Chen 4f6cf5782d
Independent Staging Quality Gate / validate (push) Successful in 12m37s
Independent Staging Quality Gate / publish (push) Successful in 10m0s
fix(admin): remove operation-level email reauthentication
2026-08-16 14:07:57 +08:00

242 lines
6.4 KiB
TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";
import type { IdentityUser } from "@/modules/identity/contracts";
import { normalizeIdentityHost } from "@/modules/identity/host";
export type AdminRole =
| "owner"
| "model_admin"
| "billing_admin"
| "operations"
| "support"
| "auditor";
export const adminPermissions = [
"admin.access",
"admin.customers.read",
"admin.customers.birth_data.read",
"admin.users.read",
"admin.users.manage_roles",
"billing.products.read",
"billing.products.write",
"billing.products.publish",
"billing.orders.read",
"billing.adjustments.write",
"models.read",
"models.write",
"models.test",
"models.publish",
"models.rollback",
"ops.flags.write",
"audit.read",
] as const;
export type AdminPermission = (typeof adminPermissions)[number];
export type AdminAccessResult =
| { allowed: true }
| { allowed: false; status: 401 | 403 };
export const ADMIN_MFA_PROOF_COOKIE = "jyotisha-admin.mfa";
export const ADMIN_MFA_PROOF_TTL_MS = 10 * 60 * 1_000;
type AdminMfaProofContext = {
userId: string;
sessionId: string;
origin: string;
};
type AdminMfaProofClaims = AdminMfaProofContext & {
version: 1;
issuedAt: number;
expiresAt: number;
};
export type AdminMfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
};
export function authorizeAdminAccess(
user: IdentityUser | null,
permissions: readonly string[],
required: AdminPermission,
): AdminAccessResult {
if (!user) return { allowed: false, status: 401 };
return permissions.includes(required)
? { allowed: true }
: { allowed: false, status: 403 };
}
export function isSameOriginAdminMutation(
origin: string | null,
requestUrl: string,
): boolean {
if (!origin) return false;
try {
return origin === new URL(requestUrl).origin;
} catch {
return false;
}
}
function singleForwardedValue(value: string | null): string | null {
const normalized = value?.trim();
return normalized && !normalized.includes(",") ? normalized : null;
}
function configuredAdminOrigin(value: string): URL | null {
try {
const url = new URL(value);
const isLocalhost = url.hostname === "localhost" || url.hostname.endsWith(".localhost");
if (
(url.protocol !== "https:"
&& !(isLocalhost && url.protocol === "http:"))
|| url.username
|| url.password
|| url.pathname !== "/"
|| url.search
|| url.hash
) {
return null;
}
return url;
} catch {
return null;
}
}
export function isTrustedAdminMutationRequest(
request: Request,
adminOriginValue?: string,
): boolean {
const origin = request.headers.get("origin");
const configuredValue = adminOriginValue?.trim();
if (!configuredValue) {
return isSameOriginAdminMutation(origin, request.url);
}
const adminOrigin = configuredAdminOrigin(configuredValue);
if (!adminOrigin || origin !== adminOrigin.origin) return false;
const hasForwardedHost = request.headers.has("x-forwarded-host");
const hasForwardedProto = request.headers.has("x-forwarded-proto");
if (!hasForwardedHost && !hasForwardedProto) {
return isSameOriginAdminMutation(origin, request.url);
}
const forwardedHostValue = request.headers.get("x-forwarded-host");
const forwardedProtoValue = request.headers.get("x-forwarded-proto");
const host = normalizeIdentityHost(request.headers.get("host"));
const forwardedHost = normalizeIdentityHost(forwardedHostValue);
const forwardedProto = singleForwardedValue(forwardedProtoValue)?.toLowerCase();
return Boolean(
host
&& forwardedHost
&& forwardedProto
&& host === forwardedHost
&& forwardedHost === adminOrigin.host.toLowerCase()
&& `${forwardedProto}:` === adminOrigin.protocol,
);
}
export function resolveAdminMfaStatus(
required: boolean,
enrolled: boolean,
verified: boolean,
): AdminMfaStatus {
const currentSessionVerified = enrolled && verified;
return {
required,
enrolled,
verified: currentSessionVerified,
};
}
function signProof(
purpose: string,
payload: string,
proofSecret: string,
sessionToken: string,
): Buffer {
return createHmac("sha256", proofSecret)
.update(purpose)
.update("\0")
.update(sessionToken)
.update("\0")
.update(payload)
.digest();
}
function encodeProof(
purpose: string,
claims: AdminMfaProofClaims,
proofSecret: string,
sessionToken: string,
): string {
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
return `${payload}.${signProof(purpose, payload, proofSecret, sessionToken).toString("base64url")}`;
}
function decodeProof<T extends { version: number; issuedAt: number; expiresAt: number }>(
proof: string | undefined,
purpose: string,
proofSecret: string,
sessionToken: string,
): Partial<T> | null {
if (!proof || proof.length > 2_048) return null;
const parts = proof.split(".");
if (parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
const [payload, encodedSignature] = parts;
const signature = Buffer.from(encodedSignature, "base64url");
const expected = signProof(purpose, payload, proofSecret, sessionToken);
if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null;
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Partial<T>;
} catch {
return null;
}
}
export function issueAdminMfaProof(
context: AdminMfaProofContext,
proofSecret: string,
sessionToken: string,
now = Date.now(),
): string {
return encodeProof("jyotisha-admin-mfa-v1", {
version: 1,
...context,
issuedAt: now,
expiresAt: now + ADMIN_MFA_PROOF_TTL_MS,
} satisfies AdminMfaProofClaims, proofSecret, sessionToken);
}
export function verifyAdminMfaProof(
proof: string | undefined,
context: AdminMfaProofContext,
proofSecret: string,
sessionToken: string,
now = Date.now(),
): boolean {
const claims = decodeProof<AdminMfaProofClaims>(
proof,
"jyotisha-admin-mfa-v1",
proofSecret,
sessionToken,
);
return claims?.version === 1
&& claims.userId === context.userId
&& claims.sessionId === context.sessionId
&& claims.origin === context.origin
&& Number.isSafeInteger(claims.issuedAt)
&& Number.isSafeInteger(claims.expiresAt)
&& claims.expiresAt! - claims.issuedAt! === ADMIN_MFA_PROOF_TTL_MS
&& claims.issuedAt! <= now
&& now < claims.expiresAt!;
}