wulf-pulse/lib/services/duo-client.ts
lorentz a4242b81be feat: Duo Security integration — full data sync from Accounts + Admin API
Duo API Client (lib/services/duo-client.ts):
- HMAC-SHA1 request signing, GET/POST, automatic pagination
- Rate-limit handling (429 + Retry-After), configurable timeout
- Accounts API: listAccounts() via POST /accounts/v1/account/list
- Admin API: getUsers, getPhones, getGroups, getIntegrations, getAuthLogs
- Child account access: parent creds signed against child api_hostname + account_id
- Factory helpers: getDuoAccountsClient(), getDuoAdminClient()

Database (migration 058):
- 6 tables: duo_accounts, duo_users, duo_phones, duo_auth_logs, duo_groups, duo_integrations
- All with proper FKs, indexes, JSONB fields for capabilities/location/groups

Sync Service (lib/services/duo-sync-service.ts):
- syncAll(): accounts → per-child data + auth logs → parent account → company matching
- Sequential child processing to respect rate limits
- Incremental auth logs (mintime = last synced timestamp, default 30 days)
- Company matching: exact → case-insensitive containment (30/32 = 94% matched)
- Non-blocking with sync ID tracking

API Routes:
- POST/GET /api/duo/sync — trigger sync / check status
- GET /api/duo/accounts — list all accounts with stats + matched company
- GET /api/duo/accounts/[id]/users — users for a specific account
- POST /api/openclaw/sync/duo — OpenClaw trigger with API key auth

Results: 33 accounts, 832 users, 925 phones, 5927 auth logs, 46 groups, 78 integrations
2026-03-27 09:18:04 -04:00

334 lines
10 KiB
TypeScript

/**
* 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<T = any> {
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<string, string | null>;
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<string, string>, 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<T = any>(
method: string,
path: string,
params: Record<string, string> = {},
hostOverride?: string,
): Promise<DuoApiResponse<T>> {
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<T>(method, path, params, hostOverride));
}
try {
const parsed = JSON.parse(data) as DuoApiResponse<T>;
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<T = any>(path: string, params: Record<string, string> = {}, hostOverride?: string): Promise<DuoApiResponse<T>> {
return this.request<T>('GET', path, params, hostOverride);
}
async post<T = any>(path: string, params: Record<string, string> = {}, hostOverride?: string): Promise<DuoApiResponse<T>> {
return this.request<T>('POST', path, params, hostOverride);
}
// ── 1.4 Paginated GET — follows metadata.next_offset ─────────────────────
async getPaginated<T = any>(path: string, params: Record<string, string> = {}, hostOverride?: string): Promise<T[]> {
const all: T[] = [];
let offset = 0;
const limit = '300';
while (true) {
const pageParams = { ...params, limit, offset: String(offset) };
const res = await this.get<T[]>(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<DuoAccount[]> {
const res = await this.post<DuoAccount[]>('/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<string, string> {
return child ? { account_id: child.account_id } : {};
}
private childHost(child?: DuoAccount): string | undefined {
return child?.api_hostname;
}
async getAccountSummary(child?: DuoAccount): Promise<DuoUserSummary> {
const res = await this.get<DuoUserSummary>(
'/admin/v1/info/summary',
this.childParams(child),
this.childHost(child),
);
return res.response;
}
async getUsers(child?: DuoAccount): Promise<DuoUser[]> {
return this.getPaginated<DuoUser>(
'/admin/v1/users',
this.childParams(child),
this.childHost(child),
);
}
async getPhones(child?: DuoAccount): Promise<DuoPhone[]> {
return this.getPaginated<DuoPhone>(
'/admin/v1/phones',
this.childParams(child),
this.childHost(child),
);
}
async getGroups(child?: DuoAccount): Promise<DuoGroup[]> {
return this.getPaginated<DuoGroup>(
'/admin/v1/groups',
this.childParams(child),
this.childHost(child),
);
}
async getIntegrations(child?: DuoAccount): Promise<DuoIntegration[]> {
return this.getPaginated<DuoIntegration>(
'/admin/v1/integrations',
this.childParams(child),
this.childHost(child),
);
}
async getAuthLogs(mintime: number, child?: DuoAccount): Promise<DuoAuthLog[]> {
// 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<string, string> = {
...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!,
});
}