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
Expand Up @@ -35,7 +35,18 @@ public class LoginController {
@Value("${duo.failmode}")
private String failmode;

private Map<String, String> stateMap;
private Map<String, Session> stateMap;

/** The per-login values that have to survive the redirect to Duo and back. */
private static final class Session {
private final String username;
private final String nonce;

Session(String username, String nonce) {
this.username = username;
this.nonce = nonce;
}
}

private Client duoClient;

Expand Down Expand Up @@ -100,13 +111,16 @@ public ModelAndView login(@RequestParam String username, @RequestParam String pa
}
}

// Step 3: Generate and save a state variable
// Step 3: Generate and save a state variable, plus an optional nonce. The nonce binds the
// ID Token that Duo returns to this specific authorization request; generateState produces a
// random value suitable for either.
String state = duoClient.generateState();
// Store the state to remember the session and username
stateMap.put(state, username);
String nonce = duoClient.generateState();
// Store the state to remember the session, username and nonce
stateMap.put(state, new Session(username, nonce));

// Step 4: Create the authUrl and redirect to it
String authUrl = duoClient.createAuthUrl(username, state);
String authUrl = duoClient.createAuthUrl(username, state, nonce);
ModelAndView model = new ModelAndView("/redirect");
model.addObject("authURL", authUrl);
return model;
Expand All @@ -131,10 +145,12 @@ public ModelAndView duoCallback(@RequestParam("duo_code") String duoCode,
return model;
}
// Remove state from the list of valid sessions
String username = stateMap.remove(state);
Session session = stateMap.remove(state);

// Step 6: Exchange the auth duoCode for a Token object
Token token = duoClient.exchangeAuthorizationCodeFor2FAResult(duoCode, username);
// Step 6: Exchange the auth duoCode for a Token object. Passing the nonce sent in step 4
// makes the SDK reject an ID Token that does not carry it.
Token token = duoClient.exchangeAuthorizationCodeFor2FAResult(duoCode, session.username,
session.nonce);

// If the auth was successful, render the welcome page otherwise return an error
if (authWasSuccessful(token)) {
Expand Down
68 changes: 65 additions & 3 deletions duo-universal-sdk/src/main/java/com/duosecurity/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static com.duosecurity.Utils.transformDecodedJwtToToken;
import static com.duosecurity.Utils.validateCaCert;
import static com.duosecurity.Validator.validateClientParams;
import static com.duosecurity.Validator.validateNonce;
import static com.duosecurity.Validator.validateState;
import static com.duosecurity.Validator.validateUsername;
import static java.lang.String.format;
Expand All @@ -16,6 +17,9 @@
import com.duosecurity.model.Token;
import com.duosecurity.model.TokenResponse;
import com.duosecurity.service.DuoConnector;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;


/**
Expand Down Expand Up @@ -485,13 +489,38 @@ public HealthCheckResponse healthCheck() throws DuoException {
* @throws DuoException For problems creating the auth url
*/
public String createAuthUrl(String username, String state) throws DuoException {
return createAuthUrl(username, state, null);
}

/**
* Constructs a string which can be used to redirect the client browser to Duo for 2FA,
* additionally binding the resulting ID token to this authorization request with a nonce.
*
* @param username The user to be authenticated by Duo.
* @param state A randomly generated String with at least 22 characters
* This value will be returned to the integration post 2FA
* and should be validated. {@link #generateState} exists as a utility function to
* generate this param.
* @param nonce A randomly generated String of 16 to 1024 characters, or null for no nonce.
* The same value must be passed to
* {@link #exchangeAuthorizationCodeFor2FAResult(String, String, String)}, which
* will reject an ID token that does not carry it.
* @return String
*
* @throws DuoException For problems creating the auth url
*/
public String createAuthUrl(String username, String state, String nonce) throws DuoException {
validateUsername(username);
validateState(state);
validateNonce(nonce);
String request = createJwtForAuthUrl(clientId, clientSecret, redirectUri,
state, username, useDuoCodeAttribute);
state, username, useDuoCodeAttribute, apiHost);
String query = format(
"?scope=openid&response_type=code&redirect_uri=%s&client_id=%s&request=%s",
redirectUri, clientId, request);
if (nonce != null) {
query = format("%s&nonce=%s", query, urlEncode(nonce));
}
return getAndValidateUrl(apiHost, OAUTH_V_1_AUTHORIZE_ENDPOINT + query).toString();
}

