418 lines
10 KiB
TypeScript
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 }
|
|
}
|