← back to Marketing Command Center
lib/norma-auth.js
76 lines
// Shared Norma instagram-agent (:9810) auth — ordered credential sources.
//
// Credential sources, tried in order (TK-12327, shared by TK-12342):
// 1. secrets-manager master .env IG_AGENT_AUTH (canonical "Basic ..." header)
// 2. the instagram-agent's OWN .env AUTH_USERNAME/AUTH_PASSWORD
// 3. NORMA_IG_USER/NORMA_IG_PASS from this repo's env (explicit override, last resort)
// A copied NORMA_IG_PASS went stale when Norma rotated its password after the 2026-07-29
// empty-password incident -> every call 403'd "Invalid credentials" (TK-12008, TK-12342).
// A 401/403 on one source falls through to the next: a rejected auth never reached the
// handler, so retrying with the next credential is side-effect free (safe even for POSTs).
//
// Env overrides (tests / relocation): NORMA_IG_BASE, SECRETS_MANAGER_ENV, NORMA_IG_AGENT_ENV.
const fs = require('fs');
const os = require('os');
const path = require('path');
const { fetchWithTimeout } = require('./fetch-timeout.js');
const normaBase = () => (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replace(/\/$/, '');
function readEnvFile(f) {
const out = {};
try {
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
} catch { /* unreadable -> next source */ }
return out;
}
// -> [{ source, header }] in priority order, de-duplicated. Never logs secret values.
function normaAuthCandidates() {
const home = os.homedir();
const basic = (u, p) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
const out = [];
const add = (source, header) => {
if (header && /^Basic \S+$/.test(header) && !out.some(c => c.header === header)) out.push({ source, header });
};
const sm = readEnvFile(process.env.SECRETS_MANAGER_ENV || path.join(home, 'Projects', 'secrets-manager', '.env'));
add('secrets-manager IG_AGENT_AUTH', (sm.IG_AGENT_AUTH || '').trim());
const agent = readEnvFile(process.env.NORMA_IG_AGENT_ENV ||
path.join(home, 'Projects', 'Norma', 'agents', 'instagram-agent', '.env'));
if (agent.AUTH_PASSWORD) add('instagram-agent .env', basic(agent.AUTH_USERNAME || 'admin', agent.AUTH_PASSWORD));
if (process.env.NORMA_IG_PASS) add('NORMA_IG_PASS env', basic(process.env.NORMA_IG_USER || 'admin', process.env.NORMA_IG_PASS));
return out;
}
// fetch `${NORMA_BASE}${urlPath}` trying each credential in order; 401/403 falls through.
// Resolves { ok:true, res, json, source } on the first non-auth-rejected response
// (res.ok may still be false for non-auth HTTP errors — caller decides), or
// { ok:false, status, error, authRejected } when no source is configured / all are rejected.
// Network errors (unreachable/timeout) throw, exactly like fetchWithTimeout.
async function normaFetch(urlPath, opts = {}, ms, { tag = 'norma-auth', candidates } = {}) {
const cands = candidates || normaAuthCandidates();
if (!cands.length) {
return { ok: false, status: 0, authRejected: true,
error: 'Norma auth not configured (no IG_AGENT_AUTH in secrets-manager, no instagram-agent .env, no NORMA_IG_PASS)' };
}
let lastStatus = 0;
for (const cand of cands) {
const res = await fetchWithTimeout(`${normaBase()}${urlPath}`,
{ ...opts, headers: { ...(opts.headers || {}), Authorization: cand.header } }, ms);
if (res.status === 401 || res.status === 403) {
lastStatus = res.status;
console.error(`[${tag}] ${new Date().toISOString()} auth via ${cand.source} rejected (HTTP ${res.status}); trying next source`);
continue;
}
const json = await res.json().catch(() => ({}));
return { ok: true, res, json, source: cand.source };
}
return { ok: false, status: lastStatus, authRejected: true,
error: `Norma auth rejected (HTTP ${lastStatus}) by every source: ${cands.map(c => c.source).join(', ')}` };
}
module.exports = { normaBase, readEnvFile, normaAuthCandidates, normaFetch };