Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/api-integrations/cisco-duo-admin.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
title: 'Cisco Duo Admin API'
sidebarTitle: 'Cisco Duo Admin API'
description: 'Integrate your application with the Cisco Duo Admin API'
---

## 🚀 Quickstart

Connect to Cisco Duo Admin API with Nango and see data flow in 2 minutes.

<Steps>
<Step title="Create the integration">
In Nango ([free signup](https://app.nango.dev)), go to [Integrations](https://app.nango.dev/dev/integrations) -> _Configure New Integration_ -> _Cisco Duo Admin API_.
</Step>
<Step title="Authorize Cisco Duo Admin API">
Go to [Connections](https://app.nango.dev/dev/connections) -> _Add Test Connection_ -> _Authorize_, then enter your Integration Key, Secret Key, and API Hostname. Later, you'll let your users do the same directly from your app.
</Step>
<Step title="Call the Cisco Duo Admin API">
Let's make your first request to the Cisco Duo Admin API (list groups). Nango handles the Duo request signing automatically. Replace the placeholders below with your [Nango secret key](https://app.nango.dev/dev/environment-settings), [integration ID](https://app.nango.dev/dev/integrations), and [connection ID](https://app.nango.dev/dev/connections):

<Tabs>
<Tab title="cURL">

```bash
curl "https://api.nango.dev/proxy/admin/v1/groups" \
-H "Authorization: Bearer <NANGO-SECRET-KEY>" \
-H "Provider-Config-Key: <INTEGRATION-ID>" \
-H "Connection-Id: <CONNECTION-ID>"
```

</Tab>

<Tab title="Node">

Install Nango's backend SDK with `npm i @nangohq/node`. Then run:

```typescript
import { Nango } from '@nangohq/node';

const nango = new Nango({ secretKey: '<NANGO-SECRET-KEY>' });

const res = await nango.get({
endpoint: '/admin/v1/groups',
providerConfigKey: '<INTEGRATION-ID>',
connectionId: '<CONNECTION-ID>'
});

console.log(res.data);
```

</Tab>
</Tabs>

Or fetch credentials with the [Node SDK](/reference/sdks/node#get-a-connection-with-credentials) or [API](/reference/api/connection/get).

✅ You're connected! Check the [Logs](https://app.nango.dev/dev/logs) tab in Nango to inspect requests.
</Step>

<Step title="Implement Nango in your app">
Follow our [Auth implementation guide](/implementation-guides/platform/auth/implement-api-auth) to integrate Nango in your app.

To obtain your own production credentials, follow the setup guide linked below.
</Step>
</Steps>

## 📚 Cisco Duo Admin API Integration Guides

Nango-maintained guides for common use cases.

- [How to connect your Cisco Duo Admin API application](/api-integrations/cisco-duo-admin/connect)
Get your Duo Integration Key, Secret Key, and API Hostname and connect them to Nango

Official docs: [Duo Admin API docs](https://duo.com/docs/adminapi)

## 🧩 Pre-built syncs & actions for Cisco Duo Admin API

Enable them in your dashboard. [Extend and customize](/implementation-guides/platform/functions/customize-template) to fit your needs.

import PreBuiltUseCases from "/snippets/generated/cisco-duo-admin/PreBuiltUseCases.mdx"

<PreBuiltUseCases />
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions docs/api-integrations/cisco-duo-admin/connect.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: 'Cisco Duo Admin API - How do I link my account?'
sidebarTitle: 'Cisco Duo Admin API'
description: 'Create a Duo Admin API application and connect it to Nango'
---

# Overview

To authenticate with Cisco Duo Admin API, you need:
1. **Integration Key** - The integration key for your Duo Admin API application.
2. **Secret Key** - The secret key for your Duo Admin API application.
3. **API Hostname** - The API hostname for your Duo Admin API application, for example `api-xxxxxxxx.duosecurity.com`.

This guide walks you through creating a Duo Admin API application and connecting it to Nango.

### Prerequisites

- You must be a Duo administrator with the **Owner** role to create or update an Admin API application.

### Instructions

#### Step 1: Find your Integration Key, Secret Key, and API Hostname

1. Log in to the [Duo Admin Panel](https://admin.duosecurity.com/).
2. Go to **Applications** > **Application Catalog**.

<img src="/api-integrations/cisco-duo-admin/applications_catalog.png"/>


3. Add **Admin API** or open your existing **Admin API** application.

<img src="/api-integrations/cisco-duo-admin/admin-api-catalog.png" alt="Duo application catalog with the Admin API application" />

4. Configure the permissions your integration needs.
5. Copy the **Integration key**, **Secret key**, and **API hostname** from the application details page.

<img src="/api-integrations/cisco-duo-admin/app_settings.png" />


#### Step 2: Enter credentials in the Connect UI

Once you have your Duo Admin API credentials:
1. Open the form where you need to authenticate with Cisco Duo Admin API.
2. Enter your **Integration Key**, **Secret Key**, and **API Hostname** in their respective fields.
3. Submit the form, and you should be successfully authenticated.



You are now connected to Cisco Duo Admin API.
Binary file added docs/api-integrations/cisco-duo-admin/form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@
"integrations/all/docuware",
"api-integrations/domo",
"api-integrations/drata",
"api-integrations/cisco-duo-admin",
"integrations/all/drchrono",
"api-integrations/dropbox",
"integrations/all/dropbox-sign",
Expand Down
41 changes: 41 additions & 0 deletions packages/providers/providers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5393,6 +5393,47 @@ drata:
title: API Key
description: Your Drata API key

cisco-duo-admin:
display_name: Cisco Duo Admin API
categories:
- security
auth_mode: BASIC
proxy:
base_url: https://${connectionConfig.hostname}
headers:
date: '${now:ddd, DD MMM YYYY HH:mm:ss ZZ}'
authorization: "Basic ${base64(${credentials.username}:${hmacSha1Hex(${now:ddd, DD MMM YYYY HH:mm:ss ZZ}\n${method}\n${connectionConfig.hostname}\n${path}\n${params}, ${credentials.password})})}"
Comment on lines +5454 to +5456
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set Duo proxy body format to form-encoded

Duo signs POST parameters from an application/x-www-form-urlencoded body, but this provider only defines date and authorization headers, so POST/PUT requests with object data are sent as JSON by default while buildCanonicalParams signs them as URL-encoded key/value pairs. That mismatch causes signature validation failures (e.g., 40103) on write endpoints unless every caller manually overrides headers/body format. Add provider-level form encoding (header + serialization path) so signed bytes match transmitted bytes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The body format form is not needed at the provider level because the proxy passes through the request body as-is; callers are expected to set the correct Content-Type headers and serialize the body themselves.

retry:
after:
- 'Retry-After'
verification:
method: GET
endpoints:
- /admin/v1/users?limit=1
docs: https://nango.dev/docs/api-integrations/cisco-duo-admin
docs_connect: https://nango.dev/docs/api-integrations/cisco-duo-admin/connect
connection_config:
hostname:
type: string
title: API Hostname
description: Your Duo API hostname.
example: api-xxxxxxxx.duosecurity.com
pattern: 'api-[a-z0-9]+\.duosecurity\.com'
order: 1
credentials:
username:
type: string
title: Integration Key
description: Your Duo application's integration key.
example: DIWJ8X6**********TQ1
pattern: 'DI[A-Z0-9]{18}'
password:
type: string
title: Secret Key
description: Your Duo application's secret key.
example: Zh5eGm**************************zLJ4Ep
pattern: '[A-Za-z0-9]{40}'

contactout:
display_name: ContactOut
categories:
Expand Down
65 changes: 62 additions & 3 deletions packages/shared/lib/services/proxy/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,45 @@ export function buildProxyURL({ config, connection }: { config: ApplicationConst
return url.toString();
}

// builds the canonical parameter string as required by the Duo API request signing spec.
// https://duo.com/docs/authapi#authentication
export function buildCanonicalParams(method: string, data: unknown, queryString: string): string {
const encode = (s: string) =>
encodeURIComponent(s)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
.replace(/%[0-9a-f]{2}/g, (m) => m.toUpperCase());

const fromQueryString = (qs: string) =>
qs
.split('&')
.filter(Boolean)
.map((pair) => {
const i = pair.indexOf('=');
return {
k: decodeURIComponent(i === -1 ? pair : pair.slice(0, i)),
v: decodeURIComponent(i === -1 ? '' : pair.slice(i + 1))
Comment thread
hassan254-prog marked this conversation as resolved.
Outdated
};
})
.sort((a, b) => a.k.localeCompare(b.k))
.map(({ k, v }) => `${encode(k)}=${encode(v)}`)
.join('&');

const isBodyMethod = ['POST', 'PUT', 'PATCH'].includes(method);

if (isBodyMethod) {
if (!data) return '';
if (Buffer.isBuffer(data)) return fromQueryString(data.toString('utf8'));
if (typeof data === 'string') return fromQueryString(data.startsWith('?') ? data.slice(1) : data);
if (typeof data !== 'object' || data instanceof FormData) return '';
return Object.entries(data as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${encode(k)}=${encode(String(v))}`)
.join('&');
Comment thread
hassan254-prog marked this conversation as resolved.
}

return queryString ? fromQueryString(queryString) : '';
}

/**
* Build Headers for proxy
*/
Expand Down Expand Up @@ -412,11 +451,25 @@ export function buildProxyHeaders({
const headerValues = Object.values(config.provider.proxy.headers).filter((v): v is string => typeof v === 'string');
const stableReplacers = getStableInterpolationReplacers(headerValues);

const baseReplacers = { endpoint: config.endpoint };
const queryStringStart = config.endpoint.indexOf('?');
const endpointPath = queryStringStart !== -1 ? config.endpoint.slice(0, queryStringStart) : config.endpoint;
const endpointQuery = queryStringStart !== -1 ? config.endpoint.slice(queryStringStart + 1) : '';
const baseReplacers = {
endpoint: config.endpoint,
path: endpointPath,
params: buildCanonicalParams(config.method, config.data, endpointQuery)
Comment thread
hassan254-prog marked this conversation as resolved.
Outdated
};

for (const [key, value] of Object.entries(config.provider.proxy.headers) as [Lowercase<string>, string][]) {
if (value.includes('connectionConfig')) {
headers[key] = interpolateIfNeeded(value.replace(/connectionConfig\./g, ''), connection.connection_config);
headers[key] = interpolateIfNeeded(value, {
connectionConfig: connection.connection_config,
credentials: connection.credentials,
...(connection.credentials as Record<string, string>),
method: config.method,
...stableReplacers,
...baseReplacers
});
continue;
}

Expand All @@ -443,7 +496,13 @@ export function buildProxyHeaders({
break;
}
default:
headers[key] = interpolateIfNeeded(value, connection.credentials as Record<string, string>);
headers[key] = interpolateIfNeeded(value, {
credentials: connection.credentials,
...(connection.credentials as Record<string, string>),
method: config.method,
...stableReplacers,
...baseReplacers
});
break;
}
}
Expand Down
100 changes: 99 additions & 1 deletion packages/shared/lib/services/proxy/utils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import FormData from 'form-data';
import { describe, expect, it } from 'vitest';

import { ProxyError, buildProxyHeaders, buildProxyURL, getAxiosConfiguration, getProxyConfiguration } from './utils.js';
import { ProxyError, buildCanonicalParams, buildProxyHeaders, buildProxyURL, getAxiosConfiguration, getProxyConfiguration } from './utils.js';
import { getDefaultProxy } from './utils.test.js';
import { getTestConnection } from '../../seeders/connection.seeder.js';

Expand Down Expand Up @@ -1202,3 +1203,100 @@ describe('getProxyConfiguration', () => {
});
});
});

describe('buildCanonicalParams', () => {
describe('GET — query string from endpoint', () => {
it('returns empty string when no query string', () => {
expect(buildCanonicalParams('GET', undefined, '')).toBe('');
});

it('returns single param encoded', () => {
expect(buildCanonicalParams('GET', undefined, 'username=root')).toBe('username=root');
});

it('sorts params lexicographically by key', () => {
expect(buildCanonicalParams('GET', undefined, 'username=root&realname=First Last')).toBe('realname=First%20Last&username=root');
});

it('uses uppercase hex digits', () => {
expect(buildCanonicalParams('GET', undefined, 'email=user@example.com')).toBe('email=user%40example.com');
});

it('does not encode RFC 3986 unreserved chars (A-Za-z0-9 - _ . ~)', () => {
expect(buildCanonicalParams('GET', undefined, 'q=hello-world_test.value~')).toBe('q=hello-world_test.value~');
});

it('decodes then re-encodes existing encoding', () => {
// input already has %20, should decode and re-encode with uppercase
expect(buildCanonicalParams('GET', undefined, 'realname=First%20Last&username=root')).toBe('realname=First%20Last&username=root');
});

it('handles multiple params already sorted', () => {
expect(buildCanonicalParams('GET', undefined, 'limit=10&offset=0')).toBe('limit=10&offset=0');
});
});

describe('DELETE — same as GET (query string)', () => {
it('uses query string for DELETE', () => {
expect(buildCanonicalParams('DELETE', undefined, 'id=123')).toBe('id=123');
});
});

describe('POST — body params (Buffer)', () => {
it('parses form-encoded Buffer body', () => {
const body = Buffer.from('name=My%20Group&desc=Test');
expect(buildCanonicalParams('POST', body, '')).toBe('desc=Test&name=My%20Group');
});

it('returns empty string for empty Buffer', () => {
expect(buildCanonicalParams('POST', Buffer.from(''), '')).toBe('');
});
});

describe('POST — body params (string)', () => {
it('parses form-encoded string body', () => {
expect(buildCanonicalParams('POST', 'name=My%20Group', '')).toBe('name=My%20Group');
});

it('strips leading ? from string body', () => {
expect(buildCanonicalParams('POST', '?name=test', '')).toBe('name=test');
});

it('sorts string body params', () => {
expect(buildCanonicalParams('POST', 'username=root&realname=First%20Last', '')).toBe('realname=First%20Last&username=root');
});
});

describe('POST — body params (plain object)', () => {
it('encodes plain object body', () => {
expect(buildCanonicalParams('POST', { name: 'My Group' }, '')).toBe('name=My%20Group');
});

it('sorts plain object keys', () => {
expect(buildCanonicalParams('POST', { username: 'root', realname: 'First Last' }, '')).toBe('realname=First%20Last&username=root');
});

it('returns empty string for null data', () => {
expect(buildCanonicalParams('POST', null, '')).toBe('');
});

it('returns empty string for FormData', () => {
expect(buildCanonicalParams('POST', new FormData(), '')).toBe('');
});
});

describe('encoding correctness', () => {
it('encodes space as %20 (not +)', () => {
expect(buildCanonicalParams('GET', undefined, 'q=hello world')).toBe('q=hello%20world');
});

it('encodes @ with uppercase hex', () => {
expect(buildCanonicalParams('GET', undefined, 'email=a@b.com')).toBe('email=a%40b.com');
});

it('encodes ! ( ) * with uppercase hex', () => {
const result = buildCanonicalParams('GET', undefined, 'q=a!b(c)d*e');
expect(result).toBe('q=a%21b%28c%29d%2Ae');
});
});
});
Loading
Loading