From 9632376614dbf46e538d60bfe639fedcf0a1bf00 Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Tue, 1 Sep 2026 15:59:04 -0400 Subject: [PATCH 1/2] Add nonce support and the missing authorize/token request parameters 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 --- .../controller/LoginController.java | 32 +++-- .../src/main/java/com/duosecurity/Client.java | 68 +++++++++- .../src/main/java/com/duosecurity/Utils.java | 5 +- .../main/java/com/duosecurity/Validator.java | 15 +++ .../java/com/duosecurity/model/Token.java | 19 ++- .../com/duosecurity/service/DuoConnector.java | 7 +- .../com/duosecurity/service/DuoService.java | 3 +- .../test/java/com/duosecurity/ClientTest.java | 126 +++++++++++++++++- .../test/java/com/duosecurity/UtilsTest.java | 12 +- .../java/com/duosecurity/model/TokenTest.java | 40 ++++++ .../duosecurity/service/DuoConnectorTest.java | 16 +-- 11 files changed, 311 insertions(+), 32 deletions(-) create mode 100644 duo-universal-sdk/src/test/java/com/duosecurity/model/TokenTest.java diff --git a/duo-example/src/main/java/com/duosecurity/controller/LoginController.java b/duo-example/src/main/java/com/duosecurity/controller/LoginController.java index 3f04d89..bffcf76 100644 --- a/duo-example/src/main/java/com/duosecurity/controller/LoginController.java +++ b/duo-example/src/main/java/com/duosecurity/controller/LoginController.java @@ -35,7 +35,18 @@ public class LoginController { @Value("${duo.failmode}") private String failmode; - private Map stateMap; + private Map stateMap; + + /** The per-login values that have to survive the redirect to Duo and back. */ + private static final class Session { + private final String username; + private final String nonce; + + Session(String username, String nonce) { + this.username = username; + this.nonce = nonce; + } + } private Client duoClient; @@ -100,13 +111,16 @@ public ModelAndView login(@RequestParam String username, @RequestParam String pa } } - // Step 3: Generate and save a state variable + // Step 3: Generate and save a state variable, plus an optional nonce. The nonce binds the + // ID Token that Duo returns to this specific authorization request; generateState produces a + // random value suitable for either. String state = duoClient.generateState(); - // Store the state to remember the session and username - stateMap.put(state, username); + String nonce = duoClient.generateState(); + // Store the state to remember the session, username and nonce + stateMap.put(state, new Session(username, nonce)); // Step 4: Create the authUrl and redirect to it - String authUrl = duoClient.createAuthUrl(username, state); + String authUrl = duoClient.createAuthUrl(username, state, nonce); ModelAndView model = new ModelAndView("/redirect"); model.addObject("authURL", authUrl); return model; @@ -131,10 +145,12 @@ public ModelAndView duoCallback(@RequestParam("duo_code") String duoCode, return model; } // Remove state from the list of valid sessions - String username = stateMap.remove(state); + Session session = stateMap.remove(state); - // Step 6: Exchange the auth duoCode for a Token object - Token token = duoClient.exchangeAuthorizationCodeFor2FAResult(duoCode, username); + // Step 6: Exchange the auth duoCode for a Token object. Passing the nonce sent in step 4 + // makes the SDK reject an ID Token that does not carry it. + Token token = duoClient.exchangeAuthorizationCodeFor2FAResult(duoCode, session.username, + session.nonce); // If the auth was successful, render the welcome page otherwise return an error if (authWasSuccessful(token)) { diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java index 5adf076..4851b8a 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java @@ -6,6 +6,7 @@ import static com.duosecurity.Utils.transformDecodedJwtToToken; import static com.duosecurity.Utils.validateCaCert; import static com.duosecurity.Validator.validateClientParams; +import static com.duosecurity.Validator.validateNonce; import static com.duosecurity.Validator.validateState; import static com.duosecurity.Validator.validateUsername; import static java.lang.String.format; @@ -16,6 +17,9 @@ import com.duosecurity.model.Token; import com.duosecurity.model.TokenResponse; import com.duosecurity.service.DuoConnector; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; /** @@ -485,13 +489,38 @@ public HealthCheckResponse healthCheck() throws DuoException { * @throws DuoException For problems creating the auth url */ public String createAuthUrl(String username, String state) throws DuoException { + return createAuthUrl(username, state, null); + } + + /** + * Constructs a string which can be used to redirect the client browser to Duo for 2FA, + * additionally binding the resulting ID token to this authorization request with a nonce. + * + * @param username The user to be authenticated by Duo. + * @param state A randomly generated String with at least 22 characters + * This value will be returned to the integration post 2FA + * and should be validated. {@link #generateState} exists as a utility function to + * generate this param. + * @param nonce A randomly generated String of 16 to 1024 characters, or null for no nonce. + * The same value must be passed to + * {@link #exchangeAuthorizationCodeFor2FAResult(String, String, String)}, which + * will reject an ID token that does not carry it. + * @return String + * + * @throws DuoException For problems creating the auth url + */ + public String createAuthUrl(String username, String state, String nonce) throws DuoException { validateUsername(username); validateState(state); + validateNonce(nonce); String request = createJwtForAuthUrl(clientId, clientSecret, redirectUri, - state, username, useDuoCodeAttribute); + state, username, useDuoCodeAttribute, apiHost); String query = format( "?scope=openid&response_type=code&redirect_uri=%s&client_id=%s&request=%s", redirectUri, clientId, request); + if (nonce != null) { + query = format("%s&nonce=%s", query, urlEncode(nonce)); + } return getAndValidateUrl(apiHost, OAUTH_V_1_AUTHORIZE_ENDPOINT + query).toString(); } @@ -514,7 +543,32 @@ public String createAuthUrl(String username, String state) throws DuoException { */ public Token exchangeAuthorizationCodeFor2FAResult(String duoCode, String username) throws DuoException { - TokenValidator validator = new DuoIdTokenValidator(clientSecret, username, clientId, apiHost); + return exchangeAuthorizationCodeFor2FAResult(duoCode, username, null); + } + + /** + * Verifies the duoCode returned by Duo and exchanges it for a {@link Token} which contains + * information pertaining to the auth. Uses the default token validator defined in + * DuoIdTokenValidator, which additionally requires the ID Token to carry the given nonce. + * + * @param duoCode This string is an identifier for the auth and should be exchanged with Duo for a + * token to determine if the auth was successful as well as obtain meta-data about + * about the auth. + * + * @param username The user to be authenticated by Duo + * + * @param nonce The same nonce passed to {@link #createAuthUrl(String, String, String)}, or null + * if no nonce was sent. A non-null value that does not match the nonce claim in + * the ID Token will fail validation. + * + * @return {@link Token} + * + * @throws DuoException For errors exchanging duoCode for 2FA results + */ + public Token exchangeAuthorizationCodeFor2FAResult(String duoCode, String username, String nonce) + throws DuoException { + TokenValidator validator = new DuoIdTokenValidator(clientSecret, username, clientId, apiHost, + nonce); return exchangeAuthorizationCodeFor2FAResult(duoCode, validator); } @@ -548,12 +602,20 @@ public Token exchangeAuthorizationCodeFor2FAResult(String duoCode, TokenValidato String aud = getAndValidateUrl(apiHost, OAUTH_V_1_TOKEN_ENDPOINT).toString(); TokenResponse response = duoConnector.exchangeAuthorizationCodeFor2FAResult(userAgent, "authorization_code", duoCode, redirectUri, CLIENT_ASSERTION_TYPE, - createJwt(clientId, clientSecret, aud)); + createJwt(clientId, clientSecret, aud), clientId); String idToken = response.getId_token(); DecodedJWT decodedJwt = validator.validateAndDecode(idToken); return transformDecodedJwtToToken(decodedJwt); } + private static String urlEncode(String value) throws DuoException { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new DuoException(e.getMessage(), e); + } + } + /** * Generates a 36 character random identifier to be used as the state variable in the * createAuthUrl method. This value should be stored in a variable and validated against diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java index 985c099..1b42599 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java @@ -47,12 +47,14 @@ static String createJwt(String clientId, String clientSecret, String aud) { static String createJwtForAuthUrl(String clientId, String clientSecret, String redirectUri, String state, String username, - Boolean useDuoCodeAttribute) { + Boolean useDuoCodeAttribute, String apiHost) { Date expiration = new Date(); expiration.setTime(expiration.getTime() + FIVE_MINUTES_IN_MILLISECONDS); return JWT.create() .withHeader(HEADERS) .withExpiresAt(expiration) + .withIssuer(clientId) + .withAudience(format("%s://%s", HTTPS, apiHost)) .withClaim("scope", "openid") .withClaim("client_id", clientId) .withClaim("redirect_uri", redirectUri) @@ -80,6 +82,7 @@ static Token transformDecodedJwtToToken(DecodedJWT decodedJwt) { token.setExp(decodedJwt.getClaim("exp").asInt()); token.setSub(decodedJwt.getClaim("sub").asString()); token.setAmr(extractAmr(decodedJwt.getClaim("amr"))); + token.setNonce(decodedJwt.getClaim("nonce").asString()); return token; } diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/Validator.java b/duo-universal-sdk/src/main/java/com/duosecurity/Validator.java index 6fb1ce8..3d184ae 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Validator.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Validator.java @@ -11,6 +11,8 @@ class Validator { private static final String ALPHA_NUMERIC_REGEX = "^[a-zA-z0-9]*$"; private static final int MINIMUM_STATE_LENGTH = 22; private static final int MAXMIUM_STATE_LENGTH = 1024; + private static final int MINIMUM_NONCE_LENGTH = 16; + private static final int MAXIMUM_NONCE_LENGTH = 1024; static void validateClientParams(String clientId, String clientSecret, String apiHost, String redirectUri) throws DuoException { @@ -35,6 +37,19 @@ static void validateState(String state) throws DuoException { } } + /** + * Validates the length of a nonce. The nonce is optional, so a null nonce is valid and + * simply means no nonce will be sent. + */ + static void validateNonce(String nonce) throws DuoException { + if (nonce == null) { + return; + } + if (nonce.length() < MINIMUM_NONCE_LENGTH || nonce.length() > MAXIMUM_NONCE_LENGTH) { + throw new DuoException("Invalid nonce"); + } + } + static void validateUsername(String username) throws DuoException { if (username == null || username.isEmpty()) { throw new DuoException("Missing username"); diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java b/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java index 75a5a7d..814f29e 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java @@ -17,10 +17,11 @@ public class Token implements Serializable { private AuthResult auth_result; private AuthContext auth_context; private List amr; + private String nonce; /** - * Constructor for the legacy set of claims. Does not set {@code amr}; - * use {@link #setAmr(java.util.List)} for that. + * Constructor for the legacy set of claims. Does not set {@code amr} or {@code nonce}; + * use {@link #setAmr(java.util.List)} and {@link #setNonce(String)} for those. * * @param iss iss * @param sub sub @@ -132,6 +133,14 @@ public void setAmr(List amr) { this.amr = amr; } + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + @Override public String toString() { return "Token [iss=" + iss @@ -144,6 +153,7 @@ public String toString() { + ", auth_result=" + auth_result + ", auth_context=" + auth_context + ", amr=" + amr + + ", nonce=" + nonce + ", getAud()=" + getAud() + ", getAuth_context()=" + getAuth_context() + ", getAuth_result()=" + getAuth_result() @@ -154,6 +164,7 @@ public String toString() { + ", getPreferred_username()=" + getPreferred_username() + ", getSub()=" + getSub() + ", getAmr()=" + getAmr() + + ", getNonce()=" + getNonce() + ", hashCode()=" + hashCode() + ", getClass()=" + getClass() + ", toString()=" + super.toString() @@ -181,7 +192,8 @@ public boolean equals(Object obj) { && Objects.equals(auth_time, other.auth_time) && Objects.equals(auth_result, other.auth_result) && Objects.equals(auth_context, other.auth_context) - && Objects.equals(amr, other.amr); + && Objects.equals(amr, other.amr) + && Objects.equals(nonce, other.nonce); } @Override @@ -198,6 +210,7 @@ public int hashCode() { result = prime * result + ((auth_result == null) ? 0 : auth_result.hashCode()); result = prime * result + ((auth_context == null) ? 0 : auth_context.hashCode()); result = prime * result + ((amr == null) ? 0 : amr.hashCode()); + result = prime * result + ((nonce == null) ? 0 : nonce.hashCode()); return result; } } diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java index 353ba7c..f74c972 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java @@ -123,6 +123,7 @@ public HealthCheckResponse duoHealthcheck(String clientId, String clientAssertio * @param clientAssertionType The type of client assertion used * @param clientAssertion JWT that holds information to verify that the owner of the duoCode * is authorized to have it + * @param clientId The client id provided by Duo in the admin panel * * @return TokenResponse Returns resulting response containing the JWT * @@ -132,11 +133,13 @@ public HealthCheckResponse duoHealthcheck(String clientId, String clientAssertio public TokenResponse exchangeAuthorizationCodeFor2FAResult(String userAgent, String grantType, String duoCode, String redirectUri, String clientAssertionType, - String clientAssertion) + String clientAssertion, + String clientId) throws DuoException { DuoService service = retrofit.create(DuoService.class); Call callSync = service.exchangeAuthorizationCodeFor2FAResult(userAgent, - grantType, duoCode, redirectUri, clientAssertionType, clientAssertion); + grantType, duoCode, redirectUri, clientAssertionType, clientAssertion, + clientId); try { Response response = callSync.execute(); if (response.code() != SUCCESS_STATUS_CODE || response.body() == null) { diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoService.java b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoService.java index b5559ed..ecaf0f9 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoService.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoService.java @@ -22,6 +22,7 @@ Call exchangeAuthorizationCodeFor2FAResult(@Header("user-agent") @Field("code") String duoCode, @Field("redirect_uri") String redirectUri, @Field("client_assertion_type") String clientAssertionType, - @Field("client_assertion") String clientAssertion); + @Field("client_assertion") String clientAssertion, + @Field("client_id") String clientId); } diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java index c2fcd23..418d0ad 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java @@ -31,6 +31,7 @@ class ClientTest { private static final String HTTPS_REDIRECT_URI = "https://redirect-uri.com"; private static final String STATE = "abcdefghijklmnopqrstuvwxyz123456"; private static final String USERNAME = "username"; + private static final String NONCE = "abcdefghijklmnopqrstuvwxyz789012"; private Client client; @@ -92,6 +93,65 @@ void createAuthUrl_success() throws DuoException { } } + @Test + void createAuthUrl_includes_nonce_in_query() throws DuoException { + String urlString = client.createAuthUrl(USERNAME, STATE, NONCE); + HttpUrl url = HttpUrl.parse(urlString); + assertEquals(NONCE, url.queryParameter("nonce")); + } + + @Test + void createAuthUrl_omits_nonce_when_not_supplied() throws DuoException { + String urlString = client.createAuthUrl(USERNAME, STATE); + HttpUrl url = HttpUrl.parse(urlString); + assertNull(url.queryParameter("nonce")); + } + + @Test + void createAuthUrl_encodes_nonce() throws DuoException { + // A nonce is caller supplied, so reserved characters in it must not be able to + // introduce additional query parameters. + String urlString = client.createAuthUrl(USERNAME, STATE, "nonce&redirect_uri=evil"); + HttpUrl url = HttpUrl.parse(urlString); + assertEquals("nonce&redirect_uri=evil", url.queryParameter("nonce")); + assertEquals(HTTPS_REDIRECT_URI, url.queryParameter("redirect_uri")); + } + + @Test + void createAuthUrl_throws_exception_for_short_nonce() { + try { + client.createAuthUrl(USERNAME, STATE, "123456789012345"); + Assertions.fail(); + } catch (DuoException e) { + assertEquals("Invalid nonce", e.getMessage()); + } + } + + @Test + void createAuthUrl_throws_exception_for_long_nonce() { + try { + client.createAuthUrl(USERNAME, STATE, repeat("a", 1025)); + Assertions.fail(); + } catch (DuoException e) { + assertEquals("Invalid nonce", e.getMessage()); + } + } + + @Test + void createAuthUrl_accepts_nonce_at_length_boundaries() throws DuoException { + // Duo documents the nonce as 16-1024 characters, inclusive on both ends. + assertNotNull(client.createAuthUrl(USERNAME, STATE, repeat("a", 16))); + assertNotNull(client.createAuthUrl(USERNAME, STATE, repeat("a", 1024))); + } + + private static String repeat(String s, int times) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < times; i++) { + sb.append(s); + } + return sb.toString(); + } + @Test void createAuthUrl_throws_exception_for_invalid_username() { try { @@ -163,7 +223,7 @@ void exchangeAuthorizationCodeFor2FAResult_success() throws DuoException { TokenResponse tokenResponse = new TokenResponse(); tokenResponse.setId_token("eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.qKLZNpctaGsuqLr6KkPiM7_9jG5sEEaLPLakrA1kjk7z0lF3HX_RTRS3c4wVFWMEV_jGg72KIjlBpsWrqMxSNg"); Mockito.when(client.duoConnector.exchangeAuthorizationCodeFor2FAResult( - anyString(), anyString(), anyString(), anyString(), anyString(), anyString())).thenReturn(tokenResponse); + anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString())).thenReturn(tokenResponse); TokenValidator stubValidator = new TokenValidator() { @Override @@ -176,6 +236,62 @@ public DecodedJWT validateAndDecode(String jwt) throws DuoException { assertEquals(result.getSub(), "1234567890"); } + @Test + void exchangeAuthorizationCodeFor2FAResult_sends_client_id() throws DuoException { + try { + client.exchangeAuthorizationCodeFor2FAResult("duo_code", Mockito.mock(TokenValidator.class)); + } catch (Exception e) { + // The call fails due to the incomplete mocking, but we only care about the + // arguments passed to the connector, so this is fine and can be ignored. + } + + ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); + verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), stringCaptor.capture()); + assertEquals(CLIENT_ID, stringCaptor.getValue()); + } + + @Test + void exchangeAuthorizationCodeFor2FAResult_rejects_mismatched_nonce() throws DuoException { + stubIdToken(createIdToken(NONCE)); + + try { + client.exchangeAuthorizationCodeFor2FAResult("duo_code", USERNAME, "a_different_nonce"); + Assertions.fail(); + } catch (DuoException e) { + assertTrue(e.getMessage().contains("ID Token verification failed")); + } + } + + @Test + void exchangeAuthorizationCodeFor2FAResult_accepts_matching_nonce() throws DuoException { + stubIdToken(createIdToken(NONCE)); + + Token result = client.exchangeAuthorizationCodeFor2FAResult("duo_code", USERNAME, NONCE); + + assertEquals(NONCE, result.getNonce()); + } + + private void stubIdToken(String idToken) throws DuoException { + TokenResponse tokenResponse = new TokenResponse(); + tokenResponse.setId_token(idToken); + Mockito.when(client.duoConnector.exchangeAuthorizationCodeFor2FAResult( + anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), + anyString())).thenReturn(tokenResponse); + } + + private String createIdToken(String nonce) { + return JWT.create() + .withIssuer("https://" + API_HOST + "/oauth/v1/token") + .withSubject("duo_subject") + .withAudience(CLIENT_ID) + .withIssuedAt(new java.util.Date()) + .withExpiresAt(new java.util.Date()) + .withClaim("preferred_username", USERNAME) + .withClaim("nonce", nonce) + .sign(com.auth0.jwt.algorithms.Algorithm.HMAC512(CLIENT_SECRET)); + } + @Test void exchangeAuthorizationCodeFor2FAResult_throws_exception_for_invalid_api_host() throws DuoException { try { @@ -231,7 +347,7 @@ void custom_useragent() throws DuoException { } ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); - verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString()); + verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); String sentUserAgent = stringCaptor.getValue(); assertTrue(sentUserAgent.startsWith("duo_universal_java") && sentUserAgent.contains(appendedUserAgent)); } @@ -248,7 +364,7 @@ void userAgent_includes_ca_bundle_version() throws DuoException { } ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); - verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString()); + verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); String sentUserAgent = stringCaptor.getValue(); assertTrue(sentUserAgent.contains("ca_bundle/1.0")); } @@ -265,7 +381,7 @@ void userAgent_includes_ca_pinning_enabled() throws DuoException { } ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); - verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString()); + verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); String sentUserAgent = stringCaptor.getValue(); assertTrue(sentUserAgent.contains("(ca_pinning=enabled)")); } @@ -284,7 +400,7 @@ void userAgent_includes_ca_pinning_disabled() throws DuoException { } ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); - verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString()); + verify(client.duoConnector).exchangeAuthorizationCodeFor2FAResult(stringCaptor.capture(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); String sentUserAgent = stringCaptor.getValue(); assertTrue(sentUserAgent.contains("(ca_pinning=disabled)")); } diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java index 15451c5..538d985 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java @@ -62,7 +62,7 @@ void createJWT() throws DuoException { @Test void createJWTForAuthURL() throws DuoException { - String jwt = Utils.createJwtForAuthUrl("my_client_id", CLIENT_SECRET, "my_redirect_uri", "my_state", "my_username", true); + String jwt = Utils.createJwtForAuthUrl("my_client_id", CLIENT_SECRET, "my_redirect_uri", "my_state", "my_username", true, "api-host.com"); // Just testing the transform logic so a simple decode is sufficient DecodedJWT decodedJWT = JWT.decode(jwt); assertEquals(decodedJWT.getClaim("client_id").asString(), "my_client_id"); @@ -71,6 +71,16 @@ void createJWTForAuthURL() throws DuoException { assertEquals(decodedJWT.getClaim("duo_uname").asString(), "my_username"); } + @Test + void createJWTForAuthURL_includes_iss_and_aud() throws DuoException { + String jwt = Utils.createJwtForAuthUrl("my_client_id", CLIENT_SECRET, "my_redirect_uri", "my_state", "my_username", true, "api-host.com"); + DecodedJWT decodedJWT = JWT.decode(jwt); + // Both are optional per Duo's OIDC docs, but every other Duo SDK sends them: + // iss must equal the client_id and aud must equal https://{api_host}. + assertEquals("my_client_id", decodedJWT.getClaim("iss").asString()); + assertEquals("https://api-host.com", decodedJWT.getClaim("aud").asString()); + } + @Test void transformDecodedJwtToToken() { String jwt = createTestJWT(); diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/model/TokenTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/model/TokenTest.java new file mode 100644 index 0000000..762c78e --- /dev/null +++ b/duo-universal-sdk/src/test/java/com/duosecurity/model/TokenTest.java @@ -0,0 +1,40 @@ +package com.duosecurity.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TokenTest { + + @Test + void tokens_with_different_nonces_are_not_equal() { + Token token = new Token(); + token.setNonce("a_nonce"); + Token other = new Token(); + other.setNonce("a_different_nonce"); + + assertNotEquals(token, other); + assertNotEquals(token.hashCode(), other.hashCode()); + } + + @Test + void tokens_with_the_same_nonce_are_equal() { + Token token = new Token(); + token.setNonce("a_nonce"); + Token other = new Token(); + other.setNonce("a_nonce"); + + assertEquals(token, other); + assertEquals(token.hashCode(), other.hashCode()); + } + + @Test + void toString_includes_nonce() { + Token token = new Token(); + token.setNonce("a_nonce"); + + assertTrue(token.toString().contains("nonce=a_nonce")); + } +} diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java index 3bfabaa..6ab5e4b 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java @@ -113,11 +113,11 @@ void exchangeAuthorizationCodeFor2FAResult() throws IOException, DuoException { tokenResponse.setId_token("token"); when(retrofit.create(DuoService.class)).thenReturn(duoService); when(duoService.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion")).thenReturn(callSync); + "client_assertion_type", "client_assertion", "client_id")).thenReturn(callSync); when(callSync.execute()).thenReturn(Response.success(tokenResponse)); TokenResponse result = duoConnector.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion"); + "client_assertion_type", "client_assertion", "client_id"); assertEquals("token", result.getId_token()); } @@ -132,13 +132,13 @@ void exchangeAuthorizationCodeFor2FAResult_network_failure() throws IOException, tokenResponse.setId_token("token"); when(retrofit.create(DuoService.class)).thenReturn(duoService); when(duoService.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion")).thenReturn(callSync); + "client_assertion_type", "client_assertion", "client_id")).thenReturn(callSync); when(callSync.execute()).thenThrow(new IOException("Timeout")); TokenResponse result = null; try { result = duoConnector.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion"); + "client_assertion_type", "client_assertion", "client_id"); Assertions.fail(); } catch (DuoException e) { assertEquals("Timeout", e.getMessage()); @@ -154,7 +154,7 @@ void exchangeAuthorizationCodeFor2FAResult_error_code() throws IOException, DuoE Call callSync = Mockito.mock(Call.class); when(retrofit.create(DuoService.class)).thenReturn(duoService); when(duoService.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion")).thenReturn(callSync); + "client_assertion_type", "client_assertion", "client_id")).thenReturn(callSync); // Create a 400 response (body doesn't matter) okhttp3.ResponseBody body = okhttp3.ResponseBody.create(null, ""); @@ -162,7 +162,7 @@ void exchangeAuthorizationCodeFor2FAResult_error_code() throws IOException, DuoE try { duoConnector.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion"); + "client_assertion_type", "client_assertion", "client_id"); Assertions.fail(); } catch (DuoException e) { assertEquals("msg=Response.error(), msg_detail=", e.getMessage()); @@ -178,14 +178,14 @@ void exchangeAuthorizationCodeFor2FAResult_null_body() throws IOException, DuoEx Call callSync = Mockito.mock(Call.class); when(retrofit.create(DuoService.class)).thenReturn(duoService); when(duoService.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion")).thenReturn(callSync); + "client_assertion_type", "client_assertion", "client_id")).thenReturn(callSync); // Create a successful (200) response with a null body when(callSync.execute()).thenReturn(Response.success(200, null)); try { duoConnector.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", - "client_assertion_type", "client_assertion"); + "client_assertion_type", "client_assertion", "client_id"); Assertions.fail(); } catch (DuoException e) { // Response.success() is the error message because that's the default message when manually crafting From 0da9f9da05501ca7717e0ca7d937a410da2dce06 Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Wed, 2 Sep 2026 08:55:41 -0400 Subject: [PATCH 2/2] Keep a backwards compatible DuoConnector overload 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 --- .../com/duosecurity/service/DuoConnector.java | 30 +++++++++++++++++++ .../duosecurity/service/DuoConnectorTest.java | 22 ++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java index f74c972..4410f6c 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/service/DuoConnector.java @@ -113,6 +113,36 @@ public HealthCheckResponse duoHealthcheck(String clientId, String clientAssertio } } + /** + * Send request to exchange duoCode for an encoded JWT, without a client_id form field. + * + *