Expand All @@ -514,7 +543,32 @@ public String createAuthUrl(String username, String state) throws DuoException {
*/
public Token exchangeAuthorizationCodeFor2FAResult(String duoCode, String username)
throws DuoException {
TokenValidator validator = new DuoIdTokenValidator(clientSecret, username, clientId, apiHost);
return exchangeAuthorizationCodeFor2FAResult(duoCode, username, null);
}

/**
* Verifies the duoCode returned by Duo and exchanges it for a {@link Token} which contains
* information pertaining to the auth. Uses the default token validator defined in
* DuoIdTokenValidator, which additionally requires the ID Token to carry the given nonce.
*
* @param duoCode This string is an identifier for the auth and should be exchanged with Duo for a
* token to determine if the auth was successful as well as obtain meta-data about
* about the auth.
*
* @param username The user to be authenticated by Duo
*
* @param nonce The same nonce passed to {@link #createAuthUrl(String, String, String)}, or null
* if no nonce was sent. A non-null value that does not match the nonce claim in
* the ID Token will fail validation.
*
* @return {@link Token}
*
* @throws DuoException For errors exchanging duoCode for 2FA results
*/
public Token exchangeAuthorizationCodeFor2FAResult(String duoCode, String username, String nonce)
throws DuoException {
TokenValidator validator = new DuoIdTokenValidator(clientSecret, username, clientId, apiHost,
nonce);
return exchangeAuthorizationCodeFor2FAResult(duoCode, validator);
}

Expand Down Expand Up @@ -548,12 +602,20 @@ public Token exchangeAuthorizationCodeFor2FAResult(String duoCode, TokenValidato
String aud = getAndValidateUrl(apiHost, OAUTH_V_1_TOKEN_ENDPOINT).toString();
TokenResponse response = duoConnector.exchangeAuthorizationCodeFor2FAResult(userAgent,
"authorization_code", duoCode, redirectUri, CLIENT_ASSERTION_TYPE,
createJwt(clientId, clientSecret, aud));
createJwt(clientId, clientSecret, aud), clientId);
String idToken = response.getId_token();
DecodedJWT decodedJwt = validator.validateAndDecode(idToken);
return transformDecodedJwtToToken(decodedJwt);
}

private static String urlEncode(String value) throws DuoException {
try {
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
throw new DuoException(e.getMessage(), e);
}
}

/**
* Generates a 36 character random identifier to be used as the state variable in the
* createAuthUrl method. This value should be stored in a variable and validated against
Expand Down
5 changes: 4 additions & 1 deletion duo-universal-sdk/src/main/java/com/duosecurity/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ static String createJwt(String clientId, String clientSecret, String aud) {

static String createJwtForAuthUrl(String clientId, String clientSecret, String redirectUri,
String state, String username,
Boolean useDuoCodeAttribute) {
Boolean useDuoCodeAttribute, String apiHost) {
Date expiration = new Date();
expiration.setTime(expiration.getTime() + FIVE_MINUTES_IN_MILLISECONDS);
return JWT.create()
.withHeader(HEADERS)
.withExpiresAt(expiration)
.withIssuer(clientId)
.withAudience(format("%s://%s", HTTPS, apiHost))
.withClaim("scope", "openid")
.withClaim("client_id", clientId)
.withClaim("redirect_uri", redirectUri)
Expand Down Expand Up @@ -80,6 +82,7 @@ static Token transformDecodedJwtToToken(DecodedJWT decodedJwt) {
token.setExp(decodedJwt.getClaim("exp").asInt());
token.setSub(decodedJwt.getClaim("sub").asString());
token.setAmr(extractAmr(decodedJwt.getClaim("amr")));
token.setNonce(decodedJwt.getClaim("nonce").asString());
return token;
}

Expand Down
15 changes: 15 additions & 0 deletions duo-universal-sdk/src/main/java/com/duosecurity/Validator.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Validator {
private static final String ALPHA_NUMERIC_REGEX = "^[a-zA-z0-9]*$";
private static final int MINIMUM_STATE_LENGTH = 22;
private static final int MAXMIUM_STATE_LENGTH = 1024;
private static final int MINIMUM_NONCE_LENGTH = 16;
private static final int MAXIMUM_NONCE_LENGTH = 1024;

static void validateClientParams(String clientId, String clientSecret,
String apiHost, String redirectUri) throws DuoException {
Expand All @@ -35,6 +37,19 @@ static void validateState(String state) throws DuoException {
}
}

/**
* Validates the length of a nonce. The nonce is optional, so a null nonce is valid and
* simply means no nonce will be sent.
*/
static void validateNonce(String nonce) throws DuoException {
if (nonce == null) {
return;
}
if (nonce.length() < MINIMUM_NONCE_LENGTH || nonce.length() > MAXIMUM_NONCE_LENGTH) {
throw new DuoException("Invalid nonce");
}
}

