|
| 1 | +/* |
| 2 | + * Copyright (c) 2026, Salesforce, Inc. |
| 3 | + * All rights reserved. |
| 4 | + * SPDX-License-Identifier: BSD-3-Clause |
| 5 | + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause |
| 6 | + */ |
| 7 | + |
| 8 | +// This server-only adapter consumes the ECOM broker contract in docs/BM-ECA-TOKEN-BROKER.md. |
| 9 | +// The broker must be deployed separately: this is not a shipped SCAPI endpoint. |
| 10 | +const AM_TOKEN_URL = 'https://account.demandware.com/dwsso/oauth2/access_token' |
| 11 | +const BROKER_SCOPE = 'sfcc.llm-auth' |
| 12 | +const MAX_TOKEN_LENGTH = 32768 |
| 13 | + |
| 14 | +export function bmBrokerEndpoint(value) { |
| 15 | + let url |
| 16 | + try { |
| 17 | + url = new URL(value) |
| 18 | + } catch { |
| 19 | + throw new Error('Invalid BM token broker configuration') |
| 20 | + } |
| 21 | + if ( |
| 22 | + url.protocol !== 'https:' || |
| 23 | + !/^[a-z0-9-]+\.api\.commercecloud\.salesforce\.com$/.test(url.hostname) || |
| 24 | + url.username || |
| 25 | + url.password || |
| 26 | + url.search || |
| 27 | + url.hash || |
| 28 | + (url.port && url.port !== '443') || |
| 29 | + !/^\/merchant\/llm-auth\/v1\/organizations\/f_ecom_[A-Za-z0-9_-]+\/access-tokens$/.test( |
| 30 | + url.pathname |
| 31 | + ) |
| 32 | + ) |
| 33 | + throw new Error('Invalid BM token broker configuration') |
| 34 | + return url |
| 35 | +} |
| 36 | +const fail = (message, status) => { |
| 37 | + const error = new Error(message) |
| 38 | + if (Number.isInteger(status)) error.status = status |
| 39 | + return error |
| 40 | +} |
| 41 | +const refreshTime = (expires, now) => expires - Math.min(120000, (expires - now) / 10) |
| 42 | +const parseBrokerToken = (payload, now) => { |
| 43 | + try { |
| 44 | + const token = payload.access_token |
| 45 | + if ( |
| 46 | + typeof token !== 'string' || |
| 47 | + token.length > MAX_TOKEN_LENGTH || |
| 48 | + !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token) || |
| 49 | + typeof payload.token_type !== 'string' || |
| 50 | + payload.token_type.toLowerCase() !== 'bearer' || |
| 51 | + Object.keys(payload).some( |
| 52 | + (key) => !['access_token', 'token_type', 'core_tenant_id'].includes(key) |
| 53 | + ) |
| 54 | + ) |
| 55 | + throw new Error() |
| 56 | + // Decode metadata only. The authenticated ECOM broker controls issuance; Gateway validates the JWT. |
| 57 | + const [encodedHeader, encodedClaims] = token.split('.') |
| 58 | + const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString()) |
| 59 | + const claims = JSON.parse(Buffer.from(encodedClaims, 'base64url').toString()) |
| 60 | + const expires = claims.exp * 1000 |
| 61 | + if (!Number.isSafeInteger(claims.exp) || expires <= now + 5000 || expires > now + 1830000) |
| 62 | + throw new Error() |
| 63 | + const tenant = header.tnk || payload.core_tenant_id |
| 64 | + if ( |
| 65 | + tenant != null && |
| 66 | + (typeof tenant !== 'string' || !/^core\/[A-Za-z0-9-]+\/[A-Za-z0-9]{18}$/.test(tenant)) |
| 67 | + ) |
| 68 | + throw new Error() |
| 69 | + if ( |
| 70 | + header.tnk && |
| 71 | + payload.core_tenant_id && |
| 72 | + header.tnk.toLowerCase() !== payload.core_tenant_id.toLowerCase() |
| 73 | + ) |
| 74 | + throw new Error() |
| 75 | + return {token, tenant, refreshAt: refreshTime(expires, now)} |
| 76 | + } catch { |
| 77 | + throw new Error('Invalid BM broker token response') |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +export function createBmEcaAuth({env, fetchImpl = fetch, now = Date.now}) { |
| 82 | + const endpoint = bmBrokerEndpoint(env.LLM_GATEWAY_BM_TOKEN_URL) |
| 83 | + const clientId = env.LLM_GATEWAY_BM_AM_CLIENT_ID |
| 84 | + const clientSecret = env.LLM_GATEWAY_BM_AM_CLIENT_SECRET |
| 85 | + if (!clientId || !clientSecret) |
| 86 | + throw new Error('Set LLM_GATEWAY_BM_AM_CLIENT_ID and LLM_GATEWAY_BM_AM_CLIENT_SECRET') |
| 87 | + // AM tenant scope is derived from the fixed broker URL, not shopper request data. |
| 88 | + const organization = endpoint.pathname.split('/')[5] |
| 89 | + const scope = `SALESFORCE_COMMERCE_API:${organization.slice('f_ecom_'.length)} ${BROKER_SCOPE}` |
| 90 | + let amToken = null |
| 91 | + let cached = null |
| 92 | + let inFlight = null |
| 93 | + |
| 94 | + const post = async (url, options, message) => { |
| 95 | + const controller = new AbortController() |
| 96 | + const timeout = setTimeout(() => controller.abort(), 10000) |
| 97 | + try { |
| 98 | + const response = await fetchImpl(url, { |
| 99 | + ...options, |
| 100 | + method: 'POST', |
| 101 | + redirect: 'error', |
| 102 | + signal: controller.signal |
| 103 | + }) |
| 104 | + if (!response.ok) { |
| 105 | + await response.body?.cancel?.().catch(() => {}) |
| 106 | + throw fail(message, response.status) |
| 107 | + } |
| 108 | + return await response.json() |
| 109 | + } catch (error) { |
| 110 | + // Do not propagate raw fetch errors, OAuth descriptions, credentials or response bodies. |
| 111 | + throw fail(message, error?.status) |
| 112 | + } finally { |
| 113 | + clearTimeout(timeout) |
| 114 | + } |
| 115 | + } |
| 116 | + const getAmToken = async () => { |
| 117 | + if (amToken && amToken.refreshAt > now()) return amToken |
| 118 | + const result = await post( |
| 119 | + AM_TOKEN_URL, |
| 120 | + { |
| 121 | + headers: { |
| 122 | + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString( |
| 123 | + 'base64' |
| 124 | + )}`, |
| 125 | + 'Content-Type': 'application/x-www-form-urlencoded', |
| 126 | + Accept: 'application/json', |
| 127 | + 'Cache-Control': 'no-store' |
| 128 | + }, |
| 129 | + body: new URLSearchParams({grant_type: 'client_credentials', scope}).toString() |
| 130 | + }, |
| 131 | + 'BM broker service authentication failed' |
| 132 | + ) |
| 133 | + if ( |
| 134 | + typeof result?.access_token !== 'string' || |
| 135 | + !result.access_token || |
| 136 | + result.access_token.length > MAX_TOKEN_LENGTH || |
| 137 | + /\s/.test(result.access_token) || |
| 138 | + typeof result.token_type !== 'string' || |
| 139 | + result.token_type.toLowerCase() !== 'bearer' || |
| 140 | + !Number.isInteger(result.expires_in) || |
| 141 | + result.expires_in < 10 || |
| 142 | + result.expires_in > 86400 |
| 143 | + ) |
| 144 | + throw new Error('Invalid BM service token response') |
| 145 | + amToken = { |
| 146 | + token: result.access_token, |
| 147 | + refreshAt: refreshTime(now() + result.expires_in * 1000, now()) |
| 148 | + } |
| 149 | + return amToken |
| 150 | + } |
| 151 | + const mint = async () => { |
| 152 | + for (let attempt = 0; attempt < 2; attempt++) { |
| 153 | + const lease = await getAmToken() |
| 154 | + let payload |
| 155 | + try { |
| 156 | + // No ECA credentials, shopper bearer tokens, prompts or service IDs are sent here. |
| 157 | + // The ECOM endpoint chooses the instance's stored ECA and always mints a fresh token. |
| 158 | + payload = await post( |
| 159 | + endpoint, |
| 160 | + { |
| 161 | + headers: { |
| 162 | + Authorization: `Bearer ${lease.token}`, |
| 163 | + Accept: 'application/json', |
| 164 | + 'Cache-Control': 'no-store' |
| 165 | + } |
| 166 | + }, |
| 167 | + 'BM credential broker request failed' |
| 168 | + ) |
| 169 | + } catch (error) { |
| 170 | + if (error.status !== 401 || attempt > 0) throw error |
| 171 | + if (amToken === lease) amToken = null |
| 172 | + continue |
| 173 | + } |
| 174 | + cached = parseBrokerToken(payload, now()) |
| 175 | + return |
| 176 | + } |
| 177 | + } |
| 178 | + return { |
| 179 | + refreshable: true, |
| 180 | + async authorize() { |
| 181 | + if (!cached || cached.refreshAt <= now()) { |
| 182 | + if (!inFlight) |
| 183 | + inFlight = mint().finally(() => { |
| 184 | + inFlight = null |
| 185 | + }) |
| 186 | + // Shared exchange is not tied to one shopper's abort signal. |
| 187 | + await inFlight |
| 188 | + } |
| 189 | + const lease = cached |
| 190 | + return { |
| 191 | + headers: { |
| 192 | + Authorization: `Bearer ${lease.token}`, |
| 193 | + ...(lease.tenant ? {'x-sfdc-core-tenant-id': lease.tenant} : {}) |
| 194 | + }, |
| 195 | + invalidate() { |
| 196 | + if (cached === lease) cached = null |
| 197 | + } |
| 198 | + } |
| 199 | + } |
| 200 | + } |
| 201 | +} |
0 commit comments