116 lines
4.2 KiB
TypeScript
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 };
|
|
}
|