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