Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/admin-email-oauth-state.server.ts
T

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