/** * Duo Security API Client * Handles HMAC-SHA1 request signing, pagination, rate-limit handling. * Supports both the Accounts API (parent) and Admin API (parent + child accounts). */ import crypto from 'crypto'; import https from 'https'; export interface DuoAccount { account_id: string; api_hostname: string; name: string; } export interface DuoClientOptions { ikey: string; skey: string; host: string; timeoutMs?: number; } export interface DuoApiResponse { stat: string; response: T; metadata?: { total_objects?: number; next_offset?: number; }; } export interface DuoUserSummary { user_count: number; integration_count: number; telephony_credits_remaining?: number; } export interface DuoUser { user_id: string; username: string; email: string; realname: string; status: string; is_enrolled: boolean; last_login: number | null; last_directory_sync: number | null; created: number; notes: string; phones: any[]; groups: any[]; aliases: Record; enable_auto_prompt: boolean; firstname: string; lastname: string; } export interface DuoPhone { phone_id: string; name: string; number: string; type: string; platform: string; model: string; os_version: string; app_version: string; activated: boolean; last_seen: string; capabilities: string[]; users: any[]; } export interface DuoGroup { group_id: string; name: string; desc: string; status: string; mobile_otp_enabled: boolean; push_enabled: boolean; sms_enabled: boolean; voice_enabled: boolean; } export interface DuoIntegration { integration_key: string; name: string; type: string; notes: string; } export interface DuoAuthLog { txid: string; timestamp: number; user: { key: string; name: string; }; factor: string; result: string; reason: string; application: { key: string; name: string; }; access_device: { ip: string; hostname: string | null; location: { city: string; state: string; country: string; }; }; auth_device: { ip: string | null; name: string; }; event_type: string; } export class DuoClient { private readonly ikey: string; private readonly skey: string; private readonly host: string; private readonly timeoutMs: number; constructor(options: DuoClientOptions) { if (!options.ikey || !options.skey || !options.host) { throw new Error('DuoClient requires ikey, skey, and host'); } this.ikey = options.ikey; this.skey = options.skey; this.host = options.host; this.timeoutMs = options.timeoutMs ?? 30_000; } // ── 1.2 HMAC-SHA1 request signing ──────────────────────────────────────── private sign(method: string, host: string, path: string, params: Record, date: string): string { const sortedParams = Object.keys(params).sort() .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&'); const canon = [date, method.toUpperCase(), host.toLowerCase(), path, sortedParams].join('\n'); const sig = crypto.createHmac('sha1', this.skey).update(canon).digest('hex'); return `Basic ${Buffer.from(`${this.ikey}:${sig}`).toString('base64')}`; } // ── 1.3 Core request methods (with 1.5 rate-limit + 1.6 timeout) ──────── private request( method: string, path: string, params: Record = {}, hostOverride?: string, ): Promise> { const host = hostOverride ?? this.host; const date = new Date().toUTCString(); const auth = this.sign(method, host, path, params, date); const sortedParams = Object.keys(params).sort() .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&'); const isGet = method.toUpperCase() === 'GET'; const urlPath = isGet ? `${path}${sortedParams ? '?' + sortedParams : ''}` : path; const body = isGet ? '' : sortedParams; return new Promise((resolve, reject) => { const req = https.request({ hostname: host, path: urlPath, method: method.toUpperCase(), headers: { Authorization: auth, Date: date, 'Content-Type': 'application/x-www-form-urlencoded', ...(body ? { 'Content-Length': String(Buffer.byteLength(body)) } : {}), }, }, res => { let data = ''; res.on('data', (chunk: Buffer) => data += chunk); res.on('end', async () => { // 1.5 rate-limit handling if (res.statusCode === 429) { const retryAfter = parseInt(res.headers['retry-after'] as string || '5', 10); console.log(`[DuoClient] Rate limited on ${path}, retrying in ${retryAfter}s`); await new Promise(r => setTimeout(r, retryAfter * 1000)); return resolve(this.request(method, path, params, hostOverride)); } try { const parsed = JSON.parse(data) as DuoApiResponse; if (parsed.stat !== 'OK') { reject(new Error(`Duo API error on ${path}: ${(parsed as any).message || JSON.stringify(parsed)}`)); } else { resolve(parsed); } } catch { reject(new Error(`Duo API non-JSON response on ${path}: HTTP ${res.statusCode} — ${data.slice(0, 200)}`)); } }); }); // 1.6 timeout req.setTimeout(this.timeoutMs, () => { req.destroy(); reject(new Error(`Duo API timeout on ${path} after ${this.timeoutMs}ms`)); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } async get(path: string, params: Record = {}, hostOverride?: string): Promise> { return this.request('GET', path, params, hostOverride); } async post(path: string, params: Record = {}, hostOverride?: string): Promise> { return this.request('POST', path, params, hostOverride); } // ── 1.4 Paginated GET — follows metadata.next_offset ───────────────────── async getPaginated(path: string, params: Record = {}, hostOverride?: string): Promise { const all: T[] = []; let offset = 0; const limit = '300'; while (true) { const pageParams = { ...params, limit, offset: String(offset) }; const res = await this.get(path, pageParams, hostOverride); const items = res.response; if (!Array.isArray(items)) break; all.push(...items); if (res.metadata?.next_offset !== undefined && res.metadata.next_offset !== null) { offset = res.metadata.next_offset; } else { break; } } return all; } // ── 1.7 Accounts API ───────────────────────────────────────────────────── async listAccounts(): Promise { const res = await this.post('/accounts/v1/account/list'); return res.response; } // ── 1.8 + 1.9 Admin API methods (with child account support) ───────────── // When `child` is provided, requests are signed against child.api_hostname // and account_id is passed as a parameter. private childParams(child?: DuoAccount): Record { return child ? { account_id: child.account_id } : {}; } private childHost(child?: DuoAccount): string | undefined { return child?.api_hostname; } async getAccountSummary(child?: DuoAccount): Promise { const res = await this.get( '/admin/v1/info/summary', this.childParams(child), this.childHost(child), ); return res.response; } async getUsers(child?: DuoAccount): Promise { return this.getPaginated( '/admin/v1/users', this.childParams(child), this.childHost(child), ); } async getPhones(child?: DuoAccount): Promise { return this.getPaginated( '/admin/v1/phones', this.childParams(child), this.childHost(child), ); } async getGroups(child?: DuoAccount): Promise { return this.getPaginated( '/admin/v1/groups', this.childParams(child), this.childHost(child), ); } async getIntegrations(child?: DuoAccount): Promise { return this.getPaginated( '/admin/v1/integrations', this.childParams(child), this.childHost(child), ); } async getAuthLogs(mintime: number, child?: DuoAccount): Promise { // Auth logs v2 has a different response structure: // { stat: "OK", response: { authlogs: [...], metadata: { next_offset: [...] } } } // So we can't use getPaginated — custom pagination here. const all: DuoAuthLog[] = []; let nextOffset: string[] | undefined; while (true) { const params: Record = { ...this.childParams(child), mintime: String(mintime), maxtime: String(Date.now()), limit: '1000', }; if (nextOffset) { params.next_offset = JSON.stringify(nextOffset); } const res = await this.get<{ authlogs: DuoAuthLog[]; metadata: { next_offset?: string[] } }>( '/admin/v2/logs/authentication', params, this.childHost(child), ); const logs = res.response?.authlogs ?? []; all.push(...logs); nextOffset = res.response?.metadata?.next_offset; if (!nextOffset || logs.length === 0) break; } return all; } } // ── Factory helpers ────────────────────────────────────────────────────────── export function getDuoAccountsClient(): DuoClient { return new DuoClient({ ikey: process.env.DUOACCOUNTS_INTEGRATION_KEY!, skey: process.env.DUOACCOUNTS_SECRET_KEY!, host: process.env.DUOACCOUNTS_API_HOSTNAME!, }); } export function getDuoAdminClient(): DuoClient { return new DuoClient({ ikey: process.env.DUOADMIN_INTEGRATION_KEY!, skey: process.env.DUOADMIN_SECRET_KEY!, host: process.env.DUOADMIN_API_HOSTNAME!, }); }