Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/emailInbox/services/oauthState.server.ts
T
2026-07-08 16:15:37 +02:00

116 lines
4.2 KiB
TypeScript

// VA-P23 · OAuth state signing for the mailbox connect flows (CSRF + context).
//
// The OAuth callbacks at /integrations/email/{google|microsoft}/oauth/callback
// are NOT tenant-scoped (providers redirect to fixed URIs), so tenant + user +
// provider travel inside a SIGNED `state` parameter rather than a cookie. We
// HMAC-SHA256 the JSON payload with CRYPTO_SECRET||SESSION_SECRET (same
// convention as calendar/oauthState.server) and URL-safe-base64 encode it.
// 10-minute TTL bounds the replay window.
import { createHmac, timingSafeEqual } from "node:crypto";
import { loadRequiredWebConfig } from "@hivegrid/config";
const STATE_TTL_MS = 10 * 60 * 1000;
const PAYLOAD_VERSION = "v1";
export type EmailOAuthProvider = "gmail" | "microsoft";
export interface EmailOAuthStatePayload {
readonly tenantId: string;
readonly userId: string;
readonly tenantSlug: string;
readonly provider: EmailOAuthProvider;
/** Epoch milliseconds. */
readonly createdAt: number;
}
export type VerifyEmailStateResult =
| { readonly ok: true; readonly payload: EmailOAuthStatePayload }
| { readonly ok: false; readonly reason: "malformed" | "bad-signature" | "expired" | "wrong-provider" };
function getSecret(override?: string): string {
if (override) return override;
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
const secret = cryptoSecret || sessionSecret;
if (!secret) {
throw new Error("CRYPTO_SECRET or SESSION_SECRET is required for email OAuth state.");
}
return secret;
}
function base64UrlEncode(input: string): string {
return Buffer.from(input, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64UrlDecode(value: string): string {
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
}
function sign(body: string, secret: string): string {
return Buffer.from(createHmac("sha256", secret).update(body).digest()).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** Sign a fresh state token for the consent redirect. */
export function signEmailOAuthState(
input: { tenantId: string; userId: string; tenantSlug: string; provider: EmailOAuthProvider },
options: { now?: () => number; secret?: string } = {},
): string {
const payload: EmailOAuthStatePayload = {
tenantId: input.tenantId,
userId: input.userId,
tenantSlug: input.tenantSlug,
provider: input.provider,
createdAt: options.now ? options.now() : Date.now(),
};
const body = base64UrlEncode(JSON.stringify({ v: PAYLOAD_VERSION, p: payload }));
return `${body}.${sign(body, getSecret(options.secret))}`;
}
/**
* Verify + decode a state token returned by the provider. `expectedProvider`
* pins the callback route to its provider (a Gmail state on the Microsoft
* callback is rejected).
*/
export function verifyEmailOAuthState(
state: string,
expectedProvider: EmailOAuthProvider,
options: { now?: () => number; secret?: string } = {},
): VerifyEmailStateResult {
const dot = state.lastIndexOf(".");
if (dot <= 0) {
return { ok: false, reason: "malformed" };
}
const body = state.slice(0, dot);
const sig = state.slice(dot + 1);
let secret: string;
try {
secret = getSecret(options.secret);
} catch {
return { ok: false, reason: "malformed" };
}
const expected = sign(body, secret);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return { ok: false, reason: "bad-signature" };
}
let decoded: { v: string; p: EmailOAuthStatePayload };
try {
decoded = JSON.parse(base64UrlDecode(body)) as { v: string; p: EmailOAuthStatePayload };
} catch {
return { ok: false, reason: "malformed" };
}
if (decoded.v !== PAYLOAD_VERSION || !decoded.p || typeof decoded.p.createdAt !== "number") {
return { ok: false, reason: "malformed" };
}
if (decoded.p.provider !== expectedProvider) {
return { ok: false, reason: "wrong-provider" };
}
const now = options.now ? options.now() : Date.now();
if (now - decoded.p.createdAt > STATE_TTL_MS) {
return { ok: false, reason: "expired" };
}
return { ok: true, payload: decoded.p };
}