Add nonce support to the OIDC authentication flow - #67
Merged
Merged
Conversation
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
marked this pull request as ready for review
September 3, 2026 21:31
AaronAtDuo
approved these changes
Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds support for the OIDC
nonce, which the Duo OIDC Auth API has accepted for some time but thisclient had no way to send.
Client.csandUtils.csboth carried// TODOcomments for it, andIdToken.Nonceexisted but was never populated, so a caller could not use the nonce as a replaydefense.
The nonce is sent as a claim in the authentication request JWT, echoed back by Duo as an
id_tokenclaim, 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:
ExchangeAuthorizationCodeForSamlResponsegained the same 3-argument overload. The 2-argumentoverloads 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
requireNonceflag. The 2-argument form cannot simply delegate to the 3-argument form,because that one validates the nonce and would reject the
nullevery existing caller effectivelypasses.
Duo omits the claim when no nonce was sent, so an absent claim deserializes to
nullrather than"", and comparison isStringComparison.Ordinalbecause a nonce is opaque.Note that the public overloads are deliberately not
async— they only hand off to the privateasync core, which is what keeps an invalid nonce arriving as a faulted
Taskrather than as a throwat the call site. This is not a signature change: the emitted IL is still
Task<IdToken>/Task<string>, so callersawaitexactly as before.SAML failures now report their cause
ExchangeAuthorizationCodeForSamlResponsewrapped 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
catchis removed rather than narrowed tocatch (DuoException) { throw; }, because it couldnot fire for anything else:
ValidateIdTokenFromResponseonly ever throwsDuoException, havingalready wrapped parse failures itself, and
SamlResponseis 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:
Taskwithout awaiting it. Awaiting on the line thatmakes the call collapses the two moments a
Task-returning method can fail, and so cannot tell asynchronous throw from a faulted
Task. HoistingValidateNonceout of the private core to failfast would break callers who start the exchange and await it later, or who pass it to
Task.WhenAll; these tests fail if that happens.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
TestCertPinningtests failed on macOS. The embedded certificateshave since expired and their OCSP responder now answers
unauthorizedfor those serials, whichsurfaces as
RevocationStatusUnknown; revocation is always checked against now, not againstChainPolicy.VerificationTime. BecauseCertificatePinnerFactoryrejects any chain with anon-
NoErrorstatus before comparing SPKI hashes, the hash comparison these tests exist toverify was never actually running. Setting
RevocationMode = NoCheckrestores that coverage(these tests cover pinning, not revocation) and drops suite time from ~1–2s to 116ms.
Callback page readability. The
<pre>was taggedclass="language.json"; the dot makes thattwo classes, so the stylesheet's
pre.language-jsonrule never matched. Fixing that alone was notenough:
.contentsetsalign-items: center, which sized the block to its widest line, and oncethat 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