From 48649a6a79435f2ed13d071d5471885f87008122 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Fri, 14 Aug 2026 17:44:53 +0330 Subject: [PATCH] fix(settings-proxy): prefer SMS shared secret for product push auth --- src/settings-proxy/settings-proxy.service.ts | 147 ++++++++++++------- 1 file changed, 96 insertions(+), 51 deletions(-) diff --git a/src/settings-proxy/settings-proxy.service.ts b/src/settings-proxy/settings-proxy.service.ts index bc111ce..cabd692 100644 --- a/src/settings-proxy/settings-proxy.service.ts +++ b/src/settings-proxy/settings-proxy.service.ts @@ -22,9 +22,9 @@ export class SettingsProxyService { async getSettings(tenantId: string) { const tenant = await this.requireTenant(tenantId); - const auth = this.buildAuthHeaders(tenant, 'GET'); + const authModes = this.buildAuthAttempts(tenant, 'GET'); - if (!auth) { + if (!authModes.length) { return { stub: true, message: @@ -39,13 +39,17 @@ export class SettingsProxyService { } try { - const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), { - headers: { - ...auth, - Accept: 'application/json', - }, - timeout: 15000, - }); + const { data } = await this.requestWithAuthFallback( + authModes, + async (auth) => + axios.get(this.resolveSettingsUrl(tenant.nodeServer), { + headers: { + ...auth.headers, + Accept: 'application/json', + }, + timeout: 15000, + }), + ); return { stub: false, settings: data }; } catch (error) { throw new BadGatewayException( @@ -60,9 +64,9 @@ export class SettingsProxyService { userId?: string, ) { const tenant = await this.requireTenant(tenantId); - const auth = this.buildAuthHeaders(tenant, 'POST'); + const authModes = this.buildAuthAttempts(tenant, 'POST'); - if (!auth) { + if (!authModes.length) { await this.audit.log({ action: 'settings.proxy.stub_update', entityType: 'tenant_settings', @@ -82,17 +86,17 @@ export class SettingsProxyService { } try { - const { data } = await axios.post( - this.resolveSettingsUrl(tenant.nodeServer), - payload, - { - headers: { - ...auth, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - timeout: 20000, - }, + const { data, authMode } = await this.requestWithAuthFallback( + authModes, + async (auth) => + axios.post(this.resolveSettingsUrl(tenant.nodeServer), payload, { + headers: { + ...auth.headers, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + timeout: 20000, + }), ); await this.audit.log({ @@ -103,7 +107,7 @@ export class SettingsProxyService { userId, after: payload as Prisma.InputJsonValue, meta: { - authMode: auth.Authorization ? 'bearer' : 'hmac', + authMode, } as Prisma.InputJsonValue, }); @@ -116,12 +120,14 @@ export class SettingsProxyService { } /** - * Auth to product: - * 1) Tenant remoteApiToken as Bearer (per-tenant) - * 2) Else SMS shared secret as Bearer (CDN-safe; same secret as SMS) - * + optional HMAC headers for non-CDN installs + * Auth to product (order matters): + * 1) SMS shared secret as Bearer (+ optional HMAC) — matches production installs + * 2) Tenant remoteApiToken as Bearer fallback (only if product SUPERADMIN_REMOTE_TOKEN matches) + * + * Preferring remoteApiToken first caused HTTP 401 when SA had a stale/mismatched token + * while the product only accepted SUPERADMIN_SMS_SHARED_SECRET. */ - private buildAuthHeaders( + private buildAuthAttempts( tenant: { remoteApiToken: string | null; domain: string | null; @@ -129,37 +135,76 @@ export class SettingsProxyService { nodeServer: string | null; }, method: 'GET' | 'POST', - ): Record | null { - const token = String(tenant.remoteApiToken || '').trim(); - if (token) { - return { Authorization: `Bearer ${token}` }; - } + ): Array<{ mode: 'shared_secret' | 'remote_token'; headers: Record }> { + const attempts: Array<{ + mode: 'shared_secret' | 'remote_token'; + headers: Record; + }> = []; const shared = String( this.config.get('SMS_REMOTE_SHARED_SECRET') || '', ).trim(); - if (!shared) { - return null; + if (shared) { + // Bearer with shared secret survives CDNs that strip custom X-* headers. + const headers: Record = { + Authorization: `Bearer ${shared}`, + }; + + const domain = this.resolveTenantDomain(tenant); + if (domain) { + const timestamp = String(Math.floor(Date.now() / 1000)); + const payload = `${timestamp}.${method}.${SUPER_SETTINGS_CANONICAL_PATH}.${domain}`; + const signature = createHmac('sha256', shared) + .update(payload, 'utf8') + .digest('hex'); + headers['X-Abshode-Domain'] = domain; + headers['X-Abshode-Timestamp'] = timestamp; + headers['X-Abshode-Signature'] = signature; + } + + attempts.push({ mode: 'shared_secret', headers }); } - // Bearer with shared secret survives CDNs that strip custom X-* headers. - const headers: Record = { - Authorization: `Bearer ${shared}`, - }; - - const domain = this.resolveTenantDomain(tenant); - if (domain) { - const timestamp = String(Math.floor(Date.now() / 1000)); - const payload = `${timestamp}.${method}.${SUPER_SETTINGS_CANONICAL_PATH}.${domain}`; - const signature = createHmac('sha256', shared) - .update(payload, 'utf8') - .digest('hex'); - headers['X-Abshode-Domain'] = domain; - headers['X-Abshode-Timestamp'] = timestamp; - headers['X-Abshode-Signature'] = signature; + const token = String(tenant.remoteApiToken || '').trim(); + if (token) { + attempts.push({ + mode: 'remote_token', + headers: { Authorization: `Bearer ${token}` }, + }); } - return headers; + return attempts; + } + + private async requestWithAuthFallback( + attempts: Array<{ + mode: 'shared_secret' | 'remote_token'; + headers: Record; + }>, + request: (auth: { + mode: 'shared_secret' | 'remote_token'; + headers: Record; + }) => Promise<{ data: T }>, + ): Promise<{ data: T; authMode: 'shared_secret' | 'remote_token' }> { + let lastError: unknown; + + for (let i = 0; i < attempts.length; i++) { + const auth = attempts[i]; + try { + const { data } = await request(auth); + return { data, authMode: auth.mode }; + } catch (error) { + lastError = error; + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + const canRetry = + i < attempts.length - 1 && (status === 401 || status === 403); + if (!canRetry) { + throw error; + } + } + } + + throw lastError ?? new Error('settings proxy auth failed'); } private resolveTenantDomain(tenant: {