mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-13 02:26:22 +02:00
230 lines
6.9 KiB
TypeScript
230 lines
6.9 KiB
TypeScript
/**
|
|
* ACLED OAuth token manager with automatic refresh.
|
|
*
|
|
* ACLED switched to OAuth tokens that expire every 24 hours.
|
|
* This module handles the token lifecycle:
|
|
*
|
|
* 1. If ACLED_EMAIL + ACLED_PASSWORD are set → exchange for an OAuth
|
|
* access token (24 h) + refresh token (14 d), cache in Redis,
|
|
* and auto-refresh before expiry.
|
|
*
|
|
* 2. If only ACLED_ACCESS_TOKEN is set → use the static token as-is
|
|
* (backward-compatible, but will expire after 24 h).
|
|
*
|
|
* 3. If neither is set → return null (graceful degradation).
|
|
*
|
|
* See: https://acleddata.com/api-documentation/getting-started
|
|
* Fixes: https://github.com/koala73/worldmonitor/issues/1283
|
|
*/
|
|
|
|
import { CHROME_UA } from './constants';
|
|
import { getCachedJson, setCachedJson } from './redis';
|
|
|
|
const ACLED_TOKEN_URL = 'https://acleddata.com/oauth/token';
|
|
const ACLED_CLIENT_ID = 'acled';
|
|
|
|
/** Refresh 5 minutes before the token actually expires. */
|
|
const EXPIRY_MARGIN_MS = 5 * 60 * 1000;
|
|
|
|
/** Redis cache key for the ACLED OAuth token state. */
|
|
const REDIS_CACHE_KEY = 'acled:oauth:token';
|
|
|
|
/** Cache token in Redis for 23 hours (token lasts 24 h, minus margin). */
|
|
const REDIS_TTL_SECONDS = 23 * 60 * 60;
|
|
|
|
interface TokenState {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
/** Absolute timestamp (ms) when the access token expires. */
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface AcledOAuthTokenResponse {
|
|
access_token?: string;
|
|
refresh_token?: string;
|
|
expires_in?: number;
|
|
}
|
|
|
|
/**
|
|
* In-memory fast-path cache.
|
|
* Acts as L1 cache; Redis is L2 and survives Vercel Edge cold starts.
|
|
*/
|
|
let memCached: TokenState | null = null;
|
|
let refreshPromise: Promise<string | null> | null = null;
|
|
|
|
async function requestAcledToken(
|
|
body: URLSearchParams,
|
|
action: 'exchange' | 'refresh',
|
|
): Promise<AcledOAuthTokenResponse> {
|
|
const resp = await fetch(ACLED_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'User-Agent': CHROME_UA,
|
|
},
|
|
body,
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
throw new Error(
|
|
`ACLED OAuth token ${action} failed (${resp.status}): ${text.slice(0, 200)}`,
|
|
);
|
|
}
|
|
|
|
return (await resp.json()) as AcledOAuthTokenResponse;
|
|
}
|
|
|
|
/**
|
|
* Exchange ACLED credentials for an OAuth token pair.
|
|
*/
|
|
async function exchangeCredentials(
|
|
email: string,
|
|
password: string,
|
|
): Promise<TokenState> {
|
|
const body = new URLSearchParams({
|
|
username: email,
|
|
password,
|
|
grant_type: 'password',
|
|
client_id: ACLED_CLIENT_ID,
|
|
});
|
|
const data = await requestAcledToken(body, 'exchange');
|
|
|
|
if (!data.access_token || !data.refresh_token) {
|
|
throw new Error('ACLED OAuth response missing access_token or refresh_token');
|
|
}
|
|
|
|
return {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token,
|
|
expiresAt: Date.now() + (data.expires_in ?? 86_400) * 1000,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Use a refresh token to obtain a new access token.
|
|
*/
|
|
async function refreshAccessToken(refreshToken: string): Promise<TokenState> {
|
|
const body = new URLSearchParams({
|
|
refresh_token: refreshToken,
|
|
grant_type: 'refresh_token',
|
|
client_id: ACLED_CLIENT_ID,
|
|
});
|
|
const data = await requestAcledToken(body, 'refresh');
|
|
|
|
if (!data.access_token) {
|
|
throw new Error('ACLED OAuth refresh response missing access_token');
|
|
}
|
|
|
|
return {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token || refreshToken,
|
|
expiresAt: Date.now() + (data.expires_in ?? 86_400) * 1000,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Persist token state to Redis so it survives Vercel Edge cold starts.
|
|
*/
|
|
async function cacheToRedis(state: TokenState): Promise<void> {
|
|
try {
|
|
await setCachedJson(REDIS_CACHE_KEY, state, REDIS_TTL_SECONDS);
|
|
} catch (err) {
|
|
console.warn('[acled-auth] Failed to cache token in Redis', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore token state from Redis (L2 cache for cold starts).
|
|
*/
|
|
async function restoreFromRedis(): Promise<TokenState | null> {
|
|
try {
|
|
const data = await getCachedJson(REDIS_CACHE_KEY);
|
|
if (
|
|
data &&
|
|
typeof data === 'object' &&
|
|
'accessToken' in (data as Record<string, unknown>) &&
|
|
'refreshToken' in (data as Record<string, unknown>) &&
|
|
'expiresAt' in (data as Record<string, unknown>)
|
|
) {
|
|
return data as TokenState;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[acled-auth] Failed to restore token from Redis', err);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Returns a valid ACLED access token, refreshing if necessary.
|
|
*
|
|
* Priority:
|
|
* 1. ACLED_EMAIL + ACLED_PASSWORD → OAuth flow with auto-refresh
|
|
* 2. ACLED_ACCESS_TOKEN → static token (legacy)
|
|
* 3. Neither → null
|
|
*
|
|
* Caching:
|
|
* L1: In-memory `memCached` (fast-path within same isolate)
|
|
* L2: Redis via `getCachedJson`/`setCachedJson` (survives cold starts)
|
|
*/
|
|
export async function getAcledAccessToken(): Promise<string | null> {
|
|
const email = process.env.ACLED_EMAIL?.trim();
|
|
const password = process.env.ACLED_PASSWORD?.trim();
|
|
|
|
// -- OAuth flow --
|
|
if (email && password) {
|
|
// L1: Return in-memory token if still fresh.
|
|
if (memCached && Date.now() < memCached.expiresAt - EXPIRY_MARGIN_MS) {
|
|
return memCached.accessToken;
|
|
}
|
|
|
|
// L2: Try Redis (survives Vercel Edge cold starts).
|
|
// Also check L2 when L1 is expired, in case another isolate wrote a fresher token.
|
|
if (!memCached || Date.now() >= memCached.expiresAt - EXPIRY_MARGIN_MS) {
|
|
const fromRedis = await restoreFromRedis();
|
|
if (fromRedis && Date.now() < fromRedis.expiresAt - EXPIRY_MARGIN_MS) {
|
|
memCached = fromRedis;
|
|
return memCached.accessToken;
|
|
}
|
|
// If Redis had a token but it's near-expiry, keep it for fallback.
|
|
if (fromRedis) memCached = fromRedis;
|
|
}
|
|
|
|
// Deduplicate concurrent refresh attempts.
|
|
if (refreshPromise) return refreshPromise;
|
|
|
|
refreshPromise = (async () => {
|
|
try {
|
|
// Try refreshing with the existing refresh token first.
|
|
if (memCached?.refreshToken) {
|
|
try {
|
|
memCached = await refreshAccessToken(memCached.refreshToken);
|
|
await cacheToRedis(memCached);
|
|
return memCached.accessToken;
|
|
} catch (refreshErr) {
|
|
console.warn('[acled-auth] Refresh token expired, re-authenticating', refreshErr);
|
|
}
|
|
}
|
|
|
|
// Full re-authentication with email/password.
|
|
memCached = await exchangeCredentials(email, password);
|
|
await cacheToRedis(memCached);
|
|
return memCached.accessToken;
|
|
} catch (err) {
|
|
console.error('[acled-auth] Failed to obtain ACLED access token', err);
|
|
// If we still have a cached token (even if near-expiry), try using it.
|
|
return memCached?.accessToken ?? null;
|
|
} finally {
|
|
refreshPromise = null;
|
|
}
|
|
})();
|
|
|
|
return refreshPromise;
|
|
}
|
|
|
|
// -- Static token fallback (legacy) --
|
|
const staticToken = process.env.ACLED_ACCESS_TOKEN?.trim();
|
|
return staticToken || null;
|
|
}
|