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>
This commit is contained in:
RepairYourTech
2026-03-12 13:24:40 -05:00
committed by GitHub
parent e1258ba1c4
commit 0420a54866
6 changed files with 380 additions and 4 deletions

View File

@@ -137,7 +137,7 @@ globalThis.fetch = async function ipv4Fetch(input, init) {
const ALLOWED_ENV_KEYS = new Set([
'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'TAVILY_API_KEYS', 'BRAVE_API_KEYS', 'SERPAPI_API_KEYS', 'FRED_API_KEY', 'EIA_API_KEY',
'CLOUDFLARE_API_TOKEN', 'ACLED_ACCESS_TOKEN', 'URLHAUS_AUTH_KEY',
'CLOUDFLARE_API_TOKEN', 'ACLED_ACCESS_TOKEN', 'ACLED_EMAIL', 'ACLED_PASSWORD', 'URLHAUS_AUTH_KEY',
'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL',
'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET',
'AISSTREAM_API_KEY', 'VITE_WS_RELAY_URL', 'FINNHUB_API_KEY', 'NASA_FIRMS_API_KEY',
@@ -806,6 +806,51 @@ async function validateSecretAgainstProvider(key, rawValue, context = {}) {
return ok('ACLED token verified');
}
case 'ACLED_EMAIL':
// Email is validated together with ACLED_PASSWORD; store it for now.
return ok('ACLED email stored');
case 'ACLED_PASSWORD': {
// Validate ACLED credentials via OAuth token exchange.
// Uses the same /oauth/token endpoint as server/_shared/acled-auth.ts.
// Requires ACLED_EMAIL to be set first (via local-env-update).
const email = String(context.ACLED_EMAIL || process.env.ACLED_EMAIL || '').trim();
if (!email) {
return fail('Set ACLED_EMAIL before verifying the password');
}
const oauthBody = new URLSearchParams({
username: email,
password: value,
grant_type: 'password',
client_id: 'acled',
});
const loginResponse = await fetchWithTimeout('https://acleddata.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'User-Agent': CHROME_UA,
},
body: oauthBody.toString(),
});
const loginText = await loginResponse.text();
if (isCloudflareChallenge403(loginResponse, loginText)) {
return ok('ACLED credentials stored (Cloudflare blocked verification)');
}
if (isAuthFailure(loginResponse.status, loginText)) {
return fail('ACLED rejected these credentials');
}
if (!loginResponse.ok) return fail(`ACLED OAuth probe failed (${loginResponse.status})`);
let loginPayload = null;
try { loginPayload = JSON.parse(loginText); } catch { /* ignore */ }
if (loginPayload?.access_token) {
// Store the obtained OAuth token so API handlers can use it.
process.env.ACLED_ACCESS_TOKEN = loginPayload.access_token;
return ok('ACLED credentials verified (OAuth token obtained)');
}
return ok('ACLED credentials accepted');
}
case 'URLHAUS_AUTH_KEY': {
const response = await fetchWithTimeout('https://urlhaus-api.abuse.ch/v1/urls/recent/limit/1/', {
headers: {