From 886bb5bc4123640819465584db27776367c368ad Mon Sep 17 00:00:00 2001 From: Brandon Wong Date: Fri, 10 Jul 2026 13:04:25 -0400 Subject: [PATCH 1/3] Add amr claim to Token model Populate the optional amr (Authentication Methods References) claim from the /token endpoint id token when present, leaving it null when absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/java/com/duosecurity/Utils.java | 1 + .../java/com/duosecurity/model/Token.java | 15 +++++++- .../test/java/com/duosecurity/UtilsTest.java | 38 ++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) 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..41045fe 100644 --- a/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java +++ b/duo-universal-sdk/src/main/java/com/duosecurity/Utils.java @@ -76,6 +76,7 @@ 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(decodedJwt.getClaim("amr").asList(String.class)); return token; } 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..4b8984e 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,6 +16,7 @@ public class Token implements Serializable { private Integer auth_time; private AuthResult auth_result; private AuthContext auth_context; + private List amr; /** * Constructor with all properties. @@ -121,6 +123,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 +142,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() @@ -167,7 +178,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 +195,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..d0ad12f 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,39 @@ 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 From 41bbe4974cc1cc81b8f5435f620fd101a13af627 Mon Sep 17 00:00:00 2001 From: Brandon Wong Date: Fri, 10 Jul 2026 15:23:34 -0400 Subject: [PATCH 2/3] Harden amr claim parsing and expand tests Wrap the amr asList() call in a JWTDecodeException guard so a non-string array element (RFC 8176 violation from upstream) yields null instead of leaking a RuntimeException past the SDK's declared DuoException contract. Also correct the 9-arg Token constructor javadoc (it is no longer "all properties"), mirror amr in toString per the existing pattern, and add tests for the null / non-array / non-string-element wire shapes plus an equals/hashCode contract test for the new field. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/java/com/duosecurity/Utils.java | 14 ++++- .../java/com/duosecurity/model/Token.java | 6 +- .../test/java/com/duosecurity/UtilsTest.java | 61 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) 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 41045fe..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,10 +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(decodedJwt.getClaim("amr").asList(String.class)); + 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 4b8984e..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 @@ -19,8 +19,9 @@ public class Token implements Serializable { 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 @@ -152,6 +153,7 @@ public String toString() { + ", getIss()=" + getIss() + ", getPreferred_username()=" + getPreferred_username() + ", getSub()=" + getSub() + + ", getAmr()=" + getAmr() + ", hashCode()=" + hashCode() + ", getClass()=" + getClass() + ", toString()=" + super.toString() 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 d0ad12f..2f7fb92 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java @@ -116,6 +116,67 @@ void transformDecodedJwtToTokenWithEmptyAmr() { 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 transformDecodedJwtToTokenWithNonStringAmrElements() { + 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)); + + assertNull(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 void getAndValidateUrl() throws DuoException { URL result = Utils.getAndValidateUrl("my_host", "/file"); From 7e0537853a84a95c312f7de8d4bd033cef422832 Mon Sep 17 00:00:00 2001 From: Brandon Wong Date: Fri, 10 Jul 2026 15:45:53 -0400 Subject: [PATCH 3/3] Fix amr numeric-element test to match Jackson coercion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior test asserted that a JSON array of integers would be rejected by asList(String.class), but Jackson silently coerces numeric nodes to their string form when the target type is String, so no JWTDecodeException is thrown. Update the test to reflect the actual library behavior — the try/catch in extractAmr remains as defense-in-depth for element types Jackson cannot coerce. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/test/java/com/duosecurity/UtilsTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 2f7fb92..15451c5 100644 --- a/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java +++ b/duo-universal-sdk/src/test/java/com/duosecurity/UtilsTest.java @@ -147,7 +147,11 @@ void transformDecodedJwtToTokenWithNonArrayAmr() { } @Test - void transformDecodedJwtToTokenWithNonStringAmrElements() { + 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") @@ -158,7 +162,7 @@ void transformDecodedJwtToTokenWithNonStringAmrElements() { Token token = assertDoesNotThrow(() -> Utils.transformDecodedJwtToToken(decodedJWT)); - assertNull(token.getAmr()); + assertEquals(Arrays.asList("1", "2"), token.getAmr()); } @Test