import { createHash, createPublicKey, createVerify } from "node:crypto" import type { NextRequest } from "next/server" const DEFAULT_EBAY_API_BASE_URL = "https://api.ebay.com" const DEFAULT_EBAY_NOTIFICATION_SCOPE = "https://api.ebay.com/oauth/api_scope" const DEFAULT_EBAY_NOTIFICATION_ENDPOINT = "https://rmv.abundancepartners.app/api/ebay/notifications" const DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN = "rmv_ebay_notifications_2026_04_01_test_7c4f1a9d" const PUBLIC_KEY_CACHE_TTL = 60 * 60 * 1000 const ACCESS_TOKEN_CACHE_TTL_SKEW = 60 * 1000 type CachedAccessToken = { token: string expiresAt: number } type CachedPublicKey = { algorithm: string digest: string key: string expiresAt: number } export type EbayNotificationSignature = { algorithm?: string digest?: string keyId: string signature: string } type EbayPublicKeyResponse = { algorithm?: string digest?: string key?: string } type VerificationResult = | { verified: true keyId: string } | { verified: false reason: string } const accessTokenCache = new Map() const publicKeyCache = new Map() function getOptionalEnv(name: string) { const value = process.env[name] return typeof value === "string" && value.trim() ? value.trim() : "" } function stripTrailingSlash(value: string) { return value.replace(/\/+$/, "") } function normalizeEndpointFromRequest(requestUrl?: string) { if (!requestUrl) { return "" } try { const url = new URL(requestUrl) return `${url.origin}${url.pathname}`.replace(/\/+$/, "") } catch { return requestUrl.split(/[?#]/)[0]?.replace(/\/+$/, "") || "" } } export function getEbayNotificationEndpoint(requestUrl?: string) { const configuredEndpoint = getOptionalEnv("EBAY_NOTIFICATION_ENDPOINT") if (configuredEndpoint) { return stripTrailingSlash(configuredEndpoint) } if (DEFAULT_EBAY_NOTIFICATION_ENDPOINT) { return stripTrailingSlash(DEFAULT_EBAY_NOTIFICATION_ENDPOINT) } return normalizeEndpointFromRequest(requestUrl) } export function getEbayNotificationVerificationToken() { return getOptionalEnv("EBAY_NOTIFICATION_VERIFICATION_TOKEN") || DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN } export function computeEbayChallengeResponse(args: { challengeCode: string endpoint: string verificationToken: string }) { const hash = createHash("sha256") hash.update(args.challengeCode) hash.update(args.verificationToken) hash.update(args.endpoint) return hash.digest("hex") } function base64DecodeText(rawValue: string) { const normalized = rawValue.trim().replace(/-/g, "+").replace(/_/g, "/") const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4) try { return Buffer.from(padded, "base64").toString("utf8").trim() } catch { return "" } } function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null } return value as Record } function firstString(...values: unknown[]) { for (const value of values) { if (typeof value === "string" && value.trim()) { return value.trim() } } return "" } export function parseEbaySignatureHeader(rawHeader: string | null): EbayNotificationSignature | null { const trimmed = typeof rawHeader === "string" ? rawHeader.trim() : "" if (!trimmed) { return null } const candidates = [trimmed, base64DecodeText(trimmed)].filter(Boolean) for (const candidate of candidates) { const parsed = (() => { try { return JSON.parse(candidate) as unknown } catch { return null } })() const parsedRecord = asRecord(parsed) if (parsedRecord) { const keyId = firstString(parsedRecord.keyId, parsedRecord.kid, parsedRecord.key_id, parsedRecord.publicKeyId) const signature = firstString(parsedRecord.signature, parsedRecord.sig, parsedRecord.value) const algorithm = firstString(parsedRecord.algorithm, parsedRecord.alg) const digest = firstString(parsedRecord.digest, parsedRecord.hash) if (keyId && signature) { return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined } } if (keyId) { return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined } } } } const decoded = base64DecodeText(trimmed) if (decoded) { const keyId = decoded return { keyId, signature: "" } } return null } function normalizeDigest(digest?: string) { switch ((digest || "").trim().toLowerCase()) { case "sha1": return "sha1" case "sha256": return "sha256" case "sha384": return "sha384" case "sha512": return "sha512" default: return "sha1" } } function normalizePublicKeyPem(value: string) { const trimmed = value.trim() if (!trimmed) { return "" } if (trimmed.includes("BEGIN PUBLIC KEY")) { return trimmed } const compact = trimmed.replace(/\s+/g, "") const chunks = compact.match(/.{1,64}/g) || [compact] return `-----BEGIN PUBLIC KEY-----\n${chunks.join("\n")}\n-----END PUBLIC KEY-----` } async function getEbayAccessToken() { const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID") const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID") const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL) const scope = getOptionalEnv("EBAY_NOTIFICATION_SCOPE") || DEFAULT_EBAY_NOTIFICATION_SCOPE if (!clientId || !clientSecret) { throw new Error("eBay notification verification requires EBAY_NOTIFICATION_APP_ID and EBAY_NOTIFICATION_CERT_ID.") } const cacheKey = `${apiBaseUrl}|${clientId}|${clientSecret}|${scope}` const cached = accessTokenCache.get(cacheKey) if (cached && cached.expiresAt > Date.now()) { return cached.token } const tokenUrl = `${apiBaseUrl}/identity/v1/oauth2/token` const body = new URLSearchParams() body.set("grant_type", "client_credentials") body.set("scope", scope) const authValue = Buffer.from(`${clientId}:${clientSecret}`).toString("base64") const response = await fetch(tokenUrl, { method: "POST", headers: { Authorization: `Basic ${authValue}`, "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body, }) if (!response.ok) { const details = await response.text().catch(() => "") throw new Error( `Failed to fetch eBay access token (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`, ) } const payload = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null const token = typeof payload?.access_token === "string" ? payload.access_token : "" const expiresIn = typeof payload?.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600 if (!token) { throw new Error("eBay access token response did not include an access_token value.") } accessTokenCache.set(cacheKey, { token, expiresAt: Date.now() + Math.max(expiresIn * 1000 - ACCESS_TOKEN_CACHE_TTL_SKEW, 60_000), }) return token } async function getEbayPublicKey(keyId: string) { const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL) const cacheKey = `${apiBaseUrl}|${keyId}` const cached = publicKeyCache.get(cacheKey) if (cached && cached.expiresAt > Date.now()) { return cached } const accessToken = await getEbayAccessToken() const response = await fetch(`${apiBaseUrl}/commerce/notification/v1/public_key/${encodeURIComponent(keyId)}`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, }) if (!response.ok) { const details = await response.text().catch(() => "") throw new Error( `Failed to fetch eBay public key (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`, ) } const payload = (await response.json().catch(() => null)) as EbayPublicKeyResponse | null const key = typeof payload?.key === "string" ? normalizePublicKeyPem(payload.key) : "" const algorithm = typeof payload?.algorithm === "string" && payload.algorithm.trim() ? payload.algorithm.trim() : "" const digest = typeof payload?.digest === "string" && payload.digest.trim() ? payload.digest.trim() : "" if (!key) { throw new Error("eBay public key response did not include a valid key.") } const cachedValue = { algorithm, digest, key, expiresAt: Date.now() + PUBLIC_KEY_CACHE_TTL, } publicKeyCache.set(cacheKey, cachedValue) return cachedValue } export async function verifyEbayNotificationSignature(args: { body: string signatureHeader: string | null }): Promise { const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID") const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID") if (!clientId || !clientSecret) { return { verified: false, reason: "Notification verification credentials are not configured.", } } const signature = parseEbaySignatureHeader(args.signatureHeader) if (!signature) { return { verified: false, reason: "Missing or invalid X-EBAY-SIGNATURE header." } } if (!signature.keyId) { return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a key id." } } if (!signature.signature) { return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a signature value." } } const publicKey = await getEbayPublicKey(signature.keyId) const verifier = createVerify(normalizeDigest(signature.digest || publicKey.digest)) verifier.update(args.body, "utf8") verifier.end() const verified = verifier.verify(createPublicKey(publicKey.key), signature.signature, "base64") if (!verified) { return { verified: false, reason: "eBay notification signature verification failed." } } return { verified: true, keyId: signature.keyId } }