Rocky_Mountain_Vending/lib/ebay-notifications.ts

418 lines
10 KiB
TypeScript

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<string, CachedAccessToken>()
const publicKeyCache = new Map<string, CachedPublicKey>()
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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
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<VerificationResult> {
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 }
}