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..8b071ee 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,27 @@ 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, display and freshness 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") + // 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"); 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..78533c0 --- /dev/null +++ b/duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java @@ -0,0 +1,206 @@ +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 { + + /** + * The values Duo accepts for {@link Builder#setPrompt(Prompt)}. + */ + 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; + private final String nonce; + private final String destAppName; + private final String destAppId; + private final String displayUsername; + private final Integer maxAge; + private final Prompt prompt; + + 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; + this.maxAge = builder.maxAge; + this.prompt = builder.prompt; + } + + 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; + } + + public Integer getMaxAge() { + return maxAge; + } + + public Prompt getPrompt() { + return prompt; + } + + /** + * 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; + private Integer maxAge; + private Prompt prompt; + + /** + * 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; + } + + /** + * 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. {@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(Prompt prompt) { + this.prompt = prompt; + 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..c410441 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, + * display_username, max_age or prompt */ 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}, {@code display_username}, + * {@code max_age} and {@code prompt} 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..159a0a3 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,37 @@ 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()); + addClaimIfPresent(jwt, "max_age", options.getMaxAge()); + addClaimIfPresent(jwt, "prompt", options.getPrompt()); + return jwt.sign(Algorithm.HMAC512(clientSecret)); + } + + private static void addClaimIfPresent(Builder jwt, String name, String value) { + if (value != null) { + jwt.withClaim(name, 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); + } + } + + private static void addClaimIfPresent(Builder jwt, String name, AuthUrlOptions.Prompt value) { + if (value != null) { + jwt.withClaim(name, value.getValue()); + } } static Token transformDecodedJwtToToken(DecodedJWT decodedJwt) { @@ -209,6 +236,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/ClientTest.java b/duo-universal-sdk/src/test/java/com/duosecurity/ClientTest.java index 418d0ad..5ba14ec 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,98 @@ 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_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()); + + // Duo expects the wire value, not the enum constant name. + 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()); + + 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()); + assertTrue(jwt.getClaim("max_age").isMissing()); + assertTrue(jwt.getClaim("prompt").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..362902e 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}. @@ -81,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..92a7a04 --- /dev/null +++ b/duo-universal-sdk/src/test/java/com/duosecurity/model/ApplicationTest.java @@ -0,0 +1,32 @@ +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; + +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); + } + + @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); + // 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()); + } +}