mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 20:57:03 +00:00
* Reject malformed CoinPay webhook signatures * Fix webhook signature type guard --------- Co-authored-by: Codex Microtask Operator <codex-microtask@example.com>
203 lines
6.4 KiB
TypeScript
203 lines
6.4 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
// Server-side CoinPay + session helpers, ported from the legacy server.js so the
|
|
// Next API routes keep identical behavior (eligibility-driven payment rails,
|
|
// signed webhook verification, and HMAC-signed OAuth session cookies).
|
|
|
|
export const COINPAY_OAUTH_STATE_COOKIE = "logicsrc_coinpay_oauth_state";
|
|
export const COINPAY_SESSION_COOKIE = "logicsrc_coinpay_session";
|
|
|
|
export interface MerchantEligibility {
|
|
accepts_card: boolean;
|
|
accepts_crypto: boolean;
|
|
chains: string[];
|
|
}
|
|
|
|
export interface PaymentRail {
|
|
method: "both" | "card" | "crypto";
|
|
currency: string;
|
|
blockchain: string | null;
|
|
}
|
|
|
|
export interface CoinPayOAuthConfig {
|
|
clientId: string;
|
|
clientSecret: string;
|
|
redirectUri: string;
|
|
issuer: string;
|
|
scopes: string;
|
|
}
|
|
|
|
export function parseJson(text: string): Record<string, unknown> {
|
|
try {
|
|
return text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export async function fetchMerchantEligibility(
|
|
apiUrl: string,
|
|
apiKey: string | undefined,
|
|
merchantId: string | undefined
|
|
): Promise<MerchantEligibility | null> {
|
|
if (!merchantId) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const url = new URL("/api/payments/merchant-eligibility", apiUrl);
|
|
url.searchParams.set("merchant_id", merchantId);
|
|
const response = await fetch(url, {
|
|
headers: { authorization: `Bearer ${apiKey ?? ""}` }
|
|
});
|
|
const text = await response.text();
|
|
const payload = parseJson(text);
|
|
|
|
if (!response.ok || payload.success !== true) {
|
|
console.warn("[coinpay] merchant eligibility unavailable", {
|
|
status: response.status,
|
|
error: (payload.error as string) || text.slice(0, 120)
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
accepts_card: payload.accepts_card === true,
|
|
accepts_crypto: payload.accepts_crypto === true,
|
|
chains: Array.isArray(payload.chains)
|
|
? (payload.chains as unknown[]).filter((chain): chain is string => typeof chain === "string")
|
|
: []
|
|
};
|
|
} catch (error) {
|
|
console.warn("[coinpay] merchant eligibility request failed", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function choosePaymentRail(
|
|
eligibility: MerchantEligibility | null,
|
|
configuredBlockchain: string
|
|
): PaymentRail | null {
|
|
const cryptoCurrency = configuredBlockchain.toLowerCase();
|
|
|
|
if (!eligibility) {
|
|
return { method: "both", currency: cryptoCurrency, blockchain: configuredBlockchain };
|
|
}
|
|
|
|
const configuredChainAvailable = eligibility.chains
|
|
.map((chain) => chain.toUpperCase())
|
|
.includes(configuredBlockchain.toUpperCase());
|
|
const acceptsConfiguredCrypto = eligibility.accepts_crypto && configuredChainAvailable;
|
|
|
|
if (eligibility.accepts_card && acceptsConfiguredCrypto) {
|
|
return { method: "both", currency: cryptoCurrency, blockchain: configuredBlockchain };
|
|
}
|
|
|
|
if (eligibility.accepts_card) {
|
|
return { method: "card", currency: "card", blockchain: null };
|
|
}
|
|
|
|
if (acceptsConfiguredCrypto) {
|
|
return { method: "crypto", currency: cryptoCurrency, blockchain: configuredBlockchain };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function verifyCoinPayWebhook(
|
|
rawBody: string,
|
|
signatureHeader: string | null | undefined,
|
|
secret: string
|
|
): boolean {
|
|
if (!signatureHeader) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const parts = signatureHeader.split(",").map((part) => part.trim());
|
|
const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2);
|
|
const signature = parts.find((part) => part.startsWith("v1="))?.slice(3);
|
|
if (!timestamp || !signature || !/^[0-9a-fA-F]{64}$/.test(signature)) {
|
|
return false;
|
|
}
|
|
|
|
const timestampSeconds = Number.parseInt(timestamp, 10);
|
|
if (!Number.isFinite(timestampSeconds) || Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300) {
|
|
return false;
|
|
}
|
|
|
|
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
const actualBuffer = Buffer.from(signature, "hex");
|
|
const expectedBuffer = Buffer.from(expected, "hex");
|
|
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function getCoinPayOAuthConfig(): CoinPayOAuthConfig | null {
|
|
const clientId = process.env.COINPAY_OAUTH_CLIENT_ID;
|
|
const clientSecret = process.env.COINPAY_OAUTH_CLIENT_SECRET;
|
|
const redirectUri = process.env.COINPAY_OAUTH_REDIRECT_URI;
|
|
const issuer = process.env.COINPAY_OAUTH_ISSUER || process.env.COINPAY_API_URL || "https://coinpayportal.com";
|
|
const scopes = process.env.COINPAY_OAUTH_SCOPES || "openid profile email";
|
|
|
|
if (!clientId || !clientSecret || !redirectUri) {
|
|
return null;
|
|
}
|
|
|
|
return { clientId, clientSecret, redirectUri, issuer, scopes };
|
|
}
|
|
|
|
function getSessionSecret(): string {
|
|
const secret = process.env.LOGICSRC_SESSION_SECRET || process.env.COINPAY_OAUTH_CLIENT_SECRET;
|
|
if (!secret) {
|
|
throw new Error("LOGICSRC_SESSION_SECRET or COINPAY_OAUTH_CLIENT_SECRET must be set");
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
export function signSession(payload: Record<string, unknown>): string {
|
|
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
const signature = createHmac("sha256", getSessionSecret()).update(encoded).digest("base64url");
|
|
return `${encoded}.${signature}`;
|
|
}
|
|
|
|
export function verifySession(value: string): Record<string, unknown> | null {
|
|
const parts = value.split(".");
|
|
if (parts.length !== 2) return null;
|
|
const [encoded, signature] = parts;
|
|
if (!encoded || !signature) return null;
|
|
|
|
const expected = createHmac("sha256", getSessionSecret()).update(encoded).digest("base64url");
|
|
const actualBuffer = Buffer.from(signature, "base64url");
|
|
const expectedBuffer = Buffer.from(expected, "base64url");
|
|
if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface CookieOptions {
|
|
maxAge?: number;
|
|
path?: string;
|
|
}
|
|
|
|
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "SameSite=Lax", `Path=${options.path || "/"}`];
|
|
|
|
if (typeof options.maxAge === "number") {
|
|
parts.push(`Max-Age=${options.maxAge}`);
|
|
}
|
|
|
|
if ((process.env.PUBLIC_URL || "").startsWith("https://")) {
|
|
parts.push("Secure");
|
|
}
|
|
|
|
return parts.join("; ");
|
|
}
|