Files
worldmonitor/scripts/shared/acled-oauth.mjs
RepairYourTech 0420a54866 fix(acled): add OAuth token manager with automatic refresh (#1437)
* fix(acled): add OAuth token manager with automatic refresh

ACLED access tokens expire every 24 hours, but WorldMonitor stores a
static ACLED_ACCESS_TOKEN with no refresh logic — causing all ACLED
API calls to fail after the first day.

This commit adds `acled-auth.ts`, an OAuth token manager that:
- Exchanges ACLED_EMAIL + ACLED_PASSWORD for an access token (24h)
  and refresh token (14d) via the official ACLED OAuth endpoint
- Caches tokens in memory and auto-refreshes before expiry
- Falls back to static ACLED_ACCESS_TOKEN for backward compatibility
- Deduplicates concurrent refresh attempts
- Degrades gracefully when no credentials are configured

The only change to the existing `acled.ts` is replacing the synchronous
`process.env.ACLED_ACCESS_TOKEN` read with an async call to the new
`getAcledAccessToken()` helper.

Fixes #1283
Relates to #290

* fix: address review feedback on ACLED OAuth PR

- Use Redis (Upstash) as L2 token cache to survive Vercel Edge cold starts
  (in-memory cache retained as fast-path L1)
- Add CHROME_UA User-Agent header on OAuth token exchange and refresh
- Update seed script to use OAuth flow via getAcledToken() helper
  instead of raw process.env.ACLED_ACCESS_TOKEN
- Add security comment to .env.example about plaintext password trade-offs
- Sidecar ACLED_ACCESS_TOKEN case is a validation probe (tests user-provided
  value, not process.env) — data fetching delegates to handler modules

* feat(sidecar): add ACLED_EMAIL/ACLED_PASSWORD to env allowlist and validation

- Add ACLED_EMAIL and ACLED_PASSWORD to ALLOWED_ENV_KEYS set
- Add ACLED_EMAIL validation case (store-only, verified with password)
- Add ACLED_PASSWORD validation case with OAuth token exchange via
  acleddata.com/api/acled/user/login
- On successful login, store obtained OAuth token in ACLED_ACCESS_TOKEN
- Follows existing validation patterns (Cloudflare challenge handling,
  auth failure detection, User-Agent header)

* fix: address remaining review feedback (duplicate OAuth, em dashes, emoji)

- Extract shared ACLED OAuth helper into scripts/shared/acled-oauth.mjs
- Remove ~55 lines of duplicate OAuth logic from seed-unrest-events.mjs,
  now imports getAcledToken from the shared helper
- Replace em dashes with ASCII dashes in acled-auth.ts section comments
- Replace em dash with parentheses in sidecar validation message
- Remove emoji from .env.example security note

Addresses koala73's second review: MEDIUM (duplicate OAuth), LOW (em
dashes), LOW (emoji).

* fix: align sidecar OAuth endpoint, fix L1/L2 cache, cleanup artifacts

- Sidecar: switch from /api/acled/user/login (JSON) to /oauth/token
  (URL-encoded) to match server/_shared/acled-auth.ts exactly
- acled-auth.ts: check L2 Redis when L1 is expired, not only when L1
  is null (fixes stale L1 skipping fresher L2 from another isolate)
- acled-oauth.mjs: remove stray backslash on line 9
- seed-unrest-events.mjs: remove extra blank line at line 13

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
Co-authored-by: RepairYourTech <30200484+RepairYourTech@users.noreply.github.com>
2026-03-12 22:24:40 +04:00

69 lines
2.0 KiB
JavaScript

/**
* Lightweight ACLED OAuth helper for seed scripts.
*
* Mirrors the credential exchange from server/_shared/acled-auth.ts
* without the Redis/TypeScript dependencies so plain .mjs scripts
* can import it directly.
*/
const ACLED_TOKEN_URL = 'https://acleddata.com/oauth/token';
const ACLED_CLIENT_ID = 'acled';
/**
* Obtain a valid ACLED access token.
*
* Priority:
* 1. ACLED_EMAIL + ACLED_PASSWORD: OAuth exchange
* 2. ACLED_ACCESS_TOKEN: static token (legacy, expires 24h)
* 3. Neither: null
*
* @param {object} options
* @param {string} [options.userAgent] - User-Agent header value.
* @returns {Promise<string|null>}
*/
export async function getAcledToken({ userAgent } = {}) {
const email = (process.env.ACLED_EMAIL || '').trim();
const password = (process.env.ACLED_PASSWORD || '').trim();
if (email && password) {
console.log(' ACLED: exchanging credentials for OAuth token...');
const body = new URLSearchParams({
username: email,
password,
grant_type: 'password',
client_id: ACLED_CLIENT_ID,
});
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (userAgent) headers['User-Agent'] = userAgent;
const resp = await fetch(ACLED_TOKEN_URL, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
console.warn(` ACLED OAuth exchange failed (${resp.status}): ${text.slice(0, 200)}`);
// Fall through to static token check
} else {
const data = await resp.json();
if (data.access_token) {
console.log(' ACLED: OAuth token obtained successfully');
return data.access_token;
}
console.warn(' ACLED: OAuth response missing access_token');
}
}
const staticToken = (process.env.ACLED_ACCESS_TOKEN || '').trim();
if (staticToken) {
console.log(' ACLED: using static ACLED_ACCESS_TOKEN (expires after 24h)');
return staticToken;
}
return null;
}