← 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 };