fix(settings-proxy): prefer SMS shared secret for product push auth

This commit is contained in:
2026-08-14 17:44:53 +03:30
parent 9cfd38ef99
commit 48649a6a79
+96 -51
View File
@@ -22,9 +22,9 @@ export class SettingsProxyService {
async getSettings(tenantId: string) { async getSettings(tenantId: string) {
const tenant = await this.requireTenant(tenantId); const tenant = await this.requireTenant(tenantId);
const auth = this.buildAuthHeaders(tenant, 'GET'); const authModes = this.buildAuthAttempts(tenant, 'GET');
if (!auth) { if (!authModes.length) {
return { return {
stub: true, stub: true,
message: message:
@@ -39,13 +39,17 @@ export class SettingsProxyService {
} }
try { try {
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), { const { data } = await this.requestWithAuthFallback(
headers: { authModes,
...auth, async (auth) =>
Accept: 'application/json', axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
}, headers: {
timeout: 15000, ...auth.headers,
}); Accept: 'application/json',
},
timeout: 15000,
}),
);
return { stub: false, settings: data }; return { stub: false, settings: data };
} catch (error) { } catch (error) {
throw new BadGatewayException( throw new BadGatewayException(
@@ -60,9 +64,9 @@ export class SettingsProxyService {
userId?: string, userId?: string,
) { ) {
const tenant = await this.requireTenant(tenantId); 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({ await this.audit.log({
action: 'settings.proxy.stub_update', action: 'settings.proxy.stub_update',
entityType: 'tenant_settings', entityType: 'tenant_settings',
@@ -82,17 +86,17 @@ export class SettingsProxyService {
} }
try { try {
const { data } = await axios.post( const { data, authMode } = await this.requestWithAuthFallback(
this.resolveSettingsUrl(tenant.nodeServer), authModes,
payload, async (auth) =>
{ axios.post(this.resolveSettingsUrl(tenant.nodeServer), payload, {
headers: { headers: {
...auth, ...auth.headers,
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 20000, timeout: 20000,
}, }),
); );
await this.audit.log({ await this.audit.log({
@@ -103,7 +107,7 @@ export class SettingsProxyService {
userId, userId,
after: payload as Prisma.InputJsonValue, after: payload as Prisma.InputJsonValue,
meta: { meta: {
authMode: auth.Authorization ? 'bearer' : 'hmac', authMode,
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
}); });
@@ -116,12 +120,14 @@ export class SettingsProxyService {
} }
/** /**
* Auth to product: * Auth to product (order matters):
* 1) Tenant remoteApiToken as Bearer (per-tenant) * 1) SMS shared secret as Bearer (+ optional HMAC) — matches production installs
* 2) Else SMS shared secret as Bearer (CDN-safe; same secret as SMS) * 2) Tenant remoteApiToken as Bearer fallback (only if product SUPERADMIN_REMOTE_TOKEN matches)
* + optional HMAC headers for non-CDN installs *
* 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: { tenant: {
remoteApiToken: string | null; remoteApiToken: string | null;
domain: string | null; domain: string | null;
@@ -129,37 +135,76 @@ export class SettingsProxyService {
nodeServer: string | null; nodeServer: string | null;
}, },
method: 'GET' | 'POST', method: 'GET' | 'POST',
): Record<string, string> | null { ): Array<{ mode: 'shared_secret' | 'remote_token'; headers: Record<string, string> }> {
const token = String(tenant.remoteApiToken || '').trim(); const attempts: Array<{
if (token) { mode: 'shared_secret' | 'remote_token';
return { Authorization: `Bearer ${token}` }; headers: Record<string, string>;
} }> = [];
const shared = String( const shared = String(
this.config.get<string>('SMS_REMOTE_SHARED_SECRET') || '', this.config.get<string>('SMS_REMOTE_SHARED_SECRET') || '',
).trim(); ).trim();
if (!shared) { if (shared) {
return null; // Bearer with shared secret survives CDNs that strip custom X-* headers.
const headers: Record<string, string> = {
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 token = String(tenant.remoteApiToken || '').trim();
const headers: Record<string, string> = { if (token) {
Authorization: `Bearer ${shared}`, attempts.push({
}; mode: 'remote_token',
headers: { Authorization: `Bearer ${token}` },
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;
} }
return headers; return attempts;
}
private async requestWithAuthFallback<T>(
attempts: Array<{
mode: 'shared_secret' | 'remote_token';
headers: Record<string, string>;
}>,
request: (auth: {
mode: 'shared_secret' | 'remote_token';
headers: Record<string, string>;
}) => 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: { private resolveTenantDomain(tenant: {