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
334 lines
10 KiB
TypeScript
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!,
|
|
});
|
|
}
|