Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(
Comment thread
AaronAtDuo marked this conversation as resolved.
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;
Expand Down
206 changes: 206 additions & 0 deletions duo-universal-sdk/src/main/java/com/duosecurity/AuthUrlOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package com.duosecurity;

/**
* The set of values that describe a single authorization request.
*
* <p>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);
}
}
}
45 changes: 38 additions & 7 deletions duo-universal-sdk/src/main/java/com/duosecurity/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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:
*
* <pre>
* client.createAuthUrl(new AuthUrlOptions.Builder(username, state)
* .setNonce(nonce)
* .setDestAppName("Acme VPN")
* .build());
* </pre>
*
* @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
Expand Down
44 changes: 37 additions & 7 deletions duo-universal-sdk/src/main/java/com/duosecurity/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,23 +47,49 @@ 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)
.withAudience(format("%s://%s", HTTPS, apiHost))
.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) {
Expand Down Expand Up @@ -209,6 +236,9 @@ private static Application getApplication(Map<String, Object> 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;
}
Expand Down
Loading
Loading