From 84e1df8c01730289b322f65c63689abd85aa6f90 Mon Sep 17 00:00:00 2001 From: dorsha Date: Tue, 21 Apr 2026 10:45:24 +0300 Subject: [PATCH 1/3] feat: add IDPResponse to AuthenticationInfo for SSO exchange Add IDPResponse type with idpGroups, idpSAMLAttributes, and idpOIDCClaims fields. Wire through from JWTResponse to AuthenticationInfo so SDK consumers can access IDP data after SSO token exchange. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../model/auth/AuthenticationInfo.java | 1 + .../com/descope/model/auth/IDPResponse.java | 16 ++++++++ .../model/jwt/response/JWTResponse.java | 2 + .../auth/impl/AuthenticationServiceImpl.java | 2 +- .../sdk/auth/impl/AuthenticationsBase.java | 3 +- .../descope/sdk/mgmt/impl/JwtServiceImpl.java | 3 +- src/test/java/com/descope/sdk/TestUtils.java | 3 +- .../sdk/auth/impl/OAuthServiceImplTest.java | 36 ++++++++++++++++++ .../auth/impl/SamlLinkServiceImplTest.java | 38 +++++++++++++++++++ 9 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/descope/model/auth/IDPResponse.java diff --git a/src/main/java/com/descope/model/auth/AuthenticationInfo.java b/src/main/java/com/descope/model/auth/AuthenticationInfo.java index 5c14886b..9f556a3d 100644 --- a/src/main/java/com/descope/model/auth/AuthenticationInfo.java +++ b/src/main/java/com/descope/model/auth/AuthenticationInfo.java @@ -12,4 +12,5 @@ public class AuthenticationInfo { private Token refreshToken; private UserResponse user; private Boolean firstSeen; + private IDPResponse idpResponse; } diff --git a/src/main/java/com/descope/model/auth/IDPResponse.java b/src/main/java/com/descope/model/auth/IDPResponse.java new file mode 100644 index 00000000..60bf41e8 --- /dev/null +++ b/src/main/java/com/descope/model/auth/IDPResponse.java @@ -0,0 +1,16 @@ +package com.descope.model.auth; + +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IDPResponse { + private List idpGroups; + private Map idpSAMLAttributes; + private Map idpOIDCClaims; +} diff --git a/src/main/java/com/descope/model/jwt/response/JWTResponse.java b/src/main/java/com/descope/model/jwt/response/JWTResponse.java index 00e87966..327e1df4 100644 --- a/src/main/java/com/descope/model/jwt/response/JWTResponse.java +++ b/src/main/java/com/descope/model/jwt/response/JWTResponse.java @@ -1,5 +1,6 @@ package com.descope.model.jwt.response; +import com.descope.model.auth.IDPResponse; import com.descope.model.user.response.UserResponse; import lombok.AllArgsConstructor; import lombok.Data; @@ -17,4 +18,5 @@ public class JWTResponse { private Integer cookieExpiration; private UserResponse user; private Boolean firstSeen; + private IDPResponse idpResponse; } diff --git a/src/main/java/com/descope/sdk/auth/impl/AuthenticationServiceImpl.java b/src/main/java/com/descope/sdk/auth/impl/AuthenticationServiceImpl.java index 97c37930..6774c9f7 100644 --- a/src/main/java/com/descope/sdk/auth/impl/AuthenticationServiceImpl.java +++ b/src/main/java/com/descope/sdk/auth/impl/AuthenticationServiceImpl.java @@ -94,7 +94,7 @@ public AuthenticationInfo validateAndRefreshSessionWithTokensAuthenticationInfo( } else if (StringUtils.isNotBlank(sessionToken)) { try { Token refresh = validateAndCreateToken(refreshToken); - return new AuthenticationInfo(validateSessionWithToken(sessionToken), refresh, null, null); + return new AuthenticationInfo(validateSessionWithToken(sessionToken), refresh, null, null, null); } catch (Exception e) { if (StringUtils.isNotBlank(refreshToken)) { return refreshSessionWithTokenAuthenticationInfo(refreshToken); diff --git a/src/main/java/com/descope/sdk/auth/impl/AuthenticationsBase.java b/src/main/java/com/descope/sdk/auth/impl/AuthenticationsBase.java index ad36605e..3acf3125 100644 --- a/src/main/java/com/descope/sdk/auth/impl/AuthenticationsBase.java +++ b/src/main/java/com/descope/sdk/auth/impl/AuthenticationsBase.java @@ -132,7 +132,8 @@ AuthenticationInfo getAuthenticationInfo(JWTResponse jwtResponse) { refreshToken = validateAndCreateToken(jwtResponse.getRefreshJwt()); } return new AuthenticationInfo( - sessionToken, refreshToken, jwtResponse.getUser(), jwtResponse.getFirstSeen()); + sessionToken, refreshToken, jwtResponse.getUser(), jwtResponse.getFirstSeen(), + jwtResponse.getIdpResponse()); } @SuppressWarnings("unchecked") diff --git a/src/main/java/com/descope/sdk/mgmt/impl/JwtServiceImpl.java b/src/main/java/com/descope/sdk/mgmt/impl/JwtServiceImpl.java index 68456a3c..ca703906 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/JwtServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/JwtServiceImpl.java @@ -138,7 +138,8 @@ private AuthenticationInfo validateAndCreateAuthInfo(JWTResponse jwtResponse) th } Token sessionToken = validateAndCreateToken(jwtResponse.getSessionJwt()); Token refreshToken = validateAndCreateToken(jwtResponse.getRefreshJwt()); - return new AuthenticationInfo(sessionToken, refreshToken, jwtResponse.getUser(), jwtResponse.getFirstSeen()); + return new AuthenticationInfo(sessionToken, refreshToken, jwtResponse.getUser(), jwtResponse.getFirstSeen(), + jwtResponse.getIdpResponse()); } private URI composeUpdateJwtUri() { diff --git a/src/test/java/com/descope/sdk/TestUtils.java b/src/test/java/com/descope/sdk/TestUtils.java index c0fc9dc6..5157f2d2 100644 --- a/src/test/java/com/descope/sdk/TestUtils.java +++ b/src/test/java/com/descope/sdk/TestUtils.java @@ -62,7 +62,8 @@ public class TestUtils { 1234567, 1234567890, MOCK_USER_RESPONSE, - true); + true, + null); public static final Map TENANTS_AUTHZ = mapOf("permissions", Arrays.asList("tp1", "tp2"), "roles", Arrays.asList("tr1", "tr2")); public static final Token MOCK_TOKEN = Token.builder() diff --git a/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java b/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java index c715abf6..01b94b0b 100644 --- a/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java +++ b/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java @@ -16,9 +16,11 @@ import static org.mockito.Mockito.mockStatic; import com.descope.model.auth.AuthenticationInfo; +import com.descope.model.auth.IDPResponse; import com.descope.model.auth.OAuthResponse; import com.descope.model.client.Client; import com.descope.model.jwt.Token; +import com.descope.model.jwt.response.JWTResponse; import com.descope.model.jwt.response.SigningKeysResponse; import com.descope.model.magiclink.LoginOptions; import com.descope.model.user.response.UserResponse; @@ -111,6 +113,40 @@ void testExchangeToken() { Assertions.assertThat(user.getLoginIds()).isNotEmpty(); } + @Test + void testExchangeTokenWithIDPResponse() { + IDPResponse idpResponse = new IDPResponse( + Arrays.asList("users"), + null, + mapOf("email_verified", true, "locale", "en-US")); + JWTResponse jwtResponseWithIdp = new JWTResponse( + "someSessionJwt", "someRefreshJwt", "", "/", 1234567, 1234567890, + MOCK_JWT_RESPONSE.getUser(), true, idpResponse); + + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(jwtResponseWithIdp).when(apiProxy).post(any(), any(), any()); + doReturn(new SigningKeysResponse(Arrays.asList(MOCK_SIGNING_KEY))).when(apiProxy).get(any(), + eq(SigningKeysResponse.class)); + + AuthenticationInfo authenticationInfo; + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + try (MockedStatic mockedJwtUtils = mockStatic(JwtUtils.class)) { + mockedJwtUtils.when(() -> JwtUtils.getToken(anyString(), any())).thenReturn(MOCK_TOKEN); + authenticationInfo = oauthService.exchangeToken("somecode"); + } + } + + Assertions.assertThat(authenticationInfo).isNotNull(); + Assertions.assertThat(authenticationInfo.getIdpResponse()).isNotNull(); + Assertions.assertThat(authenticationInfo.getIdpResponse().getIdpGroups()) + .isEqualTo(Arrays.asList("users")); + Assertions.assertThat(authenticationInfo.getIdpResponse().getIdpSAMLAttributes()).isNull(); + Assertions.assertThat(authenticationInfo.getIdpResponse().getIdpOIDCClaims()) + .containsEntry("email_verified", true) + .containsEntry("locale", "en-US"); + } + void testExampleRequireBrowser() throws Exception { System.out.println(oauthService.start(OAUTH_PROVIDER_GOOGLE, "https://localhost/kuku", null)); String encodedCode = ""; diff --git a/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java b/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java index f489b70d..18e5d586 100644 --- a/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java +++ b/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java @@ -5,6 +5,7 @@ import static com.descope.sdk.TestUtils.MOCK_TOKEN; import static com.descope.sdk.TestUtils.MOCK_URL; import static com.descope.sdk.TestUtils.PROJECT_ID; +import static com.descope.utils.CollectionUtils.mapOf; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -13,9 +14,11 @@ import static org.mockito.Mockito.mockStatic; import com.descope.model.auth.AuthenticationInfo; +import com.descope.model.auth.IDPResponse; import com.descope.model.auth.SAMLResponse; import com.descope.model.client.Client; import com.descope.model.jwt.Token; +import com.descope.model.jwt.response.JWTResponse; import com.descope.model.jwt.response.SigningKeysResponse; import com.descope.model.magiclink.LoginOptions; import com.descope.model.user.response.UserResponse; @@ -88,4 +91,39 @@ void testExchangeToken() { Assertions.assertThat(user.getUserId()).isNotBlank(); Assertions.assertThat(user.getLoginIds()).isNotEmpty(); } + + @Test + void testExchangeTokenWithIDPResponse() { + IDPResponse idpResponse = new IDPResponse( + Arrays.asList("engineering", "devops"), + mapOf("department", "engineering", "title", "Staff Engineer"), + null); + JWTResponse jwtResponseWithIdp = new JWTResponse( + "someSessionJwt", "someRefreshJwt", "", "/", 1234567, 1234567890, + MOCK_JWT_RESPONSE.getUser(), true, idpResponse); + + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(jwtResponseWithIdp).when(apiProxy).post(any(), any(), any()); + doReturn(new SigningKeysResponse(Arrays.asList(MOCK_SIGNING_KEY))) + .when(apiProxy).get(any(), eq(SigningKeysResponse.class)); + + AuthenticationInfo authenticationInfo; + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + try (MockedStatic mockedJwtUtils = mockStatic(JwtUtils.class)) { + mockedJwtUtils.when(() -> JwtUtils.getToken(anyString(), any())).thenReturn(MOCK_TOKEN); + authenticationInfo = samlService.exchangeToken("somecode"); + } + } + + Assertions.assertThat(authenticationInfo).isNotNull(); + Assertions.assertThat(authenticationInfo.getIdpResponse()).isNotNull(); + Assertions.assertThat(authenticationInfo.getIdpResponse().getIdpGroups()) + .isEqualTo(Arrays.asList("engineering", "devops")); + Assertions.assertThat(authenticationInfo.getIdpResponse().getIdpSAMLAttributes()) + .containsEntry("department", "engineering") + .containsEntry("title", "Staff Engineer"); + Assertions.assertThat(authenticationInfo.getIdpResponse().getIdpOIDCClaims()).isNull(); + } } From 9054b86fb85ed8d44d83f0e005df40ba943e90f1 Mon Sep 17 00:00:00 2001 From: dorsha Date: Tue, 21 Apr 2026 10:53:47 +0300 Subject: [PATCH 2/3] feat: add backwards-compatible constructor overloads Add explicit constructors with the previous parameter lists for AuthenticationInfo and JWTResponse to preserve binary/source compatibility for existing SDK consumers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/descope/model/auth/AuthenticationInfo.java | 4 ++++ .../java/com/descope/model/jwt/response/JWTResponse.java | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/com/descope/model/auth/AuthenticationInfo.java b/src/main/java/com/descope/model/auth/AuthenticationInfo.java index 9f556a3d..b4a5666d 100644 --- a/src/main/java/com/descope/model/auth/AuthenticationInfo.java +++ b/src/main/java/com/descope/model/auth/AuthenticationInfo.java @@ -13,4 +13,8 @@ public class AuthenticationInfo { private UserResponse user; private Boolean firstSeen; private IDPResponse idpResponse; + + public AuthenticationInfo(Token token, Token refreshToken, UserResponse user, Boolean firstSeen) { + this(token, refreshToken, user, firstSeen, null); + } } diff --git a/src/main/java/com/descope/model/jwt/response/JWTResponse.java b/src/main/java/com/descope/model/jwt/response/JWTResponse.java index 327e1df4..763fa8c0 100644 --- a/src/main/java/com/descope/model/jwt/response/JWTResponse.java +++ b/src/main/java/com/descope/model/jwt/response/JWTResponse.java @@ -19,4 +19,10 @@ public class JWTResponse { private UserResponse user; private Boolean firstSeen; private IDPResponse idpResponse; + + public JWTResponse(String sessionJwt, String refreshJwt, String cookieDomain, String cookiePath, + Integer cookieMaxAge, Integer cookieExpiration, UserResponse user, Boolean firstSeen) { + this(sessionJwt, refreshJwt, cookieDomain, cookiePath, cookieMaxAge, cookieExpiration, user, + firstSeen, null); + } } From 181fe7801208eeb4004cafcb8ce64a3bd2ff4231 Mon Sep 17 00:00:00 2001 From: dorsha Date: Tue, 21 Apr 2026 10:55:00 +0300 Subject: [PATCH 3/3] test: add backwards-compat constructor tests for exchange token Add testExchangeTokenWithoutIDPResponse tests for both OAuth and SAML that use the old 8-arg JWTResponse constructor and verify idpResponse is null, exercising the backwards-compatible constructor overloads. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/auth/impl/OAuthServiceImplTest.java | 25 ++++++++++++++++++ .../auth/impl/SamlLinkServiceImplTest.java | 26 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java b/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java index 01b94b0b..fc73c05b 100644 --- a/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java +++ b/src/test/java/com/descope/sdk/auth/impl/OAuthServiceImplTest.java @@ -113,6 +113,31 @@ void testExchangeToken() { Assertions.assertThat(user.getLoginIds()).isNotEmpty(); } + @Test + void testExchangeTokenWithoutIDPResponse() { + JWTResponse jwtResponseNoIdp = new JWTResponse( + "someSessionJwt", "someRefreshJwt", "", "/", 1234567, 1234567890, + MOCK_JWT_RESPONSE.getUser(), true); + + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(jwtResponseNoIdp).when(apiProxy).post(any(), any(), any()); + doReturn(new SigningKeysResponse(Arrays.asList(MOCK_SIGNING_KEY))).when(apiProxy).get(any(), + eq(SigningKeysResponse.class)); + + AuthenticationInfo authenticationInfo; + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + try (MockedStatic mockedJwtUtils = mockStatic(JwtUtils.class)) { + mockedJwtUtils.when(() -> JwtUtils.getToken(anyString(), any())).thenReturn(MOCK_TOKEN); + authenticationInfo = oauthService.exchangeToken("somecode"); + } + } + + Assertions.assertThat(authenticationInfo).isNotNull(); + Assertions.assertThat(authenticationInfo.getUser()).isNotNull(); + Assertions.assertThat(authenticationInfo.getIdpResponse()).isNull(); + } + @Test void testExchangeTokenWithIDPResponse() { IDPResponse idpResponse = new IDPResponse( diff --git a/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java b/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java index 18e5d586..1480a2bb 100644 --- a/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java +++ b/src/test/java/com/descope/sdk/auth/impl/SamlLinkServiceImplTest.java @@ -92,6 +92,32 @@ void testExchangeToken() { Assertions.assertThat(user.getLoginIds()).isNotEmpty(); } + @Test + void testExchangeTokenWithoutIDPResponse() { + JWTResponse jwtResponseNoIdp = new JWTResponse( + "someSessionJwt", "someRefreshJwt", "", "/", 1234567, 1234567890, + MOCK_JWT_RESPONSE.getUser(), true); + + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(jwtResponseNoIdp).when(apiProxy).post(any(), any(), any()); + doReturn(new SigningKeysResponse(Arrays.asList(MOCK_SIGNING_KEY))) + .when(apiProxy).get(any(), eq(SigningKeysResponse.class)); + + AuthenticationInfo authenticationInfo; + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + try (MockedStatic mockedJwtUtils = mockStatic(JwtUtils.class)) { + mockedJwtUtils.when(() -> JwtUtils.getToken(anyString(), any())).thenReturn(MOCK_TOKEN); + authenticationInfo = samlService.exchangeToken("somecode"); + } + } + + Assertions.assertThat(authenticationInfo).isNotNull(); + Assertions.assertThat(authenticationInfo.getUser()).isNotNull(); + Assertions.assertThat(authenticationInfo.getIdpResponse()).isNull(); + } + @Test void testExchangeTokenWithIDPResponse() { IDPResponse idpResponse = new IDPResponse(