Skip to content

Feature: Add duo universal prompt API support#447

Merged
pdecat merged 5 commits into
venth:masterfrom
mehalter:feature/add-duo-universal-prompt
Jul 17, 2026
Merged

Feature: Add duo universal prompt API support#447
pdecat merged 5 commits into
venth:masterfrom
mehalter:feature/add-duo-universal-prompt

Conversation

@mehalter

@mehalter mehalter commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #446

Duo rolled out their new single-page Universal Prompt (the "pwl" flow), which talks to a completely different set of JSON endpoints than the prompt aws-adfs already supported. Tenants that got switched over started seeing 405s and could no longer log in.

This adds support for the new prompt alongside the existing one. It detects which prompt Duo serves and drives the pwl endpoints when needed, covering the same factors as before:

  • Duo Push
  • Phone Call
  • Passcode
  • WebAuthn Security Key

--duo-factor and --duo-device keep their existing meaning, and the WebAuthn path reuses the FIDO2 code that was already in the file. Verified end to end against a live tenant on the new prompt with all four factors.

EDIT: CI/CD has been tested on my fork to be all passing (https://github.com/mehalter/aws-adfs/actions/runs/29338976977)

mehalter added 4 commits July 14, 2026 08:54
Newer Duo tenants serve a single-page 'pwl' Universal Prompt keyed by an
authkey rather than the older sid-based /frame/v4/* endpoints, which made
the client POST to a malformed path and receive HTTP 405 (upstream venth#446).

Detect the pwl prompt via its data-authkey element and drive Duo's JSON
endpoints under /prompt/{akey}/*: pre_authn/initialization, pre_authn/
evaluation, auth/payload, auth/factors/{push,phone_call}, status polling,
and auth/finalize_auth to obtain the OIDC redirect back to ADFS. Surface
the verified-push step_up_code when Duo requires it. The legacy sid-based
flow is preserved for older tenants.
Broaden the Duo Universal Prompt (pwl) flow to cover Phone Call and
Passcode in addition to Duo Push, keeping the existing --duo-factor and
--duo-device CLI semantics.

- scope device selection to the chosen factor and dedupe by pkey so a
  device is only offered where it can service the factor; match
  --duo-device by pkey/name/number with a phoneN ordinal fallback,
  defaulting to Duo's first device for the factor when nothing matches
- drive push and phone_call through one table-driven async helper
  (POST -> poll -> finalize) and add passcode via the synchronous
  mobile_otp endpoint
- handle both poll response shapes (push nests result as an object,
  phone_call returns a bare status string)
- return a clear error for the still-unsupported WebAuthn factor
Drive Duo's pwl passkey factor directly: GET /auth/factors/passkey/initialization for the credential request options, obtain an assertion from the FIDO2 authenticator, then POST /auth/factors/passkey and finalize. Reuses the existing fido2 client machinery; the pwl submit uses standard base64 for the assertion response fields (unlike the base64url used by the legacy sid flow).
@pdecat pdecat added enhancement python Pull requests that update Python code labels Jul 14, 2026

@pdecat pdecat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AI-assisted review.

The structure's clean — the whole pwl flow is gated behind _is_pwl_prompt() so classic tenants are untouched, the _initiate_authentication 7→8-tuple change has just the one (updated) caller, and the public extract() contract is preserved on every path. Reusing _retrieve_roles_page/roles_assertion_extractor and the table-driven _PWL_ASYNC_FACTORS for push/phone reads well. Confirmed the dropped ClientError/platform imports are unused here (ClientError is still used in _duo_authenticator.py, so the removal's correctly scoped).

Approving — a few non-blocking follow-ups:

  • rq.get() has no timeout in _pwl_authenticate_webauthn. If every authenticator thread errors before enqueuing (the first failure sets cancel, so the rest swallow their exceptions), the CLI hangs forever with no error. rq.get(timeout=...) + surfacing worker exceptions would turn that into a clean failure.
  • Dead cancel check: the pwl branch in extract() tests signed_response == "cancelled", but nothing in the pwl path returns that sentinel — the "no eligible authenticator" case raises instead. Harmless, just never fires.
  • Push/phone poll caps at ~60s (range(60) + sleep(1)), which can be tight for reaching for your phone — might be worth bumping.
  • INFO logging is chatty: full JSON payloads, device names + phone end_of_number suffixes, txids, and the poll body every second land at INFO. Demoting the payload dumps to DEBUG would keep device/phone/session details out of logs.

Classic flow's unaffected either way, so none of these block it.

Payload dumps, device names and phone number suffixes, txids, poll bodies, and result URLs were logged at INFO. Match the classic flow, which keeps such details at DEBUG, so a normal-verbosity run does not surface device/session details.
@mehalter

mehalter commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @pdecat !

  • The first 2 points I haven't modified to fix, this is a mirror of the same code in the legacy approach, I basically mirrored the implementation as closely as possible to minimize the risk of introducing new logic bugs. I think both of those comments could be addressed in a follow up PR where it is resolved both in this code and the original sid method. The good news is both of those issues shouldn't be a problem especially as evidenced by the fact that they haven't been a problem with the code so far.
  • For the poll cap for push/phone being limited to 60 seconds, this is due mainly to the fact that duo push, verified duo push, and duo phone authentication attempts expire after 60 seconds anyway and is not configurable by tenants. This makes 60 seconds the perfect candidate for the polling time. (https://help.duo.com/s/article/2185?language=en_US, https://help.duo.com/s/article/7325?language=en_US)
  • Good catch with the debug vs info logging. I have pushed a commit that resolves this.

Thanks again for the review and with that and approval I think this is good to merge in!

@pdecat
pdecat merged commit 28ef791 into venth:master Jul 17, 2026
@mehalter
mehalter deleted the feature/add-duo-universal-prompt branch July 17, 2026 13:39
@pdecat

pdecat commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duo started to return 405 without changes to client config

2 participants