diff --git a/nx/blocks/loc/connectors/smartling/auth.js b/nx/blocks/loc/connectors/smartling/auth.js new file mode 100644 index 000000000..e7e6dcf59 --- /dev/null +++ b/nx/blocks/loc/connectors/smartling/auth.js @@ -0,0 +1,206 @@ +import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; +import fetchWithRetry from '../../utils/fetchWithRetry.js'; +import { login, getCachedToken, setCachedToken } from '../../utils/auth.js'; + +const INTEGRATION_NAME = 'smartling'; +const FALLBACK_EXPIRES_IN_S = 280; // used only if the API response omits expiresIn +const REFRESH_BUFFER_MS = 5000; // refresh this long before the token actually expires +const MIN_REFRESH_DELAY_MS = 2000; // never schedule a refresh sooner than this +const BASE_OPTS = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, +}; + +// translate.da.live's legacy /smartling route is deprecated in favor of +// /translate/smartling// - rewrite configs still pointing at the +// old origin so they keep working without a config migration. +export function resolveOrigin(origin, org, site) { + return origin === `${DA_TRANSLATE}/smartling` + ? `${DA_TRANSLATE}/translate/smartling/${org}/${site}` + : origin; +} + +let tokenPolling; +// Retained so a failed refresh can fall back to a full re-authentication via +// da-etc: Smartling caps a token pair's session at 12 hours regardless of how +// many times it's refreshed, so refreshes eventually start failing even +// though da-etc's held credentials still work. +let authContext; + +/** + * Reads the currently cached access token, if any - the single source of + * truth for what's valid right now, kept current by the proactive refresh + * schedule and by `onUnauthorized`'s reactive recovery. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {string|undefined} The cached access token, if any. + */ +export function getToken(org, site, env) { + return getCachedToken(INTEGRATION_NAME, org, site, env).accessToken; +} + +/** + * Exchanges the org/site's Smartling credentials - held server-side by + * da-etc, never sent to the browser - for a fresh access/refresh token pair. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {Promise} The response's `accessToken`, + * `refreshToken`, and `expiresIn`, or null on failure. + */ +async function authenticate(org, site, env) { + const json = await login(INTEGRATION_NAME, org, site, env); + return json?.response?.data || null; +} + +/** + * Persists the current access/refresh token pair plus a computed expiry to + * localStorage. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @param {string} accessToken - The current access token. + * @param {string} refreshToken - The current refresh token. + * @param {number} [expiresInSecs] - Seconds until `accessToken` expires; + * falls back to `FALLBACK_EXPIRES_IN_S` if omitted. + * @returns {void} + */ +function setTokenDetails(org, site, env, accessToken, refreshToken, expiresInSecs) { + const timestamp = Date.now(); + const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; + const expires = timestamp + expiresInMs; + setCachedToken(INTEGRATION_NAME, org, site, env, { accessToken, refreshToken, expires }); +} + +/** + * Refreshes the current access token, falling back to a full + * re-authentication via da-etc if the refresh token itself has stopped + * working (Smartling caps a token pair's session at 12 hours regardless of + * how many times it's refreshed). Persists the new token, but leaves + * rescheduling the next proactive refresh to the caller - used both by the + * proactive schedule below and reactively via `onUnauthorized` when a + * request 401s before that schedule catches up (e.g. the tab was + * backgrounded and its timers were throttled). + * @returns {Promise<{accessToken: string, expiresIn: number}|null>} The + * new token details, or null if both the refresh and the fallback + * re-authentication failed. + */ +async function refreshOrReauthenticate() { + const { endpoint, org, site, env } = authContext; + const { refreshToken: currRefreshToken } = getCachedToken(INTEGRATION_NAME, org, site, env); + + const body = JSON.stringify({ refreshToken: currRefreshToken }); + const opts = { ...BASE_OPTS, body }; + const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate/refresh`, opts); + let data = resp.ok ? (await resp.json())?.response?.data : null; + + if (!data?.accessToken) data = await authenticate(org, site, env); + if (!data?.accessToken) return null; + + const { accessToken, refreshToken, expiresIn } = data; + setTokenDetails(org, site, env, accessToken, refreshToken, expiresIn); + return { accessToken, expiresIn }; +} + +/** + * Schedules a token refresh shortly before the current token expires, + * tracking Smartling's actual reported `expiresIn` instead of assuming a + * constant lifetime (that value shrinks as a session nears its 12-hour + * cap). Only stops rescheduling once `refreshOrReauthenticate` fails + * outright, so a translation job that outlives several sessions keeps + * working without user intervention. + * @param {number} [expiresInSecs] - Seconds until the current token + * expires; falls back to `FALLBACK_EXPIRES_IN_S` if omitted. + * @returns {void} + */ +function scheduleRefresh(expiresInSecs) { + const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; + const delay = Math.max(expiresInMs - REFRESH_BUFFER_MS, MIN_REFRESH_DELAY_MS); + + clearTimeout(tokenPolling); + tokenPolling = setTimeout(async () => { + const refreshed = await refreshOrReauthenticate(); + if (!refreshed) { + // Both refresh and re-authentication failed - stop polling rather than + // hammering the API forever with credentials that no longer work. + tokenPolling = undefined; + return; + } + scheduleRefresh(refreshed.expiresIn); + }, delay); +} + +/** + * Builds a `fetchWithRetry` `onUnauthorized` callback: refreshes (or + * re-authenticates) the token, reschedules the next proactive refresh + * against the new expiry, and rebuilds `opts` with a fresh Authorization + * header - so a 401, e.g. from a token that expired while the tab was + * backgrounded before the proactive refresh above could run, triggers + * exactly one retry with a valid token instead of failing the request + * outright. + * @param {Object} opts - The fetch options to rebuild on success. + * @returns {() => Promise} Callback for `fetchWithRetry`'s + * `onUnauthorized` config. + */ +export function onUnauthorized(opts) { + return async () => { + const refreshed = await refreshOrReauthenticate(); + if (!refreshed) return null; + scheduleRefresh(refreshed.expiresIn); + return { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${refreshed.accessToken}` } }; + }; +} + +/** + * Ensures a connected session: reuses a still-valid cached token if one + * exists (resuming background refresh scheduling, e.g. after a page + * reload), otherwise authenticates via da-etc - which holds the org/site's + * Smartling credentials server-side and only ever returns a short-lived + * access/refresh token pair, so no secret reaches the browser. Matches + * Trados/Lionbridge's `authReady`: connecting is transparent, with no + * separate manual step required. + * @param {Object} config - The service configuration. + * @param {string} config.origin - The configured API origin. + * @param {string} config.env - The environment key (e.g. 'prod'). + * @param {string} config.org - The DA org. + * @param {string} config.site - The DA site. + * @returns {Promise} Whether a connected session is available. + */ +async function ensureConnected(config) { + const { + origin, org, site, env, + } = config; + const endpoint = resolveOrigin(origin, org, site); + const { expires } = getCachedToken(INTEGRATION_NAME, org, site, env); + const notExpired = expires > Date.now(); + + if (notExpired) { + authContext = { + endpoint, org, site, env, + }; + // Only (re)arm the schedule if it isn't already running, so repeated + // calls against an already-connected session don't stack timers. + if (!tokenPolling) scheduleRefresh((expires - Date.now()) / 1000); + return true; + } + + const data = await authenticate(org, site, env); + if (!data?.accessToken) return false; + + authContext = { + endpoint, org, site, env, + }; + const { accessToken, refreshToken, expiresIn } = data; + setTokenDetails(org, site, env, accessToken, refreshToken, expiresIn); + scheduleRefresh(expiresIn); + return true; +} + +export function isConnected(config) { + return ensureConnected(config); +} + +export function connect(service) { + return ensureConnected(service); +} diff --git a/nx/blocks/loc/connectors/smartling/index.js b/nx/blocks/loc/connectors/smartling/index.js index 580a55843..2a8586f53 100644 --- a/nx/blocks/loc/connectors/smartling/index.js +++ b/nx/blocks/loc/connectors/smartling/index.js @@ -1,224 +1,34 @@ import { addDnt, removeDnt } from '../../dnt/dnt.js'; -import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; import downloadQueue from '../../utils/downloadQueue.js'; import fetchWithRetry from '../../utils/fetchWithRetry.js'; +import { + resolveOrigin, getToken, onUnauthorized, + isConnected as checkConnection, connect as establishConnection, +} from './auth.js'; export const dnt = { addDnt }; -const REFRESH_BUFFER_MS = 5000; // refresh this long before the token actually expires -const FALLBACK_EXPIRES_IN_S = 280; // used only if the API response omits expiresIn -const MIN_REFRESH_DELAY_MS = 2000; // never schedule a refresh sooner than this const BASE_OPTS = { method: 'POST', headers: { 'Content-Type': 'application/json' }, }; -// translate.da.live's legacy /smartling route is deprecated in favor of -// /translate/smartling// - rewrite configs still pointing at the -// old origin so they keep working without a config migration. -function resolveOrigin(origin, org, site) { - return origin === `${DA_TRANSLATE}/smartling` - ? `${DA_TRANSLATE}/translate/smartling/${org}/${site}` - : origin; -} - -let token; -let tokenPolling; -// Credentials retained so a failed refresh can fall back to a full -// re-authentication: Smartling caps a token pair's session at 12 hours -// regardless of how many times it's refreshed, so refreshes eventually -// start failing even though the original userId/userSecret still work. -let authCredentials; - -/** - * Caches the current access token in the ES module and persists both - * tokens plus a computed expiry to localStorage. - * @param {string} name - The connector's display name (e.g. 'Smartling'). - * @param {string} env - The environment key (e.g. 'prod'). - * @param {string} accessToken - The current access token. - * @param {string} refreshToken - The current refresh token. - * @param {number} [expiresInSecs] - Seconds until `accessToken` expires; - * falls back to `FALLBACK_EXPIRES_IN_S` if omitted. - * @returns {void} - */ -function setTokenDetails(name, env, accessToken, refreshToken, expiresInSecs) { - token = accessToken; - const timestamp = Date.now(); - const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; - localStorage.setItem(`${name.toLowerCase()}.${env}.token`, JSON.stringify({ accessToken, refreshToken, expires: timestamp + expiresInMs })); -} - -function getTokenDetails(name, env) { - const lsTokenDetails = localStorage.getItem(`${name.toLowerCase()}.${env}.token`); - if (lsTokenDetails) { - try { - return JSON.parse(lsTokenDetails); - } catch { - return {}; - } - } - return {}; -} - -/** - * Authenticates with Smartling using a user's identifier/secret. - * @param {string} endpoint - The resolved Smartling API origin. - * @param {string} userIdentifier - The Smartling user identifier. - * @param {string} userSecret - The Smartling user secret. - * @returns {Promise} The response's `accessToken`, - * `refreshToken`, and `expiresIn`, or null on failure. - */ -async function authenticate(endpoint, userIdentifier, userSecret) { - const body = JSON.stringify({ userIdentifier, userSecret }); - const opts = { ...BASE_OPTS, body }; - - const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate`, opts); - if (!resp.ok) return null; - const json = await resp.json(); - return json?.response?.data || null; -} - -/** - * Refreshes the current access token, falling back to a full - * re-authentication with the original credentials if the refresh token - * itself has stopped working (Smartling caps a token pair's session at - * 12 hours regardless of how many times it's refreshed). Persists the - * new token, but leaves rescheduling the next proactive refresh to the - * caller - used both by the proactive schedule below and reactively via - * `onUnauthorized` when a request 401s before that schedule catches up - * (e.g. the tab was backgrounded and its timers were throttled). - * @returns {Promise<{accessToken: string, expiresIn: number}|null>} The - * new token details, or null if both the refresh and the fallback - * re-authentication failed. - */ -async function refreshOrReauthenticate() { - const { name, env, endpoint, userIdentifier, userSecret } = authCredentials; - const { refreshToken: currRefreshToken } = getTokenDetails(name, env); - - const body = JSON.stringify({ refreshToken: currRefreshToken }); - const opts = { ...BASE_OPTS, body }; - const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate/refresh`, opts); - let data = resp.ok ? (await resp.json())?.response?.data : null; - - if (!data?.accessToken) data = await authenticate(endpoint, userIdentifier, userSecret); - if (!data?.accessToken) return null; - - const { accessToken, refreshToken, expiresIn } = data; - setTokenDetails(name, env, accessToken, refreshToken, expiresIn); - return { accessToken, expiresIn }; +export function isConnected(config) { + return checkConnection(config); } /** - * Schedules a token refresh shortly before the current token expires, - * tracking Smartling's actual reported `expiresIn` instead of assuming a - * constant lifetime (that value shrinks as a session nears its 12-hour - * cap). Only stops rescheduling once `refreshOrReauthenticate` fails - * outright, so a translation job that outlives several sessions keeps - * working without user intervention. - * @param {number} [expiresInSecs] - Seconds until the current token - * expires; falls back to `FALLBACK_EXPIRES_IN_S` if omitted. - * @returns {void} - */ -function scheduleRefresh(expiresInSecs) { - const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; - const delay = Math.max(expiresInMs - REFRESH_BUFFER_MS, MIN_REFRESH_DELAY_MS); - - clearTimeout(tokenPolling); - tokenPolling = setTimeout(async () => { - const refreshed = await refreshOrReauthenticate(); - if (!refreshed) { - // Both refresh and re-authentication failed - stop polling rather than - // hammering the API forever with credentials that no longer work. - token = undefined; - tokenPolling = undefined; - return; - } - scheduleRefresh(refreshed.expiresIn); - }, delay); -} - -/** - * Builds a `fetchWithRetry` `onUnauthorized` callback: refreshes (or - * re-authenticates) the token, reschedules the next proactive refresh - * against the new expiry, and rebuilds `opts` with a fresh Authorization - * header - so a 401, e.g. from a token that expired while the tab was - * backgrounded before the proactive refresh above could run, triggers - * exactly one retry with a valid token instead of failing the request - * outright. - * @param {Object} opts - The fetch options to rebuild on success. - * @returns {() => Promise} Callback for `fetchWithRetry`'s - * `onUnauthorized` config. - */ -function onUnauthorized(opts) { - return async () => { - const refreshed = await refreshOrReauthenticate(); - if (!refreshed) return null; - scheduleRefresh(refreshed.expiresIn); - return { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${refreshed.accessToken}` } }; - }; -} - -/** - * Checks for a still-valid cached token and, if found, resumes background - * refresh scheduling (e.g. after a page reload) instead of requiring the - * user to reconnect. - * @param {Object} config - The service configuration, including - * `userId`/`userSecret` (retained for a later refresh-failure fallback) - * and `origin`/`org`/`site` to resolve the API endpoint. - * @returns {Promise} Whether a still-valid cached token was found. - */ -export async function isConnected(config) { - const { - name, env, userId, userSecret, origin, org, site, - } = config; - const endpoint = resolveOrigin(origin, org, site); - const { expires, accessToken } = getTokenDetails(name, env); - const notExpired = expires > Date.now(); - - if (notExpired && !tokenPolling) { - // Cache the token for the ES Module - token = accessToken; - authCredentials = { name, env, endpoint, userIdentifier: userId, userSecret }; - - // Kick off the refresh scheduling - scheduleRefresh((expires - Date.now()) / 1000); - return true; - } - - return false; -} - -/** - * Authenticates with Smartling and starts background refresh scheduling. + * Authenticates with Smartling via da-etc, surfacing an error message if it + * fails. * @param {Object} service - The service configuration. - * @param {string} service.name - The connector's display name. - * @param {string} service.origin - The configured API origin. - * @param {string} service.env - The environment key (e.g. 'prod'). - * @param {string} service.userId - The Smartling user identifier. - * @param {string} service.userSecret - The Smartling user secret. - * @param {string} service.org - The DA org. - * @param {string} service.site - The DA site. * @param {Function} [sendMessage] - Callback to surface an error message * to the user if authentication fails. * @returns {Promise} Whether authentication succeeded. */ export async function connect(service, sendMessage) { - const { - name, origin, env, userId, userSecret, org, site, - } = service; - const endpoint = resolveOrigin(origin, org, site); - - const data = await authenticate(endpoint, userId, userSecret); - if (!data?.accessToken) { - sendMessage?.({ text: 'Connection to Smartling failed.', type: 'error' }); - return false; - } - - authCredentials = { name, env, endpoint, userIdentifier: userId, userSecret }; - const { accessToken, refreshToken, expiresIn } = data; - setTokenDetails(name, env, accessToken, refreshToken, expiresIn); - scheduleRefresh(expiresIn); - return true; + const connected = await establishConnection(service); + if (!connected) sendMessage?.({ text: 'Connection to Smartling failed.', type: 'error' }); + return connected; } /** @@ -244,6 +54,9 @@ function extractErrorMessage(json) { * Uploads every url to a Smartling batch, reporting an error message per * file that Smartling rejects (e.g. a locale mismatch) instead of only * counting it as not-accepted. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} batchUid - The batch to upload files into. @@ -254,7 +67,17 @@ function extractErrorMessage(json) { * @returns {Promise} Each file's Smartling response `code` * (`'ACCEPTED'` on success). */ -async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessage) { +async function uploadFiles( + org, + site, + env, + endpoint, + projectId, + batchUid, + langs, + urls, + sendMessage, +) { const uploadUrl = `${endpoint}/job-batches-api/v2/projects/${projectId}/batches/${batchUid}/file`; const results = []; @@ -270,7 +93,7 @@ async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessa body.append('localeIdsToAuthorize[]', lang.code); }); - const opts = { method: 'POST', body, headers: { Authorization: `Bearer ${token}` } }; + const opts = { method: 'POST', body, headers: { Authorization: `Bearer ${getToken(org, site, env)}` } }; const resp = await fetchWithRetry(uploadUrl, opts, { onUnauthorized: onUnauthorized(opts) }); const json = await resp.json(); @@ -285,6 +108,9 @@ async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessa /** * Creates a Smartling translation job for the given target languages. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} title - The project title, used to build the job name. @@ -294,14 +120,14 @@ async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessa * message to the user. * @returns {Promise} The new job's id, or null on failure. */ -async function createJob(endpoint, projectId, title, langs, sendMessage) { +async function createJob(org, site, env, endpoint, projectId, title, langs, sendMessage) { const timestamp = Date.now(); const jobName = `${title}-${timestamp}`; const targetLocaleIds = langs.map((lang) => lang.code); const body = JSON.stringify({ jobName, targetLocaleIds }); const opts = { ...BASE_OPTS, body }; - opts.headers.Authorization = `Bearer ${token}`; + opts.headers.Authorization = `Bearer ${getToken(org, site, env)}`; const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs`; const resp = await fetchWithRetry(url, opts, { onUnauthorized: onUnauthorized(opts) }); @@ -317,6 +143,9 @@ async function createJob(endpoint, projectId, title, langs, sendMessage) { /** * Creates a job batch for the uploaded files. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} jobUid - The job to attach the batch to. @@ -328,7 +157,17 @@ async function createJob(endpoint, projectId, title, langs, sendMessage) { * message to the user. * @returns {Promise} The new batch's id, or null on failure. */ -async function createBatch(endpoint, projectId, jobUid, urls, autoAuthorize, sendMessage) { +async function createBatch( + org, + site, + env, + endpoint, + projectId, + jobUid, + urls, + autoAuthorize, + sendMessage, +) { const body = JSON.stringify({ authorize: autoAuthorize, translationJobUid: jobUid, @@ -336,7 +175,7 @@ async function createBatch(endpoint, projectId, jobUid, urls, autoAuthorize, sen }); const opts = { ...BASE_OPTS, body }; - opts.headers.Authorization = `Bearer ${token}`; + opts.headers.Authorization = `Bearer ${getToken(org, site, env)}`; const url = `${endpoint}/job-batches-api/v2/projects/${projectId}/batches`; @@ -398,7 +237,7 @@ export async function saveItems({ saveFn, sendMessage, }) { - const { origin, projectId } = service; + const { origin, projectId, env } = service; const endpoint = resolveOrigin(origin, org, site); const downloadCallback = async (url) => { @@ -408,7 +247,7 @@ export async function saveItems({ const opts = { method: 'GET', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${getToken(org, site, env)}`, 'Content-Type': 'application/json', }, }; @@ -454,11 +293,11 @@ export async function sendAllLanguages({ }) { const { sendMessage, saveState } = actions; - const { origin, projectId, autoAuthorize } = options.service; + const { origin, projectId, autoAuthorize, env } = options.service; const endpoint = resolveOrigin(origin, org, site); sendMessage({ text: `Creating job in Smartling for: ${title}.` }); - const jobUid = await createJob(endpoint, projectId, title, langs, sendMessage); + const jobUid = await createJob(org, site, env, endpoint, projectId, title, langs, sendMessage); if (!jobUid) { sendMessage({ text: `Job creation failed for: ${title}.`, type: 'error' }); return; @@ -471,7 +310,17 @@ export async function sendAllLanguages({ // config[`${env}.jobUid`] = jobUid; sendMessage({ text: `Creating a batch in Smartling for: ${title}.` }); - const batchUid = await createBatch(endpoint, projectId, jobUid, urls, autoAuthorize === 'yes', sendMessage); + const batchUid = await createBatch( + org, + site, + env, + endpoint, + projectId, + jobUid, + urls, + autoAuthorize === 'yes', + sendMessage, + ); if (!batchUid) { sendMessage({ text: `Batch creation failed for: ${title}.`, type: 'error' }); return; @@ -485,6 +334,9 @@ export async function sendAllLanguages({ sendMessage({ text: `Uploading ${urls.length} items to Smartling for job: ${title}.` }); const results = await uploadFiles( + org, + site, + env, endpoint, projectId, batchUid, @@ -505,6 +357,9 @@ export async function sendAllLanguages({ /** * Fetches Smartling's per-locale progress for a job. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} jobUid - The job to check progress for. @@ -515,9 +370,9 @@ export async function sendAllLanguages({ * matching the "No content for translation" status Smartling's dashboard * shows for it. */ -async function fetchJobProgress(endpoint, projectId, jobUid) { +async function fetchJobProgress(org, site, env, endpoint, projectId, jobUid) { const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs/${jobUid}/progress`; - const opts = { headers: { Authorization: `Bearer ${token}` } }; + const opts = { headers: { Authorization: `Bearer ${getToken(org, site, env)}` } }; const resp = await fetchWithRetry(url, opts, { onUnauthorized: onUnauthorized(opts) }); if (!resp.ok) return null; @@ -557,7 +412,9 @@ export async function getStatusAll({ org, site, service, langs, urls, actions, }) { const { saveState, sendMessage } = actions; - const { origin, projectId, jobUid } = service; + const { + origin, projectId, jobUid, env, + } = service; const endpoint = resolveOrigin(origin, org, site); if (!jobUid?.value) { @@ -572,7 +429,14 @@ export async function getStatusAll({ const activeLangs = langs.filter((l) => !['complete', 'cancelled'].includes(l.translation.status)); if (!activeLangs.length) return; - const progressByLocale = await fetchJobProgress(endpoint, projectId, jobUid.value); + const progressByLocale = await fetchJobProgress( + org, + site, + env, + endpoint, + projectId, + jobUid.value, + ); if (!progressByLocale) { sendMessage({ text: 'Checking status failed: could not reach Smartling.', type: 'error' }); return; diff --git a/nx/blocks/loc/utils/auth.js b/nx/blocks/loc/utils/auth.js index 3d20b945a..51bacb70c 100644 --- a/nx/blocks/loc/utils/auth.js +++ b/nx/blocks/loc/utils/auth.js @@ -9,28 +9,28 @@ export const LOGIN_ORIGIN = DA_ETC || 'https://da-etc.adobeaem.workers.dev'; const TOKEN_BUFFER = 300000; // 5 min buffer before expiry /** - * Builds the localStorage key a token is cached under. - * @param {string} name - Cache-key prefix identifying the connector - * (e.g. 'trados', 'lionbridge'). + * Builds the localStorage key a connector caches its da-etc-issued token + * under, scoped per org/site/env so different sites don't collide. + * @param {string} name - The da-etc integration name (e.g. 'trados', 'smartling'). * @param {string} org - The DA org. * @param {string} site - The DA site. - * @param {string} env - The connector environment (e.g. 'prod'). - * @returns {string} The cache key. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {string} The localStorage key. */ function tokenKey(name, org, site, env) { return `${name}.${org}.${site}.${env}.token`; } /** - * Reads a cached token, if any. - * @param {string} name - Cache-key prefix identifying the connector. + * Reads and JSON-parses a connector's cached token entry, tolerating + * missing or corrupt values. + * @param {string} name - The da-etc integration name (e.g. 'trados', 'smartling'). * @param {string} org - The DA org. * @param {string} site - The DA site. - * @param {string} env - The connector environment (e.g. 'prod'). - * @returns {{accessToken?: string, expires?: number}} The cached details, - * or `{}` if none are stored. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {Object} The parsed value, or `{}` if missing/invalid. */ -function getTokenDetails(name, org, site, env) { +export function getCachedToken(name, org, site, env) { const stored = localStorage.getItem(tokenKey(name, org, site, env)); if (!stored) return {}; try { @@ -41,21 +41,16 @@ function getTokenDetails(name, org, site, env) { } /** - * Caches a token and its expiry. - * @param {string} name - Cache-key prefix identifying the connector. + * JSON-serializes and persists a connector's token details to localStorage. + * @param {string} name - The da-etc integration name (e.g. 'trados', 'smartling'). * @param {string} org - The DA org. * @param {string} site - The DA site. - * @param {string} env - The connector environment (e.g. 'prod'). - * @param {string} accessToken - The token to cache. - * @param {number} expires - Epoch ms after which the token should be - * treated as expired. + * @param {string} env - The environment key (e.g. 'prod'). + * @param {Object} value - The value to persist. * @returns {void} */ -function setTokenDetails(name, org, site, env, accessToken, expires) { - localStorage.setItem( - tokenKey(name, org, site, env), - JSON.stringify({ accessToken, expires }), - ); +export function setCachedToken(name, org, site, env, value) { + localStorage.setItem(tokenKey(name, org, site, env), JSON.stringify(value)); } /** @@ -74,6 +69,28 @@ function loginUrl(name, org, site, env) { return `${LOGIN_ORIGIN}/${org}/sites/${site}/integrations/${name}/login?env=${env}`; } +/** + * Exchanges a DA org/site's third-party service credentials - held + * server-side by da-etc, never sent to the browser - for a fresh token via + * da-etc's `/integrations//login` endpoint. The caller's own DA/IMS + * session (attached by `daFetch`) is what authorizes the exchange, so no + * secret ever reaches the browser. Returns the raw parsed response rather + * than a normalized shape, since that varies by integration (Trados and + * Lionbridge return a flat OAuth `access_token`/`expires_in` pair; Smartling + * nests its own `accessToken`/`refreshToken`/`expiresIn` shape under + * `response.data`). + * @param {string} name - The da-etc integration name (e.g. 'trados', 'smartling'). + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {Promise} The parsed response body, or null on failure. + */ +export async function login(name, org, site, env) { + const resp = await daFetch({ url: loginUrl(name, org, site, env), opts: { method: 'POST' } }); + if (!resp.ok) return null; + return resp.json(); +} + /** * Returns a valid access token for a da-etc-backed connector login flow, * reusing a cached one if it hasn't expired, otherwise logging in. @@ -96,19 +113,16 @@ export async function getAccessToken(name, service, { force = false } = {}) { const { org, site, env = 'prod' } = service; if (!force) { - const { accessToken: cached, expires: cachedExpires } = getTokenDetails(name, org, site, env); + const { accessToken: cached, expires: cachedExpires } = getCachedToken(name, org, site, env); if (cached && cachedExpires > Date.now()) return cached; } - const opts = { method: 'POST' }; - const resp = await daFetch({ url: loginUrl(name, org, site, env), opts }); - if (!resp.ok) return null; - - const { access_token: accessToken, expires_in: expiresIn } = await resp.json(); + const data = await login(name, org, site, env); + const { access_token: accessToken, expires_in: expiresIn } = data || {}; if (!accessToken) return null; const expires = Date.now() + (expiresIn * 1000) - TOKEN_BUFFER; - setTokenDetails(name, org, site, env, accessToken, expires); + setCachedToken(name, org, site, env, { accessToken, expires }); return accessToken; } diff --git a/test/loc/connectors/smartling/index.test.js b/test/loc/connectors/smartling/index.test.js index 14cdbd562..f181f07bd 100644 --- a/test/loc/connectors/smartling/index.test.js +++ b/test/loc/connectors/smartling/index.test.js @@ -1,6 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { - connect, isConnected, saveItems, sendAllLanguages, getStatusAll, + isConnected, saveItems, sendAllLanguages, getStatusAll, } from '../../../../nx/blocks/loc/connectors/smartling/index.js'; import { DA_TRANSLATE } from '../../../../nx2/utils/utils.js'; @@ -68,18 +68,18 @@ describe('smartling connector - legacy origin rewriting', () => { beforeEach(() => installFetch()); afterEach(() => restoreFetch()); - // Must run before any other test calls connect()/scheduleRefresh - isConnected's + // Must run before any other test calls scheduleRefresh - isConnected's // early return depends on tokenPolling being unset, which is otherwise - // module-level state left over from every later connect() call in this file. + // module-level state left over from every later test in this file. it('resolves the endpoint from origin/org/site in isConnected, not a nonexistent config key', async () => { - localStorage.setItem('smartling.prod.token', JSON.stringify({ + localStorage.setItem(`smartling.${org}.${site}.prod.token`, JSON.stringify({ accessToken: 'cached-token', refreshToken: 'cached-refresh-token', expires: Date.now() + 60000, })); const connected = await isConnected({ - name: 'Smartling', env: 'prod', userId: 'u', userSecret: 's', origin: legacyOrigin, org, site, + name: 'Smartling', env: 'prod', origin: legacyOrigin, org, site, }); expect(connected).to.equal(true); @@ -123,45 +123,31 @@ describe('smartling connector - legacy origin rewriting', () => { expect(langs[0].translation.status).to.equal('created'); }); - it('rewrites the legacy /smartling origin to /translate/smartling// on connect', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - - expect(calls[0].url).to.equal(`${DA_TRANSLATE}/translate/smartling/${org}/${site}/auth-api/v2/authenticate`); - }); + it('auto-connects via isConnected when there is no cached token, with no separate connect() step', async () => { + // Distinct org/site so this test's cache key can't collide with the + // 'acme'/'site1' state other tests in this file leave behind. + const autoOrg = 'auto-org'; + const autoSite = 'auto-site'; - it('leaves a non-legacy origin untouched on connect', async () => { - const customOrigin = 'https://api.smartling.com'; - await connect({ - name: 'Smartling', origin: customOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - - expect(calls[0].url).to.equal(`${customOrigin}/auth-api/v2/authenticate`); - }); - - it('surfaces an error and returns false when connect fails', async () => { origFetch = window.fetch; window.fetch = async (url, opts = {}) => { const u = url.toString(); calls.push({ url: u, method: opts.method, body: opts.body }); - if (u.includes('/auth-api/v2/authenticate')) { - return new Response('', { status: 401 }); + if (u.includes('/integrations/smartling/login')) { + return new Response(JSON.stringify({ + response: { data: { accessToken: 'auto-token', refreshToken: 'auto-refresh', expiresIn: 300 } }, + }), { status: 200 }); } return new Response('{}', { status: 200 }); }; - const messages = []; - const sendMessage = (m) => messages.push(m); - - const result = await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }, sendMessage); + const connected = await isConnected({ + name: 'Smartling', env: 'prod', origin: 'https://api.smartling.com', org: autoOrg, site: autoSite, + }); - expect(result).to.equal(false); - const errorMessage = messages.find((m) => m.type === 'error'); - expect(errorMessage.text).to.include('Connection to Smartling failed'); + expect(connected).to.equal(true); + expect(calls.some((c) => c.url.includes('/integrations/smartling/login'))).to.equal(true); }); it('rewrites the origin for sendAllLanguages job/batch/upload calls', async () => { @@ -489,10 +475,12 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('recovers from a 401 on getStatusAll by refreshing the token and retrying', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // Real auth (connect()) now goes through da-etc, which requires a + // signed-in IMS session and isn't mockable at this level. No seeding is + // needed here though - `authContext` is already populated by the + // isConnected() call in the first test above, and the initial request's + // token read is a cache miss regardless (this test's `service` has no + // `env`), so it always 401s and forces the refresh path being tested. let progressCalls = 0; let refreshCalls = 0; origFetch = window.fetch; @@ -807,10 +795,8 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('recovers from a 401 by refreshing the token and retrying the request', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // See the getStatusAll 401-recovery test above for why no seeding is + // needed here. let jobCalls = 0; let refreshCalls = 0; origFetch = window.fetch; @@ -854,10 +840,8 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('gives up without looping when the retried request also 401s', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // See the getStatusAll 401-recovery test above for why no seeding is + // needed here. let refreshCalls = 0; origFetch = window.fetch; window.fetch = async (url, opts = {}) => {