Skip to content

Add nonce support to the OIDC authentication flow - #67

Merged
AaronAtDuo merged 6 commits into
duosecurity:mainfrom
scweber-cisco:nonce-support
Sep 8, 2026
Merged

AaronAtDuo merged 6 commits into
duosecurity:mainfrom
scweber-cisco:nonce-support

Conversation

@scweber-cisco

@scweber-cisco scweber-cisco commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for the OIDC nonce, which the Duo OIDC Auth API has accepted for some time but this
client had no way to send. Client.cs and Utils.cs both carried // TODO comments for it, and
IdToken.Nonce existed but was never populated, so a caller could not use the nonce as a replay
defense.

The nonce is sent as a claim in the authentication request JWT, echoed back by Duo as an id_token
claim, and compared by the client. Bounds are the 16–1024 characters the
API docs specify.

API

Every new entry point is an overload, so existing callers are untouched and the nonce stays opt-in:

string nonce = Client.GenerateNonce();                                  // also GenerateNonce(length)
string authUri = client.GenerateAuthUri(username, state, nonce);
IdToken token = await client.ExchangeAuthorizationCodeFor2faResult(code, username, nonce);

ExchangeAuthorizationCodeForSamlResponse gained the same 3-argument overload. The 2-argument
overloads omit the claim entirely rather than sending an empty one, and skip the check on the way
back; the 3-argument overloads validate the nonce up front and reject a mismatch with
DuoException.

All three nonce-aware entry points use the same shape: thin public overloads over a private core
taking a requireNonce flag. The 2-argument form cannot simply delegate to the 3-argument form,
because that one validates the nonce and would reject the null every existing caller effectively
passes.

Duo omits the claim when no nonce was sent, so an absent claim deserializes to null rather than
"", and comparison is StringComparison.Ordinal because a nonce is opaque.

Note that the public overloads are deliberately not async — they only hand off to the private
async core, which is what keeps an invalid nonce arriving as a faulted Task rather than as a throw
at the call site. This is not a signature change: the emitted IL is still
Task<IdToken>/Task<string>, so callers await exactly as before.

SAML failures now report their cause

ExchangeAuthorizationCodeForSamlResponse wrapped everything from token validation in
"Error while retrieveing saml response", so a username mismatch — and now a nonce mismatch —
reached the caller as a generic message with the real reason demoted to an inner exception. The Id
Token flow has always reported those directly; the SAML flow now matches it.

The catch is removed rather than narrowed to catch (DuoException) { throw; }, because it could
not fire for anything else: ValidateIdTokenFromResponse only ever throws DuoException, having
already wrapped parse failures itself, and SamlResponse is a plain auto-property.

This changes the message text on an existing failure path, so it is worth a look even though no
signature moves.

Test coverage

160 tests pass. New coverage: nonce generation and its bounds, the claim's presence and absence in
the request JWT, decoding it out of the id_token, and the exchange path for match, mismatch,
empty, null, a claim missing from the response, and an unexpected claim arriving when none was
requested — for both the token and SAML flows. The documented 16/1024 bounds are additionally
asserted as literals, so changing the client's constants cannot silently drift from the API
contract.

Two of those deserve calling out:

  • The nonce-mismatch tests hold the returned Task without awaiting it. Awaiting on the line that
    makes the call collapses the two moments a Task-returning method can fail, and so cannot tell a
    synchronous throw from a faulted Task. Hoisting ValidateNonce out of the private core to fail
    fast would break callers who start the exchange and await it later, or who pass it to
    Task.WhenAll; these tests fail if that happens.
  • A mismatch test has to use a wrong nonce that is still at least 16 characters, or validation
    rejects it on length and the comparison against the value Duo echoed back never runs.

Two unrelated fixes carried along

Neither has anything to do with the nonce; one is in the test suite, the other in the example app.
Each is a separate commit:

  • Cert pinning tests. All 12 TestCertPinning tests failed on macOS. The embedded certificates
    have since expired and their OCSP responder now answers unauthorized for those serials, which
    surfaces as RevocationStatusUnknown; revocation is always checked against now, not against
    ChainPolicy.VerificationTime. Because CertificatePinnerFactory rejects any chain with a
    non-NoError status before comparing SPKI hashes, the hash comparison these tests exist to
    verify was never actually running. Setting RevocationMode = NoCheck restores that coverage
    (these tests cover pinning, not revocation) and drops suite time from ~1–2s to 116ms.

  • Callback page readability. The <pre> was tagged class="language.json"; the dot makes that
    two classes, so the stylesheet's pre.language-json rule never matched. Fixing that alone was not
    enough: .content sets align-items: center, which sized the block to its widest line, and once
    that exceeded the window the start of every line landed at a negative offset where a browser will
    not scroll. At a 600px viewport the left edge sat at -38px, cutting off the opening brace and each
    key's leading quote. Verified before and after at narrow and wide viewports.

Not included

No version bump and no root README changes — those seemed like release decisions rather than part of
this change.

🤖 Generated with Claude Code

