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 ad3b624..7e4d199 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java @@ -4,6 +4,8 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; import com.duosecurity.exception.DuoException; import com.duosecurity.model.AccessDevice; @@ -19,6 +21,7 @@ import java.security.SecureRandom; import java.util.Collections; import java.util.Date; +import java.util.List; import java.util.Map; public class Utils { @@ -76,9 +79,19 @@ static Token transformDecodedJwtToToken(DecodedJWT decodedJwt) { token.setAuth_time(decodedJwt.getClaim("auth_time").asInt()); token.setExp(decodedJwt.getClaim("exp").asInt()); token.setSub(decodedJwt.getClaim("sub").asString()); + token.setAmr(extractAmr(decodedJwt.getClaim("amr"))); return token; } + private static List extractAmr(Claim amrClaim) { + try { + return amrClaim.asList(String.class); + } catch (JWTDecodeException e) { + // Non-string array elements (RFC 8176 violation) — treat as absent. + return null; + } + } + static boolean validateCaCert(String[] userCaCerts) { if (userCaCerts == null || userCaCerts.length == 0) { return false; diff --git a/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java b/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java index 90d0ea6..75a5a7d 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/model/Token.java @@ -1,6 +1,7 @@ package com.duosecurity.model; import java.io.Serializable; +import java.util.List; import java.util.Objects; public class Token implements Serializable { @@ -15,10 +16,12 @@ public class Token implements Serializable { private Integer auth_time; private AuthResult auth_result; private AuthContext auth_context; + private List amr; /** - * Constructor with all properties. - * + * Constructor for the legacy set of claims. Does not set {@code amr}; + * use {@link #setAmr(java.util.List)} for that. + * * @param iss iss * @param sub sub * @param preferredUsername preferred_username @@ -121,6 +124,14 @@ public void setAuth_context(AuthContext authContext) { this.auth_context = authContext; } + public List getAmr() { + return amr; + } + + public void setAmr(List amr) { + this.amr = amr; + } + @Override public String toString() { return "Token [iss=" + iss @@ -132,6 +143,7 @@ public String toString() { + ", auth_time=" + auth_time + ", auth_result=" + auth_result + ", auth_context=" + auth_context + + ", amr=" + amr + ", getAud()=" + getAud() + ", getAuth_context()=" + getAuth_context() + ", getAuth_result()=" + getAuth_result() @@ -141,6 +153,7 @@ public String toString() { + ", getIss()=" + getIss() + ", getPreferred_username()=" + getPreferred_username() + ", getSub()=" + getSub() + + ", getAmr()=" + getAmr() + ", hashCode()=" + hashCode() + ", getClass()=" + getClass() + ", toString()=" + super.toString() @@ -167,7 +180,8 @@ public boolean equals(Object obj) { && Objects.equals(iat, other.iat) && Objects.equals(auth_time, other.auth_time) && Objects.equals(auth_result, other.auth_result) - && Objects.equals(auth_context, other.auth_context); + && Objects.equals(auth_context, other.auth_context) + && Objects.equals(amr, other.amr); } @Override @@ -183,6 +197,7 @@ public int hashCode() { result = prime * result + ((auth_time == null) ? 0 : auth_time.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()); 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 7301cd2..15451c5 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java @@ -8,8 +8,11 @@ import org.junit.jupiter.api.Test; import java.net.URL; +import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -70,7 +73,7 @@ void createJWTForAuthURL() throws DuoException { @Test void transformDecodedJwtToToken() { - String jwt = createTestJWT(); + String jwt = createTestJWT(); // Just testing the transform logic so a simple decode is sufficient DecodedJWT decodedJWT = JWT.decode(jwt); Token token = Utils.transformDecodedJwtToToken(decodedJWT); @@ -78,6 +81,104 @@ void transformDecodedJwtToToken() { assertEquals(token.getIss(), "issuer"); assertEquals(token.getSub(), "test"); assertEquals(token.getAud(), "aud"); + // amr claim is optional; when absent, the field should be null. + assertNull(token.getAmr()); + } + + @Test + void transformDecodedJwtToTokenWithAmr() { + List amr = Arrays.asList("mfa", "otp"); + String jwt = JWT.create() + .withIssuer("issuer") + .withSubject("test") + .withAudience("aud") + .withArrayClaim("amr", amr.toArray(new String[0])) + .sign(Algorithm.HMAC512(CLIENT_SECRET)); + DecodedJWT decodedJWT = JWT.decode(jwt); + + Token token = Utils.transformDecodedJwtToToken(decodedJWT); + + assertEquals(amr, token.getAmr()); + } + + @Test + void transformDecodedJwtToTokenWithEmptyAmr() { + String jwt = JWT.create() + .withIssuer("issuer") + .withSubject("test") + .withAudience("aud") + .withArrayClaim("amr", new String[0]) + .sign(Algorithm.HMAC512(CLIENT_SECRET)); + DecodedJWT decodedJWT = JWT.decode(jwt); + + Token token = Utils.transformDecodedJwtToToken(decodedJWT); + + assertEquals(Collections.emptyList(), token.getAmr()); + } + + @Test + void transformDecodedJwtToTokenWithNullAmr() { + String jwt = JWT.create() + .withIssuer("issuer") + .withSubject("test") + .withAudience("aud") + .withNullClaim("amr") + .sign(Algorithm.HMAC512(CLIENT_SECRET)); + DecodedJWT decodedJWT = JWT.decode(jwt); + + Token token = Utils.transformDecodedJwtToToken(decodedJWT); + + assertNull(token.getAmr()); + } + + @Test + void transformDecodedJwtToTokenWithNonArrayAmr() { + String jwt = JWT.create() + .withIssuer("issuer") + .withSubject("test") + .withAudience("aud") + .withClaim("amr", "mfa") + .sign(Algorithm.HMAC512(CLIENT_SECRET)); + DecodedJWT decodedJWT = JWT.decode(jwt); + + Token token = Utils.transformDecodedJwtToToken(decodedJWT); + + assertNull(token.getAmr()); + } + + @Test + void transformDecodedJwtToTokenWithNumericAmrElements() { + // Jackson coerces numeric elements to their string form when the target + // type is String, so this does not throw and yields ["1", "2"]. + // The try/catch in extractAmr is defense-in-depth for genuinely + // non-coercible element types. + String jwt = JWT.create() + .withIssuer("issuer") + .withSubject("test") + .withAudience("aud") + .withArrayClaim("amr", new Integer[]{1, 2}) + .sign(Algorithm.HMAC512(CLIENT_SECRET)); + DecodedJWT decodedJWT = JWT.decode(jwt); + + Token token = assertDoesNotThrow(() -> Utils.transformDecodedJwtToToken(decodedJWT)); + + assertEquals(Arrays.asList("1", "2"), token.getAmr()); + } + + @Test + void tokenEqualityRespectsAmrField() { + Token a = new Token(); + a.setAmr(Arrays.asList("mfa")); + Token b = new Token(); + b.setAmr(Arrays.asList("mfa")); + Token c = new Token(); + c.setAmr(Arrays.asList("otp")); + Token d = new Token(); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + assertNotEquals(a, d); } @Test