162 lines
4.7 KiB
TypeScript
162 lines
4.7 KiB
TypeScript
import {
|
|
createHmac,
|
|
randomBytes,
|
|
timingSafeEqual,
|
|
} from "node:crypto";
|
|
import {
|
|
loadRequiredWebConfig,
|
|
resolveHostOnlyCookiePolicy,
|
|
} from "@hivegrid/config";
|
|
import type { AdminMailConnectionProvider } from "@hivegrid/integrations";
|
|
|
|
const SECURE_COOKIE = "__Host-hivegrid_admin_mail_oauth";
|
|
const LOCAL_COOKIE = "hivegrid_admin_mail_oauth";
|
|
const TTL_MS = 10 * 60 * 1_000;
|
|
|
|
export type AdminEmailOAuthState = {
|
|
readonly state: string;
|
|
readonly adminUserId: string;
|
|
readonly provider: AdminMailConnectionProvider;
|
|
readonly createdAt: number;
|
|
};
|
|
|
|
export function createAdminEmailOAuthState(input: {
|
|
readonly adminUserId: string;
|
|
readonly provider: AdminMailConnectionProvider;
|
|
readonly now?: () => number;
|
|
readonly secret?: string;
|
|
}): { readonly state: string; readonly cookie: string } {
|
|
const payload: AdminEmailOAuthState = {
|
|
state: randomBytes(32).toString("hex"),
|
|
adminUserId: required(input.adminUserId),
|
|
provider: input.provider,
|
|
createdAt: input.now?.() ?? Date.now(),
|
|
};
|
|
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString(
|
|
"base64url"
|
|
);
|
|
const signature = sign(encoded, resolveSecret(input.secret));
|
|
const policy = cookiePolicy();
|
|
return {
|
|
state: payload.state,
|
|
cookie: [
|
|
`${policy.name}=${encoded}.${signature}`,
|
|
`Path=${policy.path}`,
|
|
"HttpOnly",
|
|
"SameSite=Lax",
|
|
`Max-Age=${Math.ceil(TTL_MS / 1_000)}`,
|
|
...(policy.secure ? ["Secure"] : []),
|
|
].join("; "),
|
|
};
|
|
}
|
|
|
|
export function consumeAdminEmailOAuthState(
|
|
request: Request,
|
|
input: {
|
|
readonly expectedState: string;
|
|
readonly expectedProvider: AdminMailConnectionProvider;
|
|
readonly expectedAdminUserId: string;
|
|
readonly now?: () => number;
|
|
readonly secret?: string;
|
|
}
|
|
):
|
|
| {
|
|
readonly ok: true;
|
|
readonly clearCookie: string;
|
|
}
|
|
| {
|
|
readonly ok: false;
|
|
readonly reason:
|
|
| "missing"
|
|
| "malformed"
|
|
| "invalid_signature"
|
|
| "expired"
|
|
| "mismatch";
|
|
readonly clearCookie: string;
|
|
} {
|
|
const policy = cookiePolicy();
|
|
const clearCookie = [
|
|
`${policy.name}=`,
|
|
`Path=${policy.path}`,
|
|
"HttpOnly",
|
|
"SameSite=Lax",
|
|
"Max-Age=0",
|
|
...(policy.secure ? ["Secure"] : []),
|
|
].join("; ");
|
|
const value = readCookie(request, policy.name);
|
|
if (!value) return { ok: false, reason: "missing", clearCookie };
|
|
const [encoded, signature, ...rest] = value.split(".");
|
|
if (!encoded || !signature || rest.length > 0) {
|
|
return { ok: false, reason: "malformed", clearCookie };
|
|
}
|
|
const expectedSignature = sign(encoded, resolveSecret(input.secret));
|
|
const left = Buffer.from(signature);
|
|
const right = Buffer.from(expectedSignature);
|
|
if (left.length !== right.length || !timingSafeEqual(left, right)) {
|
|
return { ok: false, reason: "invalid_signature", clearCookie };
|
|
}
|
|
let payload: AdminEmailOAuthState;
|
|
try {
|
|
payload = JSON.parse(
|
|
Buffer.from(encoded, "base64url").toString("utf8")
|
|
) as AdminEmailOAuthState;
|
|
} catch {
|
|
return { ok: false, reason: "malformed", clearCookie };
|
|
}
|
|
const now = input.now?.() ?? Date.now();
|
|
if (
|
|
!Number.isFinite(payload.createdAt) ||
|
|
payload.createdAt > now + 60_000 ||
|
|
now - payload.createdAt > TTL_MS
|
|
) {
|
|
return { ok: false, reason: "expired", clearCookie };
|
|
}
|
|
if (
|
|
payload.state !== input.expectedState ||
|
|
payload.provider !== input.expectedProvider ||
|
|
payload.adminUserId !== input.expectedAdminUserId
|
|
) {
|
|
return { ok: false, reason: "mismatch", clearCookie };
|
|
}
|
|
return { ok: true, clearCookie };
|
|
}
|
|
|
|
function resolveSecret(override?: string): string {
|
|
const secret = override ?? loadRequiredWebConfig().sessionSecret;
|
|
if (!secret || Buffer.byteLength(secret, "utf8") < 32) {
|
|
throw new Error("SESSION_SECRET is required for admin mail OAuth state.");
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function cookiePolicy() {
|
|
const config = loadRequiredWebConfig();
|
|
return resolveHostOnlyCookiePolicy({
|
|
nodeEnv: config.nodeEnv,
|
|
routing: config.routing,
|
|
secureName: SECURE_COOKIE,
|
|
localName: LOCAL_COOKIE,
|
|
maxAge: Math.ceil(TTL_MS / 1_000),
|
|
});
|
|
}
|
|
|
|
function sign(value: string, secret: string): string {
|
|
return createHmac("sha256", secret).update(value).digest("base64url");
|
|
}
|
|
|
|
function readCookie(request: Request, name: string): string | null {
|
|
for (const segment of request.headers.get("cookie")?.split(/;\s*/) ?? []) {
|
|
const separator = segment.indexOf("=");
|
|
if (separator > 0 && segment.slice(0, separator) === name) {
|
|
return segment.slice(separator + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function required(value: string): string {
|
|
const normalized = value.trim();
|
|
if (!normalized) throw new Error("adminUserId is required");
|
|
return normalized;
|
|
}
|