| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { CHROME_UA } from './constants'; |
| import { getCachedJson, setCachedJson } from './redis'; |
|
|
| const ACLED_TOKEN_URL = 'https://acleddata.com/oauth/token'; |
| const ACLED_CLIENT_ID = 'acled'; |
|
|
| |
| const EXPIRY_MARGIN_MS = 5 * 60 * 1000; |
|
|
| |
| const REDIS_CACHE_KEY = 'acled:oauth:token'; |
|
|
| |
| const REDIS_TTL_SECONDS = 23 * 60 * 60; |
|
|
| interface TokenState { |
| accessToken: string; |
| refreshToken: string; |
| |
| expiresAt: number; |
| } |
|
|
| interface AcledOAuthTokenResponse { |
| access_token?: string; |
| refresh_token?: string; |
| expires_in?: number; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function getAcledAccessToken(): Promise<string | null> { |
| const email = process.env.ACLED_EMAIL?.trim(); |
| const password = process.env.ACLED_PASSWORD?.trim(); |
|
|
| |
| if (email && password) { |
| |
| if (memCached && Date.now() < memCached.expiresAt - EXPIRY_MARGIN_MS) { |
| return memCached.accessToken; |
| } |
|
|
| |
| |
| 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 (fromRedis) memCached = fromRedis; |
| } |
|
|
| |
| if (refreshPromise) return refreshPromise; |
|
|
| refreshPromise = (async () => { |
| try { |
| |
| 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); |
| } |
| } |
|
|
| |
| memCached = await exchangeCredentials(email, password); |
| await cacheToRedis(memCached); |
| return memCached.accessToken; |
| } catch (err) { |
| console.error('[acled-auth] Failed to obtain ACLED access token', err); |
| |
| return memCached?.accessToken ?? null; |
| } finally { |
| refreshPromise = null; |
| } |
| })(); |
|
|
| return refreshPromise; |
| } |
|
|
| |
| const staticToken = process.env.ACLED_ACCESS_TOKEN?.trim(); |
| return staticToken || null; |
| } |
|
|