Skip to content

Add nonce support and the missing authorize/token request parameters - #67

Merged
AaronAtDuo merged 2 commits into
duosecurity:mainfrom
scweber-cisco:nonce-support-and-oidc-claims
Sep 2, 2026
Merged

Add nonce support and the missing authorize/token request parameters#67
AaronAtDuo merged 2 commits into
duosecurity:mainfrom
scweber-cisco:nonce-support-and-oidc-claims

Conversation

@scweber-cisco

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

Copy link
Copy Markdown
Contributor

What

Closes the gap between what Duo's OIDC endpoints accept and what this SDK sends, for three parameters.

Nonce

The SDK could already validate a nonce — DuoIdTokenValidator has a nonce constructor and enforces the claim — but Client had no way to send one, so the feature was unreachable through the public API. This adds the send side:

  • createAuthUrl(username, state, nonce) puts the nonce in the authorize query string. That matches duo_universal_python and Duo's documented precedence rule that a query value wins over the same value in the signed request JWT. The nonce is URL encoded, so a caller-supplied value containing & cannot introduce additional query parameters.
  • exchangeAuthorizationCodeFor2FAResult(duoCode, username, nonce) builds the existing 5-arg DuoIdTokenValidator, which fails validation if the ID token's nonce claim doesn't match.
  • Validator.validateNonce enforces Duo's documented 16–1024 characters, inclusive on both ends.
  • Token exposes the nonce claim, alongside amr.

The existing 2-arg overloads delegate with a null nonce, so current callers send no nonce and behave exactly as before.

Note on what this buys us: preferred_username is already checked unconditionally, so the nonce is not protecting against cross-user token substitution here. What it adds is binding the ID token to this authorization request, which is defense against same-user replay of a previously issued token.

iss / aud on the authorize request

The signed request JWT now carries iss (the client id) and aud (https://{api_host}). Both are optional per Duo's docs, but every other Duo Universal SDK sends them and this was the only one that didn't.

client_id on the token POST

/oauth/v1/token now receives client_id as a form field, again matching the other SDKs.

API surface

New public methods, no removals:

Client.createAuthUrl(String, String, String) new overload
Client.exchangeAuthorizationCodeFor2FAResult(String, String, String) new overload
Token.getNonce() / setNonce(String) new
DuoConnector.exchangeAuthorizationCodeFor2FAResult(..., String clientId) new overload

DuoConnector.exchangeAuthorizationCodeFor2FAResult needed the clientId, and it's public, so the six-argument signature is retained as an overload that delegates with a null client id. Retrofit drops null @Fields from the form body, so anyone calling the connector directly keeps both source compatibility and their exact previous request.

Testing

Built with TDD; every new behavior has a test that was watched failing first.

  • mvn install — 66 tests, 0 failures, checkstyle clean on both modules.
  • New tests cover: nonce in the query string, nonce absent when not supplied, nonce URL encoding (the pre-fix version let nonce&redirect_uri=evil inject a second redirect_uri), the 16/1024 length boundaries and both out-of-range cases, iss/aud claims, client_id on the token POST, ID token rejection on nonce mismatch and acceptance on match, and Token nonce equality/toString.

The duo-example app was updated to generate a nonce, store it next to the state, and pass it to both calls — so the demo exercises the new path end to end. I also ran the duo-example app to confirm that it successfully completes with a nonce, and that if the nonce doesn't match, an error is received.

🤖 Generated with Claude Code

The Java SDK could validate a nonce on the returned ID token but had no way
to send one, so the feature was unreachable through Client. It was also the
only Duo Universal SDK omitting iss/aud from the signed authorize request and
client_id from the token POST.

Nonce:
* createAuthUrl(username, state, nonce) sends the nonce in the authorize
  query string, matching duo_universal_python and Duo's documented precedence
  that the query value wins over a JWT claim. The nonce is URL encoded so a
  caller supplied value cannot introduce extra query parameters.
* exchangeAuthorizationCodeFor2FAResult(duoCode, username, nonce) builds the
  existing DuoIdTokenValidator nonce constructor, which rejects an ID token
  whose nonce claim does not match.
* Validator.validateNonce enforces Duo's documented 16 to 1024 characters,
  inclusive on both ends.
* Token exposes the nonce claim alongside amr.

The two argument overloads delegate with a null nonce, so existing callers
send no nonce and behave exactly as before.

Request parameters:
* The authorize request JWT now carries iss (the client id) and aud
  (https://{api_host}).
* The token POST now sends client_id as a form field.

The example app generates a nonce, stores it next to the state, and passes it
to both calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scweber-cisco
scweber-cisco marked this pull request as ready for review September 1, 2026 20:41
DuoConnector is public, so adding client_id to
exchangeAuthorizationCodeFor2FAResult was source breaking for anyone calling
the connector directly instead of going through Client. Restore the six
argument signature as an overload that delegates with a null client_id;
Retrofit drops null form fields, so it sends exactly what it sent before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AaronAtDuo
AaronAtDuo merged commit 0f1d44b into duosecurity:main Sep 2, 2026
6 checks passed
@scweber-cisco
scweber-cisco deleted the nonce-support-and-oidc-claims branch September 2, 2026 18:00
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.

3 participants