242 lines
6.4 KiB
TypeScript
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!;
|
|
}
|