import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' /** * eBay Digital Signatures for APIs (RFC 9421 HTTP Message Signatures). * * eBay requires every Finances API request (and a few other "compliant" APIs, * mandatory in the EU/UK) to carry a message signature, otherwise it answers * 403 "Missing x-ebay-signature-key header". * * The scheme: * 1. Create an Ed25519 signing key once via the Key Management API * (POST /developer/key_management/v1/signing_key) using an APPLICATION token * (client_credentials grant). eBay returns { privateKey, publicKey, jwe, * signingKeyId }. The `jwe` is the value echoed back in the * `x-ebay-signature-key` header. * 2. For each request, build the RFC 9421 signature base over the covered * components, sign it with the Ed25519 private key, and send: * x-ebay-signature-key: * Signature-Input: sig1=(...);created= * Signature: sig1=:: * Content-Digest: sha-256=:: (only when there is a request body) * * The signing key is cached in-memory and persisted to data/ebay-signing-keys.json * (keyed per environment + client id) so we don't mint a new key every request. * READ side-effects only — no iDempiere writes. */ const EBAY_TOKEN_URL = { production: 'https://api.ebay.com/identity/v1/oauth2/token', sandbox: 'https://api.sandbox.ebay.com/identity/v1/oauth2/token' } const EBAY_API_BASE = { production: 'https://apiz.ebay.com', sandbox: 'https://apiz.sandbox.ebay.com' } interface SigningKeyEntry { jwe: string privateKey: string publicKey?: string signingKeyId?: string expirationTime?: number // epoch seconds, if eBay supplied one createdAt?: string } const memCache = new Map() const storePath = () => path.join(process.cwd(), 'data', 'ebay-signing-keys.json') const loadStore = (): Record => { try { const p = storePath() if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')) || {} } catch { /* fall through to empty store */ } return {} } const saveStore = (store: Record) => { try { const p = storePath() fs.mkdirSync(path.dirname(p), { recursive: true }) fs.writeFileSync(p, JSON.stringify(store, null, 2), 'utf8') } catch { /* non-fatal: in-memory cache still works for this process */ } } // Application access token (client_credentials grant) — used only to create the // signing key. Distinct from the per-user OAuth token used for data calls. const getAppAccessToken = async (clientId: string, clientSecret: string, environment: string): Promise => { const url = environment === 'sandbox' ? EBAY_TOKEN_URL.sandbox : EBAY_TOKEN_URL.production const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${credentials}` }, body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'https://api.ebay.com/oauth/api_scope' }).toString() }) const text = await response.text() let json: any = {} try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } } if (!response.ok || !json.access_token) { const msg = json?.error_description || json?.error || text || response.statusText const err: any = new Error(`eBay application token request failed (${response.status}): ${msg}`) err.status = response.status err.ebay = json throw err } return json.access_token } // Create a fresh Ed25519 signing key via the Key Management API. This endpoint // itself does NOT require a signature. const createSigningKey = async (appToken: string, environment: string): Promise => { const base = environment === 'sandbox' ? EBAY_API_BASE.sandbox : EBAY_API_BASE.production const response = await fetch(`${base}/developer/key_management/v1/signing_key`, { method: 'POST', headers: { Authorization: `Bearer ${appToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ signingKeyCipher: 'ED25519' }) }) const text = await response.text() let json: any = {} try { json = text ? JSON.parse(text) : {} } catch { json = { raw: text } } if (!response.ok || !json.jwe || !json.privateKey) { const msg = json?.errors?.[0]?.message || json?.errors?.[0]?.longMessage || json?.error_description || text || response.statusText const err: any = new Error(`eBay signing key creation failed (${response.status}): ${msg}`) err.status = response.status err.ebay = json throw err } return { jwe: json.jwe, privateKey: json.privateKey, publicKey: json.publicKey, signingKeyId: json.signingKeyId, expirationTime: json.expirationTime ? Number(json.expirationTime) : undefined, createdAt: new Date().toISOString() } } const isExpired = (entry: SigningKeyEntry): boolean => { if (!entry?.expirationTime) return false // 1-day safety buffer return Math.floor(Date.now() / 1000) > entry.expirationTime - 86400 } // Get a usable signing key (cache → file → create), keyed per env + client id. export const getOrCreateSigningKey = async ( clientId: string, clientSecret: string, environment: string ): Promise => { const cacheKey = `${environment}:${clientId}` const cached = memCache.get(cacheKey) if (cached && cached.jwe && cached.privateKey && !isExpired(cached)) return cached const store = loadStore() const persisted = store[cacheKey] if (persisted && persisted.jwe && persisted.privateKey && !isExpired(persisted)) { memCache.set(cacheKey, persisted) return persisted } const appToken = await getAppAccessToken(clientId, clientSecret, environment) const entry = await createSigningKey(appToken, environment) store[cacheKey] = entry saveStore(store) memCache.set(cacheKey, entry) return entry } // Wrap a base64 DER (PKCS#8) key as PEM so node:crypto can read it. const pemFromBase64 = (b64: string, label = 'PRIVATE KEY'): string => { const body = (b64.match(/.{1,64}/g) || [b64]).join('\n') return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n` } // Build the RFC 9421 signature base + the signature-params serialization. const buildSignatureBase = (opts: { method: string pathName: string authority: string jwe: string contentDigest?: string created: number }): { base: string; params: string } => { const components: string[] = [] if (opts.contentDigest) components.push('content-digest') components.push('x-ebay-signature-key', '@method', '@path', '@authority') const lines: string[] = [] for (const c of components) { let value = '' if (c === 'content-digest') value = opts.contentDigest as string else if (c === 'x-ebay-signature-key') value = opts.jwe else if (c === '@method') value = opts.method.toUpperCase() else if (c === '@path') value = opts.pathName else if (c === '@authority') value = opts.authority lines.push(`"${c}": ${value}`) } const inner = components.map(c => `"${c}"`).join(' ') const params = `(${inner});created=${opts.created}` lines.push(`"@signature-params": ${params}`) return { base: lines.join('\n'), params } } // Produce the eBay digital-signature headers for one request. // For GET requests pass no body (no Content-Digest needed). export const buildSigningHeaders = async (opts: { method: string url: string clientId: string clientSecret: string environment: string body?: string | null }): Promise> => { if (!opts.clientId || !opts.clientSecret) { const err: any = new Error('eBay Client ID / Secret are required to sign Finances API requests.') err.status = 400 throw err } const { jwe, privateKey } = await getOrCreateSigningKey(opts.clientId, opts.clientSecret, opts.environment) const u = new URL(opts.url) const created = Math.floor(Date.now() / 1000) let contentDigest: string | undefined if (opts.body) { const hash = crypto.createHash('sha256').update(opts.body).digest('base64') contentDigest = `sha-256=:${hash}:` } const { base, params } = buildSignatureBase({ method: opts.method, pathName: u.pathname, authority: u.host, jwe, contentDigest, created }) const keyObj = crypto.createPrivateKey({ key: pemFromBase64(privateKey), format: 'pem' }) const signature = crypto.sign(null, Buffer.from(base, 'utf8'), keyObj).toString('base64') const headers: Record = { 'x-ebay-signature-key': jwe, 'Signature-Input': `sig1=${params}`, Signature: `sig1=:${signature}:` } if (contentDigest) headers['Content-Digest'] = contentDigest return headers }