fix(settings-proxy): prefer SMS shared secret for product push auth
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user