Prefer + * {@link #exchangeAuthorizationCodeFor2FAResult(String, String, String, String, String, String, + * String)}, which sends the client_id that Duo's token endpoint expects. This overload is + * retained for backwards compatibility. + * + * @param userAgent A user agent string + * @param grantType A string that tells what type of exchange that will occur + * @param duoCode An authentication session transaction id + * @param redirectUri The URL to redirect back to after a successful auth + * @param clientAssertionType The type of client assertion used + * @param clientAssertion JWT that holds information to verify that the owner of the duoCode + * is authorized to have it + * + * @return TokenResponse Returns resulting response containing the JWT + * + * @throws DuoException For issues sending or receiving the request, + or failing to exchange a token + */ + public TokenResponse exchangeAuthorizationCodeFor2FAResult(String userAgent, String grantType, + String duoCode, String redirectUri, + String clientAssertionType, + String clientAssertion) + throws DuoException { + return exchangeAuthorizationCodeFor2FAResult(userAgent, grantType, duoCode, redirectUri, + clientAssertionType, clientAssertion, null); + } + /** * Send request to exchange duoCode for an encoded JWT. * diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java index 6ab5e4b..59f52df 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/service/DuoConnectorTest.java @@ -121,6 +121,28 @@ void exchangeAuthorizationCodeFor2FAResult() throws IOException, DuoException { assertEquals("token", result.getId_token()); } + @Test + void exchangeAuthorizationCodeFor2FAResult_without_client_id_omits_the_field() throws IOException, DuoException { + DuoConnector duoConnector = new DuoConnector(API_HOST, CA_CERT); + Retrofit retrofit = Mockito.mock(Retrofit.class); + duoConnector.retrofit = retrofit; + DuoService duoService = Mockito.mock(DuoService.class); + when(retrofit.create(DuoService.class)).thenReturn(duoService); + + try { + duoConnector.exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", "redirect_uri", + "client_assertion_type", "client_assertion"); + } catch (Exception e) { + // The unstubbed service call returns a null Call, so executing it fails. We only care + // about the arguments the connector forwarded, so this can be ignored. + } + + // Retrofit drops a null @Field from the form body, so this overload sends exactly what it + // sent before client_id was added. + Mockito.verify(duoService).exchangeAuthorizationCodeFor2FAResult("user-agent", "grant_type", "duo_code", + "redirect_uri", "client_assertion_type", "client_assertion", null); + } + @Test void exchangeAuthorizationCodeFor2FAResult_network_failure() throws IOException, DuoException { DuoConnector duoConnector = new DuoConnector(API_HOST, CA_CERT);