static void validateUsername(String username) throws DuoException {
if (username == null || username.isEmpty()) {
throw new DuoException("Missing username");
Expand Down
19 changes: 16 additions & 3 deletions duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ public class Token implements Serializable {
private AuthResult auth_result;
private AuthContext auth_context;
private List<String> amr;
private String nonce;

/**
* Constructor for the legacy set of claims. Does not set {@code amr};
* use {@link #setAmr(java.util.List)} for that.
* Constructor for the legacy set of claims. Does not set {@code amr} or {@code nonce};
* use {@link #setAmr(java.util.List)} and {@link #setNonce(String)} for those.
*
* @param iss iss
* @param sub sub
Expand Down Expand Up @@ -132,6 +133,14 @@ public void setAmr(List<String> amr) {
this.amr = amr;
}

public String getNonce() {
return nonce;
}

public void setNonce(String nonce) {
this.nonce = nonce;
}

@Override
public String toString() {
return "Token [iss=" + iss
Expand All @@ -144,6 +153,7 @@ public String toString() {
+ ", auth_result=" + auth_result
+ ", auth_context=" + auth_context
+ ", amr=" + amr
+ ", nonce=" + nonce
+ ", getAud()=" + getAud()
+ ", getAuth_context()=" + getAuth_context()
+ ", getAuth_result()=" + getAuth_result()
Expand All @@ -154,6 +164,7 @@ public String toString() {
+ ", getPreferred_username()=" + getPreferred_username()
+ ", getSub()=" + getSub()
+ ", getAmr()=" + getAmr()
+ ", getNonce()=" + getNonce()
+ ", hashCode()=" + hashCode()
+ ", getClass()=" + getClass()
+ ", toString()=" + super.toString()
Expand Down Expand Up @@ -181,7 +192,8 @@ public boolean equals(Object obj) {
&& Objects.equals(auth_time, other.auth_time)
&& Objects.equals(auth_result, other.auth_result)
&& Objects.equals(auth_context, other.auth_context)
&& Objects.equals(amr, other.amr);
&& Objects.equals(amr, other.amr)
&& Objects.equals(nonce, other.nonce);
}

@Override
Expand All @@ -198,6 +210,7 @@ public int hashCode() {
result = prime * result + ((auth_result == null) ? 0 : auth_result.hashCode());
result = prime * result + ((auth_context == null) ? 0 : auth_context.hashCode());
result = prime * result + ((amr == null) ? 0 : amr.hashCode());
result = prime * result + ((nonce == null) ? 0 : nonce.hashCode());
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ public HealthCheckResponse duoHealthcheck(String clientId, String clientAssertio
}

/**
* Send request to exchange duoCode for an encoded JWT.
* Send request to exchange duoCode for an encoded JWT, without a client_id form field.
*
* <p>Prefer
* {@link #exchangeAuthorizationCodeFor2FAResult(String, String, String, String, String, String,
* String)}, which sends the client_id that Duo's token endpoint expects. This overload is
* retained for backwards compatibility.
*
* @param userAgent A user agent string
* @param grantType A string that tells what type of exchange that will occur
Expand All @@ -134,9 +139,37 @@ public TokenResponse exchangeAuthorizationCodeFor2FAResult(String userAgent, Str
String clientAssertionType,
String clientAssertion)
throws DuoException {
return exchangeAuthorizationCodeFor2FAResult(userAgent, grantType, duoCode, redirectUri,
clientAssertionType, clientAssertion, null);
}

/**
* Send request to exchange duoCode for an encoded JWT.
*
* @param userAgent A user agent string
* @param grantType A string that tells what type of exchange that will occur
* @param duoCode An authentication session transaction id
* @param redirectUri The URL to redirect back to after a successful auth
* @param clientAssertionType The type of client assertion used
* @param clientAssertion JWT that holds information to verify that the owner of the duoCode
* is authorized to have it
* @param clientId The client id provided by Duo in the admin panel
*
* @return TokenResponse Returns resulting response containing the JWT
*
* @throws DuoException For issues sending or receiving the request,
or failing to exchange a token
*/
public TokenResponse exchangeAuthorizationCodeFor2FAResult(String userAgent, String grantType,
String duoCode, String redirectUri,
String clientAssertionType,
String clientAssertion,
String clientId)
throws DuoException {
DuoService service = retrofit.create(DuoService.class);
Call<TokenResponse> callSync = service.exchangeAuthorizationCodeFor2FAResult(userAgent,
grantType, duoCode, redirectUri, clientAssertionType, clientAssertion);
grantType, duoCode, redirectUri, clientAssertionType, clientAssertion,
clientId);
try {
Response<TokenResponse> response = callSync.execute();
if (response.code() != SUCCESS_STATUS_CODE || response.body() == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Call<TokenResponse> exchangeAuthorizationCodeFor2FAResult(@Header("user-agent")
@Field("code") String duoCode,
@Field("redirect_uri") String redirectUri,
@Field("client_assertion_type") String clientAssertionType,
@Field("client_assertion") String clientAssertion);
@Field("client_assertion") String clientAssertion,
@Field("client_id") String clientId);

}
Loading
Loading