The picture. Your team's AI assistant can already read the CRM. A rep asks it to "pull up my customers" — done in seconds. Then they ask for the defence-sector accounts, the most sensitive records you have. An agent that's merely logged in would hand them right over.
This demo makes that moment safe. The agent can use the CRM freely, but the instant a request touches sensitive data, Cisco Duo pushes a real-time approval to the right person's phone — and the records only move once it's approved. Deny it, and the agent is stopped cold.
The shift: the identity and policy you already trust in Duo now govern what your agents can do — every login, every scope, every sensitive action — and Arcade enforces it on every tool call, with no new authorization stack to build. This repo is that, end to end: a Duo-protected CRM, an Arcade MCP server, and the contextual step-up that turns "the agent is logged in" into "the agent is authorized, right now, for this."
Cisco Duo is the central place for your access policies. Arcade enforces them in real time, on every agent tool call.
This repo is a working demo of identity-governed AI agents: Duo owns who can do what, and Arcade — the runtime for MCP tools — makes the agent obey it, in two layers:
- Login + per-tool scopes (OIDC). Duo SSO logs the user in (with MFA), Arcade brokers the token, and each MCP tool requires a specific Duo-managed scope.
- Contextual step-up MFA (out-of-band). An Arcade Pre-Execution Hook inspects each tool call and, when the request is sensitive (e.g. "show me my defence customers"), triggers a real-time Duo Push via the Duo Auth API before the tool is allowed to run.
Pairing Cisco Duo (identity + policy) with Arcade (the MCP runtime that brokers auth and runs tools) lets you give agents real, governed access to enterprise systems — without rebuilding auth:
- Agents inherit your existing Duo policies. The same identities, groups, MFA, and device/risk rules that protect your apps now protect what an agent can do.
- Per-tool authorization, managed in Duo. Each tool maps to a Duo scope; granting/revoking a scope per user or group instantly changes which tools that user's agent can execute.
- Real-time, content-aware step-up. Authorization isn't just "who you are" at login — Arcade can require a fresh Duo Push based on what the request is doing (sensitive query, write vs. read, a flagged record), out-of-band, mid-conversation.
- Revocable and auditable. Revoke in Duo → the agent loses access on the next call. Every tool call flows through Duo's controls and Arcade's policy hooks.
- No token ever touches the model. Arcade injects credentials at execution time; the LLM and MCP client never see them.
The demo makes this concrete with a tiny Duo-protected CRM and an agent that reads it.
Full diagrams of both layers: docs/architecture.md.
- Repo layout
- Prerequisites
- Setup
- 1. Cisco Duo — overview (two apps)
- 2. Duo — create the OAuth / OIDC app (login)
- 3. Duo — scopes & access policy
- 4. Duo — create the Auth API app (step-up MFA)
- 5. Run the CRM API + expose it
- 6. Arcade — configure the OAuth provider
- 7. Arcade — deploy the MCP server + gateway
- 8. Arcade — configure the Contextual Access hook
- Run the demo
- Step-up MFA — what the user sees
- Environment variables
- How it works
- Troubleshooting
- Production notes
- Tests
- Arcade documentation
- License
.
├── README.md
├── docs/
│ ├── architecture.md # two-layer architecture + diagrams
│ └── images/ # screenshots used throughout
├── crm_api/ # Duo-protected CRM (FastAPI) + the contextual-access hook
│ ├── main.py # endpoints, scope enforcement, POST /hooks/pre
│ ├── duo_auth.py # token validation via Duo token introspection
│ ├── stepup.py # sensitivity check + Duo Auth API push (duo_client)
│ ├── seed.py # in-memory demo contacts (incl. a defence-sector one)
│ ├── requirements.txt
│ └── .env.example
└── crm_mcp/ # Arcade MCP server (the tools the agent calls)
├── pyproject.toml
├── .env.example
└── src/crm_mcp/server.py # tools, each requires_auth=OAuth2(id="cisco-duo-oidc", ...)
- A Cisco Duo account with Duo SSO enabled (Essentials / Advantage / Premier).
- An Arcade account + API key —
https://api.arcade.dev/dashboard. - Python 3.10+,
uv, the Arcade CLI (uv tool install arcade-mcp). - ngrok (or equivalent) to expose the local CRM + hook over HTTPS.
- A smartphone with Duo Mobile for push approvals.
Throughout, the Arcade-side steps link to the official Arcade docs rather than duplicating them. The Duo steps and the step-up MFA mechanics are documented here in full, since that's the part this demo adds.
In the Duo Admin Panel (https://admin.duosecurity.com → Applications → Application Catalog) you create two separate apps — same account, different types/credentials:
| Duo app | Purpose | Credentials |
|---|---|---|
| OAuth 2.1 / OIDC – Single Sign-On | Login + scoped tokens (Layer 1) | Client ID / Secret + OIDC endpoints |
| Auth API (entry labeled 2FA) | Programmatic Duo Push for step-up (Layer 2) | ikey / skey / API host |
- Application Catalog → search OIDC → choose "OAuth 2.1 / OIDC – Single Sign-On" (SSO label) → Protect.
- General: enable grant type Authorization Code (+ Refresh Token if you want silent refresh). Leave Sign-In Redirect URLs empty for now.
- Metadata: copy the Issuer, Client ID, Client Secret. Issuer pattern:
Endpoints derive from it:
https://sso-<tenant>.sso.duosecurity.com/oauth2/<app_id>/authorize,/token,/userinfo,/token_introspection. - User access → Enable for all users (the default Disable for all users blocks everyone).
- Confirm the app Policy requires two-factor authentication at login.
→ Arcade's own write-up of this provider: Cisco Duo auth provider and the generic OAuth 2.0 provider reference.
- Scopes tab → Add Custom Scope — create
crm.contacts.read,crm.contacts.write,crm.deals.read. Duo requires each custom scope to map at least one claim (any benign mapping works — the CRM reads the granted scope list, not these claims). Keepopenid,profile,emailenabled. - Clients tab → Confidential client scopes — add all six so the client may request them.
- Access Policy → Scope Authorization — grant scopes per user / per group. This is the dial: revoke here and the matching tool stops working.
- Application Catalog → search Auth API → choose the 2FA-labeled entry → Protect.
- Copy the Integration key (
ikey), Secret key (skey), API hostname (api-<tenant>.duosecurity.com). - User access → Enable for all users (otherwise the API returns "Your account does not have access to this application").
- The user you push to must be enrolled with Duo Mobile activated (a phone number isn't enough — activate Duo Mobile so the device is push-capable). Note that Duo username → it becomes
DUO_STEPUP_USERNAME.
Duo Auth API reference: https://duo.com/docs/authapi (the /preauth and /auth endpoints, and pushinfo).
cd crm_api
uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
cp .env.example .env # fill in per "Environment variables" below
.venv/bin/uvicorn main:app --port 8000
ngrok http 8000 # note the https URLSmoke-test without Duo: set DEV_ALLOW_INSECURE=true, then curl localhost:8000/healthz and curl localhost:8000/hooks/health. Set it back to false for the real flow.
Dashboard → OAuth → Add OAuth Provider → OAuth 2.0: ID cisco-duo-oidc, the Duo Client ID/Secret, Authorization URL <issuer>/authorize, Token URL <issuer>/token, scopes openid profile email, PKCE enabled (S256). Save, then paste Arcade's generated Redirect URL into the Duo OIDC app's Sign-In Redirect URLs.
→ Steps: Configure a custom OAuth 2.0 provider.
cd crm_mcp
cp .env.example .env # CRM_API_BASE_URL = your public CRM (ngrok) URL
arcade login
arcade deploy -e src/crm_mcp/server.pyThen create an MCP Gateway and connect your agent.
→ Docs: Build an MCP server · Add user authorization to tools · Deploy · MCP Gateways.
Dashboard → Contextual Access → create a Logic Extension with a Pre-Execution Hook:
- Endpoint:
https://<your-public-host>/hooks/pre - Auth: Bearer = your
HOOK_BEARER_TOKEN(same value ascrm_api/.env) - Timeout: ~75s
⚠️ (a synchronous Duo Push can take up to ~60s; the 5s default blocks the call before you can approve) - Failure mode: fail closed · Scope: the
duo_crmproject / tools
→ Docs: Contextual Access · Webhook API reference.
The hook fires on every tool call. If its endpoint is unreachable, all CRM tools fail with "extension endpoint not found" — keep the CRM + tunnel running.
In your agent:
- "List my CRM contacts" → returns everyone immediately (no step-up).
- "Search my CRM for defence customers" → the hook flags the sensitive query → Duo Push to your phone → approve → Riley Vance @ Aegis Defence Systems. Deny → the tool is blocked.
Scope demo: revoke crm.deals.read from your user in Duo → list_deals returns 403; re-grant → it works again.
When a tool call is flagged sensitive, Arcade's Pre-Execution Hook calls crm_api's /hooks/pre, which sends a synchronous Duo Push through the Auth API (factor=push) and waits for the decision. The push carries the request context via Duo's pushinfo parameter, so the approver sees exactly what the agent is trying to do:
In this example the Duo Mobile prompt shows:
- Title:
Sensitive CRM access: Auth API - App:
Arcade.dev· User:demouser - Context (pushinfo):
from: Acme CRM·tool: ListContacts·query: defence - Actions: Approve / Deny
The flow:
- Agent calls a tool (e.g.
list_contacts(query="defence")). - Arcade invokes the Pre-Execution Hook →
POST /hooks/prewith the tool name, inputs, and user context. - The hook checks the inputs against
SENSITIVE_QUERY_TERMS. Not sensitive → returns{"code":"OK"}immediately (no push). Sensitive → continues. - The hook
preauths the Duo user, then sends a Duo Push withpushinfo(from,tool,query) and blocks until the user responds. - Approve → hook returns
{"code":"OK"}→ the tool runs. Deny / timeout → returns{"code":"CHECK_FAILED", "error_message": ...}→ Arcade blocks the call and the agent surfaces the reason.
This is out-of-band, content-aware step-up: it layers on top of the static scopes from login, and the decision is driven by what the request is doing, not just who's asking. Which terms trip it is configurable via SENSITIVE_QUERY_TERMS; the Duo username pushed to is DUO_STEPUP_USERNAME.
# --- Duo OIDC app (login / token validation) ---
DUO_OIDC_ISSUER=https://sso-<tenant>.sso.duosecurity.com/oauth2/<app_id>
DUO_CLIENT_ID=<oidc app client id>
DUO_CLIENT_SECRET=<oidc app client secret>
DUO_INTROSPECTION_URL= # optional; defaults to <issuer>/token_introspection
# --- Contextual step-up MFA (Duo Auth API) ---
DUO_AUTH_IKEY=<auth api integration key>
DUO_AUTH_SKEY=<auth api secret key>
DUO_AUTH_HOST=api-<tenant>.duosecurity.com
DUO_STEPUP_USERNAME=<enrolled Duo username to push to>
HOOK_BEARER_TOKEN=<random secret; must match the Arcade Logic Extension bearer>
SENSITIVE_QUERY_TERMS=defence,defense,classified,military,intelligence
# --- Local testing only ---
DEV_ALLOW_INSECURE=false # true = skip token validation (CRM only)
DEV_SCOPES=openid profile email crm.contacts.read crm.contacts.write crm.deals.read
STEPUP_DEV_MODE= # "allow"/"deny" = simulate the push without real DuoCRM_API_BASE_URL=https://<your-public-host> # the ngrok https URL of the CRM.env files are git-ignored. Use arcade secret set / the Dashboard for the deployed MCP's secrets.
- Token validation & scopes (Layer 1):
crm_api/duo_auth.pycalls Duo token introspection (authenticated with the OIDC client creds) to validate the bearer token and read the grantedscope.crm_api/main.pymaps each endpoint to a required scope → 403 if missing. Duo decides what's granted; the CRM enforces it. - Contextual step-up (Layer 2):
crm_api/main.pyexposesPOST /hooks/pre(Arcade's hook contract).crm_api/stepup.pyruns the sensitivity check and the Duo Auth API push viaduo_client. - MCP server: each tool declares
requires_auth=OAuth2(id="cisco-duo-oidc", scopes=[...])and forwards the Arcade-injected token to the CRM — no auth code; Arcade brokers the Duo OAuth flow.
| Symptom | Cause / fix |
|---|---|
| Duo: "Must specify both 'code_challenge' and 'code_challenge_method'" | Enable PKCE (S256) on the Arcade provider — Duo OAuth 2.1 requires it. |
| Duo: "no scopes … configured for the specified Relying Party" | Add the scopes to the client's Confidential client scopes (Clients tab). |
| Login link 403 / no access | OIDC app User access = Disable for all users — set Enable for all users. |
| Step-up: "Your account does not have access to this application" | Enable User access on the Auth API app. |
Step-up: 400 no capable device / preauth enroll |
DUO_STEPUP_USERNAME has no activated Duo Mobile push device — enroll/activate it. |
| Push never arrives | DUO_STEPUP_USERNAME isn't the enrolled Duo username (often not the email — check Admin → Users). |
| Agent: "extension endpoint not found" | The CRM/hook or tunnel is down, or the Logic Extension URL no longer matches the tunnel. |
| Blocked before you can approve | Arcade hook timeout too low — raise to ~75s. |
| Tool returns 403 "Missing required scope" | Expected when the user lacks that scope in Duo. Grant it (and re-authorize). |
- Run the contextual-access hook as its own service (not co-located in the CRM), behind stable HTTPS with Bearer or mTLS.
- Prefer async Duo Push (
async=1+/auth_statuspolling) bounded under the Arcade hook timeout, instead of a long synchronous call. - Map the Arcade user identity to the Duo username deterministically (the demo uses a single
DUO_STEPUP_USERNAME). - Consider Verified Duo Push / risk-based policies for higher-assurance step-up.
- Treat all
DUO_*keys andHOOK_BEARER_TOKENas secrets.
A dev-mode smoke suite exercises both authorization layers without contacting Duo. Token validation runs in DEV_ALLOW_INSECURE mode and the step-up is simulated with STEPUP_DEV_MODE, so the tests cover the per-tool 403 and the step-up approve and deny paths offline.
cd crm_api
uv venv .venv && uv pip install --python .venv/bin/python -r requirements-dev.txt
.venv/bin/pytest- Auth providers (overview): https://docs.arcade.dev/en/references/auth-providers
- Cisco Duo auth provider: https://docs.arcade.dev/en/references/auth-providers/cisco-duo
- Custom OAuth 2.0 provider: https://docs.arcade.dev/en/references/auth-providers/oauth2
- Build an MCP server: https://docs.arcade.dev/en/guides/create-tools/tool-basics/build-mcp-server
- Add user authorization to tools: https://docs.arcade.dev/en/guides/create-tools/tool-basics/create-tool-auth
- Deploy to Arcade: https://docs.arcade.dev/en/guides/deployment-hosting/arcade-deploy
- MCP Gateways: https://docs.arcade.dev/en/guides/mcp-gateways
- Contextual Access (Logic Extensions): https://docs.arcade.dev/en/guides/contextual-access
- Contextual Access webhook API: https://docs.arcade.dev/en/references/contextual-access-webhook-api
This project is released under the MIT License. See LICENSE.