scweber-cisco and others added 6 commits September 3, 2026 15:08
The Duo OIDC Auth API accepts an optional nonce on the authorize request
and echoes it back as a claim in the Id Token, but the client had no way
to send one. IdToken.Nonce existed as dead code alongside two TODOs.

Adds overloads that take a nonce:
  GenerateAuthUri(username, state, nonce)
  ExchangeAuthorizationCodeFor2faResult(duoCode, username, nonce)
  ExchangeAuthorizationCodeForSamlResponse(duoCode, username, nonce)

plus GenerateNonce()/GenerateNonce(length), mirroring GenerateState. The
exchange overloads compare the returned nonce to the one supplied and
raise a DuoException on a mismatch or if Duo omitted the claim, which is
the replay protection the nonce exists to provide.

Nonce length bounds are 16 to 1024, per the API documentation. Tests
assert those boundaries as literals rather than referencing the client's
constants, so the values cannot silently drift from the documented
contract.

The existing overloads are unchanged on the wire: they send no nonce
claim and check none, so current callers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two chain helpers built an X509Chain with the default
RevocationMode.Online and asserted chain.Build() succeeded. That made
12 tests depend on network access and on how long a CA keeps answering
OCSP for a given serial. The pinned certificates have since expired and
Amazon's responder now returns "unauthorized" for the leaf, which
surfaces as RevocationStatusUnknown and fails the build:

  VALID=False  RevocationMode=Online
  CHAINSTATUS RevocationStatusUnknown

The failure is platform-dependent, which is why CI stayed green: on
Linux and Windows the chain builds, while on macOS the Security
framework reports the responder's refusal.

Revocation state is irrelevant to what these tests cover. They exercise
SPKI hash pinning, and they already freeze VerificationTime for
determinism; revocation is the remaining nondeterministic input.

This restores coverage rather than hiding a failure. The pinner rejects
any chain carrying a non-NoError status before it compares hashes, so
with revocation on it returned false without ever reaching the SPKI
comparison the tests exist to verify.

155 tests now pass, and the suite no longer makes network calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The callback page's <pre> was tagged class="language.json".  The dot makes that two
classes, "language" and "json", so the stylesheet's pre.language-json rule never
matched and none of its styling applied.

Fixing the class alone is not enough.  The .content column sets align-items: center,
which sized the output block to its widest line; once that exceeded the window the
block was centered on it and the start of every line landed at a negative offset,
where a browser will not scroll.  At a 600px viewport the left edge sat at -38px, so
the opening brace and the leading quote of each key were unreachable.  Giving
div.success a definite width keeps the block inside the column, and overflow-x on the
<pre> lets long lines scroll within the block instead of escaping it.

Also drop the font size from the never-applied 20px to 15px; at 20px the lines are
wide enough to need scrolling almost immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ExchangeAuthorizationCodeForSamlResponse wrapped everything from token validation in
"Error while retrieveing saml response", so a username or nonce mismatch reached the
caller as a generic message with the real reason demoted to an inner exception.  The
Id Token flow reports those directly.  Since ValidateIdTokenFromResponse only ever
throws DuoException, and SamlResponse is a plain property, that catch could not fire
for anything else, so it is removed rather than narrowed.

ExchangeAuthorizationCodeFor2faResult now uses the same public-overloads-over-a-
private-core shape as GenerateAuthUri and the SAML method, instead of two independent
public bodies.  No behaviour change: the two-argument form passes a null nonce with
requireNonce false, which skips validation and, as before, skips the comparison.

Also fixes tests that were not testing what they claimed.  TestNonceMismatch and
TestSamlResponseNonceMismatch passed "not the nonce", which is 13 characters and so was
rejected by ValidateNonce on length before any comparison against the value Duo echoed
back.  They now use a nonce long enough to pass validation and actually reach that
comparison, and assert on the message so the two failure modes cannot be confused
again.  The old length-rejection cases are kept as TestUnusableNonceIsRejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The public exchange overloads are not async; they hand off to a private async core, and
the nonce is validated inside that core, so a bad nonce reaches the caller as a faulted
Task.  Hoisting ValidateNonce up into a public overload to fail fast would throw at the
call site instead, before the caller holds a Task at all, breaking anyone who starts the
exchange and awaits it later or passes it to Task.WhenAll.

Nothing caught that.  Every other test here awaits the call on the line that makes it,
which collapses the two moments a Task-returning method can fail into one and cannot
tell a synchronous throw from a faulted Task.  These tests keep them apart by holding
the Task without awaiting it.  Both fail on a hoisted ValidateNonce, which is how they
were checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every existing header reads 2022 because a single 2022 commit stamped that year across
files that had been added in 2021, not because the year tracks authorship.  This file is
the first added since, so it states 2026 rather than copying a year in which it did not
exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scweber-cisco
scweber-cisco marked this pull request as ready for review September 3, 2026 21:31
@AaronAtDuo
AaronAtDuo merged commit 0131f1f into duosecurity:main Sep 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants