From a8ff6f304be2f67d171021933175cda61f1a2649 Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Wed, 2 Sep 2026 16:39:00 -0400 Subject: [PATCH 1/4] Add dest_app_name, dest_app_id and display_username These are the last three optional parameters Duo's authorize endpoint accepts that this SDK could not send. All three are claims in the signed request JWT: dest_app_name user-facing name of the application being authenticated to, shown in Duo Mobile and recorded in the auth log dest_app_id long-lived identifier for that application, not shown to users display_username username shown in Duo Mobile's "user" field for Push, in place of the Duo username Duo distinguishes an absent claim from one present with an empty value, so each is omitted from the JWT unless the caller supplies it. Rather than grow createAuthUrl to six positional Strings, this adds AuthUrlOptions with a builder and a createAuthUrl(AuthUrlOptions) overload. Username and state live in the options object rather than staying positional: a three-argument createAuthUrl(username, state, AuthUrlOptions) would have made the existing createAuthUrl(username, state, null) calls ambiguous and stopped them compiling. As written, all existing call shapes are untouched and now delegate to the options path, so the existing tests cover that delegation. Utils.createJwtForAuthUrl takes the options object for the same reason; it is package private, so only its two callers in UtilsTest changed. Co-Authored-By: Claude Opus 5 --- .../controller/LoginController.java | 19 ++- .../java/com/duosecurity/AuthUrlOptions.java | 139 ++++++++++++++++++ .../src/main/java/com/duosecurity/Client.java | 45 +++++- .../src/main/java/com/duosecurity/Utils.java | 26 +++- .../test/java/com/duosecurity/ClientTest.java | 63 ++++++++ .../test/java/com/duosecurity/UtilsTest.java | 6 +- 6 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.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 bffcf76..cf2f450 100644 --- a/duo-example/src/main/java/com/duosecurity/controller/LoginController.java +++ b/duo-example/src/main/java/com/duosecurity/controller/LoginController.java @@ -1,6 +1,7 @@ package com.duosecurity.controller; +import com.duosecurity.AuthUrlOptions; import com.duosecurity.Client; import com.duosecurity.exception.DuoException; import com.duosecurity.model.Token; @@ -120,7 +121,23 @@ public ModelAndView login(@RequestParam String username, @RequestParam String pa stateMap.put(state, new Session(username, nonce)); // Step 4: Create the authUrl and redirect to it - String authUrl = duoClient.createAuthUrl(username, state, nonce); + String authUrl = duoClient.createAuthUrl( + new AuthUrlOptions.Builder(username, state) + .setNonce(nonce) + .build()); + + /* Example of setting the optional destination application and display fields + String authUrl = duoClient.createAuthUrl( + new AuthUrlOptions.Builder(username, state) + .setNonce(nonce) + // Shown in Duo Mobile and recorded in the authentication log + .setDestAppName("Acme VPN") + // A long-lived identifier for that application; not shown to users + .setDestAppId("vpn-prod-1") + // Shown in Duo Mobile's "user" field for Push, in place of the Duo username + .setDisplayUsername("a.smith@acme.com") + .build()); + */ ModelAndView model = new ModelAndView("/redirect"); model.addObject("authURL", authUrl); return model; diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java b/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java new file mode 100644 index 0000000..3736628 --- /dev/null +++ b/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java @@ -0,0 +1,139 @@ +package com.duosecurity; + +/** + * The set of values that describe a single authorization request. + * + *

Instances are created with {@link AuthUrlOptions.Builder} and passed to + * {@link Client#createAuthUrl(AuthUrlOptions)}. + */ +public class AuthUrlOptions { + + private final String username; + private final String state; + private final String nonce; + private final String destAppName; + private final String destAppId; + private final String displayUsername; + + private AuthUrlOptions(Builder builder) { + this.username = builder.username; + this.state = builder.state; + this.nonce = builder.nonce; + this.destAppName = builder.destAppName; + this.destAppId = builder.destAppId; + this.displayUsername = builder.displayUsername; + } + + public String getUsername() { + return username; + } + + public String getState() { + return state; + } + + public String getNonce() { + return nonce; + } + + public String getDestAppName() { + return destAppName; + } + + public String getDestAppId() { + return destAppId; + } + + public String getDisplayUsername() { + return displayUsername; + } + + /** + * Builds an {@link AuthUrlOptions}. + */ + public static class Builder { + private final String username; + private final String state; + private String nonce; + private String destAppName; + private String destAppId; + private String displayUsername; + + /** + * Builder. + * + * @param username The user to be authenticated by Duo. + * @param state A randomly generated String of 16 to 1024 characters. This value will be + * returned to the integration post 2FA and should be validated. + * {@link Client#generateState} exists as a utility function to generate it. + */ + public Builder(String username, String state) { + this.username = username; + this.state = state; + } + + /** + * Optionally bind the resulting ID token to this authorization request with a nonce. + * The same value must be passed to + * {@link Client#exchangeAuthorizationCodeFor2FAResult(String, String, String)}, which will + * reject an ID token that does not carry it. + * + * @param nonce A randomly generated String of 16 to 1024 characters + * + * @return the Builder + */ + public Builder setNonce(String nonce) { + this.nonce = nonce; + return this; + } + + /** + * Optionally set the user-facing name of the application the user is authenticating to. + * Duo shows this name in Duo Mobile and records it in the authentication log. + * + * @param destAppName The name of the destination application + * + * @return the Builder + */ + public Builder setDestAppName(String destAppName) { + this.destAppName = destAppName; + return this; + } + + /** + * Optionally set a long-lived unique identifier for the destination application. + * This value is not shown to users. + * + * @param destAppId The identifier of the destination application + * + * @return the Builder + */ + public Builder setDestAppId(String destAppId) { + this.destAppId = destAppId; + return this; + } + + /** + * Optionally set the username shown in the Duo Mobile "user" field for Duo Push. + * Duo shows the authenticating username when this is not set. This does not change which + * user Duo authenticates. + * + * @param displayUsername The username to display to the user + * + * @return the Builder + */ + public Builder setDisplayUsername(String displayUsername) { + this.displayUsername = displayUsername; + return this; + } + + /** + * Build the options object. + * + * @return {@link AuthUrlOptions} + */ + public AuthUrlOptions build() { + return new AuthUrlOptions(this); + } + } +} 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 4851b8a..76f266d 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java @@ -508,23 +508,54 @@ public String createAuthUrl(String username, String state) throws DuoException { * @return String * * @throws DuoException For problems creating the auth url + * + * @see #createAuthUrl(AuthUrlOptions) to additionally send dest_app_name, dest_app_id or + * display_username */ public String createAuthUrl(String username, String state, String nonce) throws DuoException { - validateUsername(username); - validateState(state); - validateNonce(nonce); + return createAuthUrl(new AuthUrlOptions.Builder(username, state).setNonce(nonce).build()); + } + + + /** + * Constructs a string which can be used to redirect the client browser to Duo for 2FA. + * + *

This is the full form of {@code createAuthUrl}, and the only one that can send the + * optional {@code dest_app_name}, {@code dest_app_id} and {@code display_username} values. + * For example: + * + *

+   * client.createAuthUrl(new AuthUrlOptions.Builder(username, state)
+   *         .setNonce(nonce)
+   *         .setDestAppName("Acme VPN")
+   *         .build());
+   * 
+ * + * @param options The values describing this authorization request, built with + * {@link AuthUrlOptions.Builder}. + * + * @return String + * + * @throws DuoException For problems creating the auth url, or if options is null + */ + public String createAuthUrl(AuthUrlOptions options) throws DuoException { + if (options == null) { + throw new DuoException("Missing options"); + } + validateUsername(options.getUsername()); + validateState(options.getState()); + validateNonce(options.getNonce()); String request = createJwtForAuthUrl(clientId, clientSecret, redirectUri, - state, username, useDuoCodeAttribute, apiHost); + useDuoCodeAttribute, apiHost, options); 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)); + if (options.getNonce() != null) { + query = format("%s&nonce=%s", query, urlEncode(options.getNonce())); } return getAndValidateUrl(apiHost, OAUTH_V_1_AUTHORIZE_ENDPOINT + query).toString(); } - /** * 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 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 1b42599..b45b070 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java @@ -3,6 +3,7 @@ import static java.lang.String.format; import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator.Builder; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.Claim; @@ -46,11 +47,11 @@ static String createJwt(String clientId, String clientSecret, String aud) { } static String createJwtForAuthUrl(String clientId, String clientSecret, String redirectUri, - String state, String username, - Boolean useDuoCodeAttribute, String apiHost) { + Boolean useDuoCodeAttribute, String apiHost, + AuthUrlOptions options) { Date expiration = new Date(); expiration.setTime(expiration.getTime() + FIVE_MINUTES_IN_MILLISECONDS); - return JWT.create() + Builder jwt = JWT.create() .withHeader(HEADERS) .withExpiresAt(expiration) .withIssuer(clientId) @@ -58,11 +59,22 @@ static String createJwtForAuthUrl(String clientId, String clientSecret, String r .withClaim("scope", "openid") .withClaim("client_id", clientId) .withClaim("redirect_uri", redirectUri) - .withClaim("state", state) - .withClaim("duo_uname", username) + .withClaim("state", options.getState()) + .withClaim("duo_uname", options.getUsername()) .withClaim("response_type", "code") - .withClaim("use_duo_code_attribute", useDuoCodeAttribute) - .sign(Algorithm.HMAC512(clientSecret)); + .withClaim("use_duo_code_attribute", useDuoCodeAttribute); + // The remaining claims are optional, and Duo treats an absent claim differently from an + // empty one, so only add them when the caller supplied a value. + addClaimIfPresent(jwt, "dest_app_name", options.getDestAppName()); + addClaimIfPresent(jwt, "dest_app_id", options.getDestAppId()); + addClaimIfPresent(jwt, "display_username", options.getDisplayUsername()); + return jwt.sign(Algorithm.HMAC512(clientSecret)); + } + + private static void addClaimIfPresent(Builder jwt, String name, String value) { + if (value != null) { + jwt.withClaim(name, value); + } } static Token transformDecodedJwtToToken(DecodedJWT decodedJwt) { 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 418d0ad..d9a925d 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java @@ -152,6 +152,69 @@ private static String repeat(String s, int times) { return sb.toString(); } + @Test + void createAuthUrl_with_options_sends_username_and_state() throws DuoException { + String urlString = client.createAuthUrl(new AuthUrlOptions.Builder(USERNAME, STATE).build()); + + DecodedJWT jwt = decodeRequestJwt(urlString); + assertEquals(USERNAME, jwt.getClaim("duo_uname").asString()); + assertEquals(STATE, jwt.getClaim("state").asString()); + } + + @Test + void createAuthUrl_sends_dest_app_name() throws DuoException { + String urlString = client.createAuthUrl( + new AuthUrlOptions.Builder(USERNAME, STATE).setDestAppName("Acme VPN").build()); + + assertEquals("Acme VPN", decodeRequestJwt(urlString).getClaim("dest_app_name").asString()); + } + + @Test + void createAuthUrl_sends_dest_app_id() throws DuoException { + String urlString = client.createAuthUrl( + new AuthUrlOptions.Builder(USERNAME, STATE).setDestAppId("vpn-prod-1").build()); + + assertEquals("vpn-prod-1", decodeRequestJwt(urlString).getClaim("dest_app_id").asString()); + } + + @Test + void createAuthUrl_sends_display_username() throws DuoException { + String urlString = client.createAuthUrl(new AuthUrlOptions.Builder(USERNAME, STATE) + .setDisplayUsername("a.smith@acme.com").build()); + + DecodedJWT jwt = decodeRequestJwt(urlString); + assertEquals("a.smith@acme.com", jwt.getClaim("display_username").asString()); + // display_username only changes what Duo shows the user; the username Duo authenticates + // and later returns as preferred_username must be unaffected. + assertEquals(USERNAME, jwt.getClaim("duo_uname").asString()); + } + + @Test + void createAuthUrl_omits_optional_claims_that_were_not_set() throws DuoException { + String urlString = client.createAuthUrl(new AuthUrlOptions.Builder(USERNAME, STATE).build()); + + DecodedJWT jwt = decodeRequestJwt(urlString); + // Duo treats an absent claim differently from one present with a null value, so an + // unset option must leave the claim out of the JWT entirely. + assertTrue(jwt.getClaim("dest_app_name").isMissing()); + assertTrue(jwt.getClaim("dest_app_id").isMissing()); + assertTrue(jwt.getClaim("display_username").isMissing()); + } + + @Test + void createAuthUrl_throws_exception_for_null_options() { + try { + client.createAuthUrl((AuthUrlOptions) null); + Assertions.fail(); + } catch (DuoException e) { + assertEquals("Missing options", e.getMessage()); + } + } + + private static DecodedJWT decodeRequestJwt(String urlString) { + return JWT.decode(HttpUrl.parse(urlString).queryParameter("request")); + } + @Test void createAuthUrl_throws_exception_for_invalid_username() { try { 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 538d985..59ec88b 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,8 @@ 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, "api-host.com"); + String jwt = Utils.createJwtForAuthUrl("my_client_id", CLIENT_SECRET, "my_redirect_uri", true, "api-host.com", + new AuthUrlOptions.Builder("my_username", "my_state").build()); // 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"); @@ -73,7 +74,8 @@ void createJWTForAuthURL() throws DuoException { @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"); + String jwt = Utils.createJwtForAuthUrl("my_client_id", CLIENT_SECRET, "my_redirect_uri", true, "api-host.com", + new AuthUrlOptions.Builder("my_username", "my_state").build()); 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}. From 44267e76c5d207d361f6675ca38d6950740997e3 Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Thu, 3 Sep 2026 11:00:12 -0400 Subject: [PATCH 2/4] Map auth_context.application.destination_name Duo echoes the dest_app_name sent on the authorize request back in the ID token as auth_context.application.destination_name, but the Application model only had key and name, so Utils.getApplication silently dropped it. Callers had no way to read back the destination application name Duo recorded for the auth. Adds the field, includes it in equals, hashCode and toString, and maps it alongside the existing two. The two-argument Application constructor is left as it was, matching how Token handles amr and nonce. The getter is getDestination_name rather than getDestinationName to match every other multi-word field in this package (getPreferred_username, getAuth_result, getId_token and ten others). These classes are serialization targets, so the getter name determines the JSON property name; camelCase here would emit destinationName and break the snake_case convention the rest of the token follows. dest_app_id and display_username are not documented as being returned in the token, so they need no response-side counterpart. Co-Authored-By: Claude Opus 5 --- .../src/main/java/com/duosecurity/Utils.java | 3 ++ .../com/duosecurity/model/Application.java | 20 ++++++++-- .../test/java/com/duosecurity/UtilsTest.java | 39 ++++++++++++++++++ .../duosecurity/model/ApplicationTest.java | 40 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java 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 b45b070..ac4306c 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java @@ -221,6 +221,9 @@ private static Application getApplication(Map authContextMap) { application.setName(applicationMap.containsKey("name") && applicationMap.get("name") != null ? applicationMap.get("name").toString() : null); + application.setDestination_name(applicationMap.containsKey("destination_name") + && applicationMap.get("destination_name") != null + ? applicationMap.get("destination_name").toString() : null); } return application; } diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/model/Application.java b/duo-universal-sdk/src/main/java/com/duosecurity/model/Application.java index cf692e7..7d2441c 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/model/Application.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/model/Application.java @@ -8,10 +8,12 @@ public class Application implements Serializable { private String key; private String name; + private String destination_name; /** - * Constructor with all properties. - * + * Constructor for the legacy set of properties. Does not set {@code destination_name}; + * use {@link #setDestination_name(String)} for that. + * * @param key key * @param name name */ @@ -43,12 +45,22 @@ public void setName(String name) { this.name = name; } + public String getDestination_name() { + return destination_name; + } + + public void setDestination_name(String destinationName) { + this.destination_name = destinationName; + } + @Override public String toString() { return "Application [key=" + key + ", name=" + name + + ", destination_name=" + destination_name + ", getKey()=" + getKey() + ", getName()=" + getName() + + ", getDestination_name()=" + getDestination_name() + ", hashCode()=" + hashCode() + ", getClass()=" + getClass() + ", toString()=" + super.toString() @@ -65,7 +77,8 @@ public boolean equals(Object obj) { } Application other = (Application) obj; return Objects.equals(key, other.key) - && Objects.equals(name, other.name); + && Objects.equals(name, other.name) + && Objects.equals(destination_name, other.destination_name); } @Override @@ -74,6 +87,7 @@ public int hashCode() { int result = 1; result = prime * result + ((key == null) ? 0 : key.hashCode()); result = prime * result + ((name == null) ? 0 : name.hashCode()); + result = prime * result + ((destination_name == null) ? 0 : destination_name.hashCode()); return result; } } 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 59ec88b..362902e 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java @@ -83,6 +83,45 @@ void createJWTForAuthURL_includes_iss_and_aud() throws DuoException { assertEquals("https://api-host.com", decodedJWT.getClaim("aud").asString()); } + @Test + void transformDecodedJwtToTokenWithApplicationDestinationName() { + // Duo echoes the dest_app_name sent on the authorize request back as + // auth_context.application.destination_name. + Map application = new HashMap<>(); + application.put("key", "DIXXXXXXXXXXXXXXXXXX"); + application.put("name", "Acme Corp"); + application.put("destination_name", "Acme Intranet"); + Map authContext = new HashMap<>(); + authContext.put("application", application); + + String jwt = JWT.create() + .withIssuer("issuer") + .withClaim("auth_context", authContext) + .sign(Algorithm.HMAC512(CLIENT_SECRET)); + Token token = Utils.transformDecodedJwtToToken(JWT.decode(jwt)); + + Application result = token.getAuth_context().getApplication(); + assertEquals("Acme Intranet", result.getDestination_name()); + assertEquals("Acme Corp", result.getName()); + assertEquals("DIXXXXXXXXXXXXXXXXXX", result.getKey()); + } + + @Test + void transformDecodedJwtToTokenWithoutApplicationDestinationName() { + Map application = new HashMap<>(); + application.put("name", "Acme Corp"); + Map authContext = new HashMap<>(); + authContext.put("application", application); + + String jwt = JWT.create() + .withIssuer("issuer") + .withClaim("auth_context", authContext) + .sign(Algorithm.HMAC512(CLIENT_SECRET)); + Token token = Utils.transformDecodedJwtToToken(JWT.decode(jwt)); + + assertNull(token.getAuth_context().getApplication().getDestination_name()); + } + @Test void transformDecodedJwtToToken() { String jwt = createTestJWT(); diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java new file mode 100644 index 0000000..ddade05 --- /dev/null +++ b/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.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 ApplicationTest { + + @Test + void applications_with_different_destination_names_are_not_equal() { + Application application = new Application("key", "name"); + application.setDestination_name("Acme Intranet"); + Application other = new Application("key", "name"); + other.setDestination_name("Acme Payroll"); + + assertNotEquals(application, other); + assertNotEquals(application.hashCode(), other.hashCode()); + } + + @Test + void applications_with_the_same_destination_name_are_equal() { + Application application = new Application("key", "name"); + application.setDestination_name("Acme Intranet"); + Application other = new Application("key", "name"); + other.setDestination_name("Acme Intranet"); + + assertEquals(application, other); + assertEquals(application.hashCode(), other.hashCode()); + } + + @Test + void toString_includes_destination_name() { + Application application = new Application("key", "name"); + application.setDestination_name("Acme Intranet"); + + assertTrue(application.toString().contains("destination_name=Acme Intranet")); + } +} From 0813b4a78b13b648278f153de61ade3a86b41a14 Mon Sep 17 00:00:00 2001 From: Scott Weber Date: Fri, 4 Sep 2026 11:59:08 -0400 Subject: [PATCH 3/4] Add max_age and prompt authorize parameters Duo's authorize endpoint documents two further optional claims for the signed request JWT, neither of which the SDK could send: max_age How many seconds may have passed since the user last authenticated interactively. A remembered session older than this forces interactive reauthentication. prompt "login" forces interactive reauthentication even when a remembered session exists, equivalent to max_age of 0. maxAge is a boxed Integer rather than an int so that unset stays distinguishable from 0, which is a value Duo acts on rather than a default; addClaimIfPresent gains an Integer overload that keys off null alone for the same reason. Both claims are omitted from the JWT entirely when unset, matching the treatment of the other optional claims. prompt is a String with an AuthUrlOptions.PROMPT_LOGIN constant instead of an enum, so that a value Duo starts accepting later works without an SDK release. Neither value is validated locally, consistent with the other optional claims -- Duo is the authority on what it accepts. Three new tests: the claims reach the JWT, a max_age of 0 is sent rather than swallowed, and the omission test now covers both. That last pair of assertions passed on arrival, so they were mutation verified by defaulting the claims to 0 and "login" when unset. Neither parameter appears in the Python SDK yet. Co-Authored-By: Claude Opus 5 --- .../controller/LoginController.java | 6 ++- .../java/com/duosecurity/AuthUrlOptions.java | 48 +++++++++++++++++++ .../src/main/java/com/duosecurity/Client.java | 8 ++-- .../src/main/java/com/duosecurity/Utils.java | 9 ++++ .../test/java/com/duosecurity/ClientTest.java | 28 +++++++++++ 5 files changed, 94 insertions(+), 5 deletions(-) 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 cf2f450..db0ff0e 100644 --- a/duo-example/src/main/java/com/duosecurity/controller/LoginController.java +++ b/duo-example/src/main/java/com/duosecurity/controller/LoginController.java @@ -126,7 +126,7 @@ public ModelAndView login(@RequestParam String username, @RequestParam String pa .setNonce(nonce) .build()); - /* Example of setting the optional destination application and display fields + /* Example of setting the optional destination application, display and freshness fields String authUrl = duoClient.createAuthUrl( new AuthUrlOptions.Builder(username, state) .setNonce(nonce) @@ -136,6 +136,10 @@ public ModelAndView login(@RequestParam String username, @RequestParam String pa .setDestAppId("vpn-prod-1") // Shown in Duo Mobile's "user" field for Push, in place of the Duo username .setDisplayUsername("a.smith@acme.com") + // Reauthenticate interactively if the remembered session is older than this + .setMaxAge(3600) + // Or force interactive reauthentication regardless of remembered session + .setPrompt(AuthUrlOptions.PROMPT_LOGIN) .build()); */ ModelAndView model = new ModelAndView("/redirect"); diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java b/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java index 3736628..6bfafe5 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java @@ -8,12 +8,19 @@ */ public class AuthUrlOptions { + /** + * The only value Duo currently accepts for {@link Builder#setPrompt(String)}. + */ + public static final String PROMPT_LOGIN = "login"; + private final String username; private final String state; private final String nonce; private final String destAppName; private final String destAppId; private final String displayUsername; + private final Integer maxAge; + private final String prompt; private AuthUrlOptions(Builder builder) { this.username = builder.username; @@ -22,6 +29,8 @@ private AuthUrlOptions(Builder builder) { this.destAppName = builder.destAppName; this.destAppId = builder.destAppId; this.displayUsername = builder.displayUsername; + this.maxAge = builder.maxAge; + this.prompt = builder.prompt; } public String getUsername() { @@ -48,6 +57,14 @@ public String getDisplayUsername() { return displayUsername; } + public Integer getMaxAge() { + return maxAge; + } + + public String getPrompt() { + return prompt; + } + /** * Builds an {@link AuthUrlOptions}. */ @@ -58,6 +75,8 @@ public static class Builder { private String destAppName; private String destAppId; private String displayUsername; + private Integer maxAge; + private String prompt; /** * Builder. @@ -127,6 +146,35 @@ public Builder setDisplayUsername(String displayUsername) { return this; } + /** + * Optionally limit how long ago the user's last interactive Duo authentication may have been. + * Duo forces the user to authenticate interactively again when a remembered session is older + * than this, and a value of {@code 0} always forces it. + * + * @param maxAge The number of seconds since the user last authenticated interactively + * + * @return the Builder + */ + public Builder setMaxAge(Integer maxAge) { + this.maxAge = maxAge; + return this; + } + + /** + * Optionally set the OIDC {@code prompt} value. Pass {@link AuthUrlOptions#PROMPT_LOGIN} to + * force the user to authenticate interactively even when a remembered session exists, which + * is equivalent to {@link #setMaxAge(Integer)} with {@code 0}. Duo does not currently accept + * any other value. + * + * @param prompt The prompt value to send + * + * @return the Builder + */ + public Builder setPrompt(String prompt) { + this.prompt = prompt; + return this; + } + /** * Build the options object. * 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 76f266d..c410441 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Client.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Client.java @@ -509,8 +509,8 @@ public String createAuthUrl(String username, String state) throws DuoException { * * @throws DuoException For problems creating the auth url * - * @see #createAuthUrl(AuthUrlOptions) to additionally send dest_app_name, dest_app_id or - * display_username + * @see #createAuthUrl(AuthUrlOptions) to additionally send dest_app_name, dest_app_id, + * display_username, max_age or prompt */ public String createAuthUrl(String username, String state, String nonce) throws DuoException { return createAuthUrl(new AuthUrlOptions.Builder(username, state).setNonce(nonce).build()); @@ -521,8 +521,8 @@ public String createAuthUrl(String username, String state, String nonce) throws * Constructs a string which can be used to redirect the client browser to Duo for 2FA. * *

This is the full form of {@code createAuthUrl}, and the only one that can send the - * optional {@code dest_app_name}, {@code dest_app_id} and {@code display_username} values. - * For example: + * optional {@code dest_app_name}, {@code dest_app_id}, {@code display_username}, + * {@code max_age} and {@code prompt} values. For example: * *

    * client.createAuthUrl(new AuthUrlOptions.Builder(username, state)
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 ac4306c..988bb35 100644
--- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java
+++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java
@@ -68,6 +68,8 @@ static String createJwtForAuthUrl(String clientId, String clientSecret, String r
     addClaimIfPresent(jwt, "dest_app_name", options.getDestAppName());
     addClaimIfPresent(jwt, "dest_app_id", options.getDestAppId());
     addClaimIfPresent(jwt, "display_username", options.getDisplayUsername());
+    addClaimIfPresent(jwt, "max_age", options.getMaxAge());
+    addClaimIfPresent(jwt, "prompt", options.getPrompt());
     return jwt.sign(Algorithm.HMAC512(clientSecret));
   }
 
@@ -77,6 +79,13 @@ private static void addClaimIfPresent(Builder jwt, String name, String value) {
     }
   }
 
+  private static void addClaimIfPresent(Builder jwt, String name, Integer value) {
+    // Only null counts as unset here; zero is a value Duo acts on.
+    if (value != null) {
+      jwt.withClaim(name, value);
+    }
+  }
+
   static Token transformDecodedJwtToToken(DecodedJWT decodedJwt) {
     Token token = new Token();
     token.setIat(decodedJwt.getClaim("iat").asDouble());
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 d9a925d..f8a9af8 100644
--- a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java
+++ b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java
@@ -189,6 +189,32 @@ void createAuthUrl_sends_display_username() throws DuoException {
         assertEquals(USERNAME, jwt.getClaim("duo_uname").asString());
     }
 
+    @Test
+    void createAuthUrl_sends_max_age() throws DuoException {
+        String urlString = client.createAuthUrl(
+                new AuthUrlOptions.Builder(USERNAME, STATE).setMaxAge(300).build());
+
+        assertEquals(300, decodeRequestJwt(urlString).getClaim("max_age").asInt());
+    }
+
+    @Test
+    void createAuthUrl_sends_max_age_of_zero() throws DuoException {
+        String urlString = client.createAuthUrl(
+                new AuthUrlOptions.Builder(USERNAME, STATE).setMaxAge(0).build());
+
+        // Zero is a meaningful value to Duo -- it forces interactive reauthentication -- so it
+        // must be sent rather than treated as "unset".
+        assertEquals(0, decodeRequestJwt(urlString).getClaim("max_age").asInt());
+    }
+
+    @Test
+    void createAuthUrl_sends_prompt() throws DuoException {
+        String urlString = client.createAuthUrl(new AuthUrlOptions.Builder(USERNAME, STATE)
+                .setPrompt(AuthUrlOptions.PROMPT_LOGIN).build());
+
+        assertEquals("login", decodeRequestJwt(urlString).getClaim("prompt").asString());
+    }
+
     @Test
     void createAuthUrl_omits_optional_claims_that_were_not_set() throws DuoException {
         String urlString = client.createAuthUrl(new AuthUrlOptions.Builder(USERNAME, STATE).build());
@@ -199,6 +225,8 @@ void createAuthUrl_omits_optional_claims_that_were_not_set() throws DuoException
         assertTrue(jwt.getClaim("dest_app_name").isMissing());
         assertTrue(jwt.getClaim("dest_app_id").isMissing());
         assertTrue(jwt.getClaim("display_username").isMissing());
+        assertTrue(jwt.getClaim("max_age").isMissing());
+        assertTrue(jwt.getClaim("prompt").isMissing());
     }
 
     @Test

From 037b091482f7537c73a32fe7790de90aa92709e3 Mon Sep 17 00:00:00 2001
From: Scott Weber 
Date: Tue, 8 Sep 2026 14:44:50 -0400
Subject: [PATCH 4/4] Make prompt an enum and trim ApplicationTest

Review feedback from AaronAtDuo on #68.

prompt becomes AuthUrlOptions.Prompt rather than a String plus a
PROMPT_LOGIN constant. The forward compatibility argument for a String
does not hold up: the realistic next value is prompt=none, and
supporting that needs the SDK to handle login_required error redirects
as well, so a caller could not reach it through a string escape hatch
without an SDK change regardless. The enum's constant name and its wire
value differ in case, so the existing test asserting the claim is "login"
was mutation verified against value.name().

ApplicationTest drops the assertNotEquals on hashCode, which asserted
something Object's contract does not guarantee -- unequal objects are
free to share a hash code -- and drops the toString test, which was
brittle and guarded nothing. The two equals tests remain: adding a field
to a hand-written equals is where the field gets forgotten, which is the
failure the destination_name work actually hit. Mutation verified by
removing destination_name from Application.equals.

Co-Authored-By: Claude Opus 5 
---
 .../controller/LoginController.java           |  2 +-
 .../java/com/duosecurity/AuthUrlOptions.java  | 39 ++++++++++++++-----
 .../src/main/java/com/duosecurity/Utils.java  |  6 +++
 .../test/java/com/duosecurity/ClientTest.java |  3 +-
 .../duosecurity/model/ApplicationTest.java    | 12 +-----
 5 files changed, 40 insertions(+), 22 deletions(-)

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 db0ff0e..8b071ee 100644
--- a/duo-example/src/main/java/com/duosecurity/controller/LoginController.java
+++ b/duo-example/src/main/java/com/duosecurity/controller/LoginController.java
@@ -139,7 +139,7 @@ public ModelAndView login(@RequestParam String username, @RequestParam String pa
                     // Reauthenticate interactively if the remembered session is older than this
                     .setMaxAge(3600)
                     // Or force interactive reauthentication regardless of remembered session
-                    .setPrompt(AuthUrlOptions.PROMPT_LOGIN)
+                    .setPrompt(AuthUrlOptions.Prompt.LOGIN)
                     .build());
     */
     ModelAndView model = new ModelAndView("/redirect");
diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java b/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java
index 6bfafe5..78533c0 100644
--- a/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java
+++ b/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java
@@ -9,9 +9,29 @@
 public class AuthUrlOptions {
 
   /**
-   * The only value Duo currently accepts for {@link Builder#setPrompt(String)}.
+   * The values Duo accepts for {@link Builder#setPrompt(Prompt)}.
    */
-  public static final String PROMPT_LOGIN = "login";
+  public enum Prompt {
+    /**
+     * Force the user to authenticate interactively even when a remembered session exists.
+     */
+    LOGIN("login");
+
+    private final String value;
+
+    Prompt(String value) {
+      this.value = value;
+    }
+
+    /**
+     * The value Duo expects on the wire, which is not the name of the constant.
+     *
+     * @return the prompt value sent to Duo
+     */
+    public String getValue() {
+      return value;
+    }
+  }
 
   private final String username;
   private final String state;
@@ -20,7 +40,7 @@ public class AuthUrlOptions {
   private final String destAppId;
   private final String displayUsername;
   private final Integer maxAge;
-  private final String prompt;
+  private final Prompt prompt;
 
   private AuthUrlOptions(Builder builder) {
     this.username = builder.username;
@@ -61,7 +81,7 @@ public Integer getMaxAge() {
     return maxAge;
   }
 
-  public String getPrompt() {
+  public Prompt getPrompt() {
     return prompt;
   }
 
@@ -76,7 +96,7 @@ public static class Builder {
     private String destAppId;
     private String displayUsername;
     private Integer maxAge;
-    private String prompt;
+    private Prompt prompt;
 
     /**
      * Builder.
@@ -161,16 +181,15 @@ public Builder setMaxAge(Integer maxAge) {
     }
 
     /**
-     * Optionally set the OIDC {@code prompt} value. Pass {@link AuthUrlOptions#PROMPT_LOGIN} to
-     * force the user to authenticate interactively even when a remembered session exists, which
-     * is equivalent to {@link #setMaxAge(Integer)} with {@code 0}. Duo does not currently accept
-     * any other value.
+     * Optionally set the OIDC {@code prompt} value. {@link Prompt#LOGIN} forces the user to
+     * authenticate interactively even when a remembered session exists, which is equivalent to
+     * {@link #setMaxAge(Integer)} with {@code 0}. It is the only value Duo currently accepts.
      *
      * @param prompt The prompt value to send
      *
      * @return the Builder
      */
-    public Builder setPrompt(String prompt) {
+    public Builder setPrompt(Prompt prompt) {
       this.prompt = prompt;
       return this;
     }
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 988bb35..159a0a3 100644
--- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java
+++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java
@@ -86,6 +86,12 @@ private static void addClaimIfPresent(Builder jwt, String name, Integer value) {
     }
   }
 
+  private static void addClaimIfPresent(Builder jwt, String name, AuthUrlOptions.Prompt value) {
+    if (value != null) {
+      jwt.withClaim(name, value.getValue());
+    }
+  }
+
   static Token transformDecodedJwtToToken(DecodedJWT decodedJwt) {
     Token token = new Token();
     token.setIat(decodedJwt.getClaim("iat").asDouble());
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 f8a9af8..5ba14ec 100644
--- a/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java
+++ b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java
@@ -210,8 +210,9 @@ void createAuthUrl_sends_max_age_of_zero() throws DuoException {
     @Test
     void createAuthUrl_sends_prompt() throws DuoException {
         String urlString = client.createAuthUrl(new AuthUrlOptions.Builder(USERNAME, STATE)
-                .setPrompt(AuthUrlOptions.PROMPT_LOGIN).build());
+                .setPrompt(AuthUrlOptions.Prompt.LOGIN).build());
 
+        // Duo expects the wire value, not the enum constant name.
         assertEquals("login", decodeRequestJwt(urlString).getClaim("prompt").asString());
     }
 
diff --git a/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java
index ddade05..92a7a04 100644
--- a/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java
+++ b/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java
@@ -4,7 +4,6 @@
 
 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 ApplicationTest {
 
@@ -16,7 +15,6 @@ void applications_with_different_destination_names_are_not_equal() {
         other.setDestination_name("Acme Payroll");
 
         assertNotEquals(application, other);
-        assertNotEquals(application.hashCode(), other.hashCode());
     }
 
     @Test
@@ -27,14 +25,8 @@ void applications_with_the_same_destination_name_are_equal() {
         other.setDestination_name("Acme Intranet");
 
         assertEquals(application, other);
+        // Equal objects are required to agree on hashCode; unequal ones are not required to
+        // disagree, so there is no matching assertion in the test above.
         assertEquals(application.hashCode(), other.hashCode());
     }
-
-    @Test
-    void toString_includes_destination_name() {
-        Application application = new Application("key", "name");
-        application.setDestination_name("Acme Intranet");
-
-        assertTrue(application.toString().contains("destination_name=Acme Intranet"));
-    }
 }