diff --git a/README.md b/README.md index e597f720..4e9bd12f 100644 --- a/README.md +++ b/README.md @@ -1247,6 +1247,25 @@ mgmtSignUpUser.setCustomClaims(new HashMap() {{ AuthenticationInfo res = jwtService.signUpOrIn("Dummy", mgmtSignUpUser); ``` +Generate a client assertion JWT for OAuth 2.0 client credentials flow. + +```java +JwtService jwtService = descopeClient.getManagementServices().getJwtService(); +try { + ClientAssertionResponse response = jwtService.generateClientAssertionJwt( + "client-id", + "client-id", + Arrays.asList("https://auth.example.com/token"), + 3600, + false, + "RS256" + ); + String jwt = response.getJwt(); +} catch (DescopeException de) { + // Handle the error +} +``` + ### Audit You can perform an audit search for either specific values or full-text across the fields. Audit search is limited to the last 30 days. diff --git a/pom.xml b/pom.xml index aa96a305..3d463ade 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.descope java-sdk 4.0.0 - 1.0.60 + 1.0.61 ${project.groupId}:${project.artifactId} Java library used to integrate with Descope. https://github.com/descope/descope-java diff --git a/src/main/java/com/descope/literals/Routes.java b/src/main/java/com/descope/literals/Routes.java index af821be4..10784061 100644 --- a/src/main/java/com/descope/literals/Routes.java +++ b/src/main/java/com/descope/literals/Routes.java @@ -166,6 +166,7 @@ public static class ManagementEndPoints { // JWT public static final String UPDATE_JWT_LINK = "/v1/mgmt/jwt/update"; + public static final String CLIENT_ASSERTION = "/v1/mgmt/token/clientassertion"; public static final String MANAGEMENT_SIGN_IN = "/v1/mgmt/auth/signin"; public static final String MANAGEMENT_SIGN_UP = "/v1/mgmt/auth/signup"; public static final String MANAGEMENT_SIGN_UP_OR_IN = "/v1/mgmt/auth/signup-in"; diff --git a/src/main/java/com/descope/model/jwt/request/ClientAssertionRequest.java b/src/main/java/com/descope/model/jwt/request/ClientAssertionRequest.java new file mode 100644 index 00000000..951a5a68 --- /dev/null +++ b/src/main/java/com/descope/model/jwt/request/ClientAssertionRequest.java @@ -0,0 +1,20 @@ +package com.descope.model.jwt.request; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ClientAssertionRequest { + String issuer; + String subject; + List audience; + Integer expiresIn; + Boolean flattenAudience; + String algorithm; +} diff --git a/src/main/java/com/descope/model/jwt/response/ClientAssertionResponse.java b/src/main/java/com/descope/model/jwt/response/ClientAssertionResponse.java new file mode 100644 index 00000000..979d6ebf --- /dev/null +++ b/src/main/java/com/descope/model/jwt/response/ClientAssertionResponse.java @@ -0,0 +1,12 @@ +package com.descope.model.jwt.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClientAssertionResponse { + private String jwt; +} diff --git a/src/main/java/com/descope/sdk/mgmt/JwtService.java b/src/main/java/com/descope/sdk/mgmt/JwtService.java index 7ee1814b..7f7bc236 100644 --- a/src/main/java/com/descope/sdk/mgmt/JwtService.java +++ b/src/main/java/com/descope/sdk/mgmt/JwtService.java @@ -5,7 +5,9 @@ import com.descope.model.jwt.MgmtSignUpUser; import com.descope.model.jwt.Token; import com.descope.model.jwt.request.AnonymousUserRequest; +import com.descope.model.jwt.response.ClientAssertionResponse; import com.descope.model.magiclink.LoginOptions; +import java.util.List; import java.util.Map; /** Provide functions for manipulating valid JWT. */ @@ -31,4 +33,21 @@ AuthenticationInfo signUpOrIn(String loginId, MgmtSignUpUser signUpUserDetails) AuthenticationInfo signIn(String loginId, LoginOptions loginOptions) throws DescopeException; AuthenticationInfo anonymous(AnonymousUserRequest request) throws DescopeException; + + /** + * Generate a client assertion JWT for OAuth 2.0 client credentials flow. + * This JWT can be used as a client authentication method when requesting access tokens. + * + * @param issuer - The issuer of the JWT (typically the client ID) + * @param subject - The subject of the JWT (typically the client ID) + * @param audience - The audience of the JWT (typically the authorization server) + * @param expiresIn - Expiration time in seconds + * @param flattenAudience - Whether to flatten the audience array to a single string (optional) + * @param algorithm - The signing algorithm to use: RS256, RS384, or ES384 (optional) + * @return ClientAssertionResponse containing the generated JWT + * @throws DescopeException if validation fails or the request cannot be completed + */ + ClientAssertionResponse generateClientAssertionJwt(String issuer, String subject, + List audience, Integer expiresIn, Boolean flattenAudience, String algorithm) + throws DescopeException; } 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 738cb1fe..68456a3c 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/JwtServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/JwtServiceImpl.java @@ -1,5 +1,6 @@ package com.descope.sdk.mgmt.impl; +import static com.descope.literals.Routes.ManagementEndPoints.CLIENT_ASSERTION; import static com.descope.literals.Routes.ManagementEndPoints.MANAGEMENT_ANONYMOUS_USER; import static com.descope.literals.Routes.ManagementEndPoints.MANAGEMENT_SIGN_IN; import static com.descope.literals.Routes.ManagementEndPoints.MANAGEMENT_SIGN_UP; @@ -14,15 +15,18 @@ import com.descope.model.jwt.MgmtSignUpUser; import com.descope.model.jwt.Token; import com.descope.model.jwt.request.AnonymousUserRequest; +import com.descope.model.jwt.request.ClientAssertionRequest; import com.descope.model.jwt.request.ManagementSignInRequest; import com.descope.model.jwt.request.ManagementSignUpRequest; import com.descope.model.jwt.request.UpdateJwtRequest; +import com.descope.model.jwt.response.ClientAssertionResponse; import com.descope.model.jwt.response.JWTResponse; import com.descope.model.jwt.response.UpdateJwtResponse; import com.descope.model.magiclink.LoginOptions; import com.descope.proxy.ApiProxy; import com.descope.sdk.mgmt.JwtService; import java.net.URI; +import java.util.List; import java.util.Map; import org.apache.commons.lang3.StringUtils; @@ -140,4 +144,36 @@ private AuthenticationInfo validateAndCreateAuthInfo(JWTResponse jwtResponse) th private URI composeUpdateJwtUri() { return getUri(UPDATE_JWT_LINK); } + + @Override + public ClientAssertionResponse generateClientAssertionJwt(String issuer, String subject, + List audience, Integer expiresIn, Boolean flattenAudience, String algorithm) + throws DescopeException { + if (StringUtils.isBlank(issuer)) { + throw ServerCommonException.invalidArgument("issuer"); + } + if (StringUtils.isBlank(subject)) { + throw ServerCommonException.invalidArgument("subject"); + } + if (audience == null || audience.isEmpty()) { + throw ServerCommonException.invalidArgument("audience"); + } + if (expiresIn == null || expiresIn <= 0) { + throw ServerCommonException.invalidArgument("expiresIn"); + } + + ClientAssertionRequest request = ClientAssertionRequest.builder() + .issuer(issuer) + .subject(subject) + .audience(audience) + .expiresIn(expiresIn) + .flattenAudience(flattenAudience) + .algorithm(algorithm) + .build(); + + URI uri = getUri(CLIENT_ASSERTION); + ApiProxy apiProxy = getApiProxy(); + ClientAssertionResponse response = apiProxy.post(uri, request, ClientAssertionResponse.class); + return response; + } } diff --git a/src/test/java/com/descope/sdk/mgmt/impl/JwtServiceImplClientAssertionTest.java b/src/test/java/com/descope/sdk/mgmt/impl/JwtServiceImplClientAssertionTest.java new file mode 100644 index 00000000..641832ea --- /dev/null +++ b/src/test/java/com/descope/sdk/mgmt/impl/JwtServiceImplClientAssertionTest.java @@ -0,0 +1,203 @@ +package com.descope.sdk.mgmt.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import com.descope.exception.ServerCommonException; +import com.descope.model.client.Client; +import com.descope.model.jwt.response.ClientAssertionResponse; +import com.descope.proxy.ApiProxy; +import com.descope.proxy.impl.ApiProxyBuilder; +import com.descope.sdk.mgmt.JwtService; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +public class JwtServiceImplClientAssertionTest { + + @Test + void testGenerateClientAssertionJwtSuccess() { + ApiProxy apiProxy = mock(ApiProxy.class); + ClientAssertionResponse mockResponse = new ClientAssertionResponse("mock.jwt.token"); + doReturn(mockResponse).when(apiProxy).post(any(), any(), eq(ClientAssertionResponse.class)); + + try (MockedStatic mockedBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedBuilder.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Arrays.asList("https://auth.example.com/token"); + ClientAssertionResponse response = jwtService.generateClientAssertionJwt( + "client-id", + "client-id", + audience, + 3600, + null, + null + ); + + assertNotNull(response); + assertEquals("mock.jwt.token", response.getJwt()); + } + } + + @Test + void testGenerateClientAssertionJwtWithOptionalParams() { + ApiProxy apiProxy = mock(ApiProxy.class); + ClientAssertionResponse mockResponse = new ClientAssertionResponse("mock.jwt.token"); + doReturn(mockResponse).when(apiProxy).post(any(), any(), eq(ClientAssertionResponse.class)); + + try (MockedStatic mockedBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedBuilder.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Arrays.asList("https://auth.example.com/token"); + ClientAssertionResponse response = jwtService.generateClientAssertionJwt( + "client-id", + "client-id", + audience, + 3600, + true, + "RS256" + ); + + assertNotNull(response); + assertEquals("mock.jwt.token", response.getJwt()); + } + } + + @Test + void testGenerateClientAssertionJwtEmptyIssuer() { + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Arrays.asList("https://auth.example.com/token"); + ServerCommonException thrown = assertThrows( + ServerCommonException.class, + () -> jwtService.generateClientAssertionJwt("", "client-id", audience, 3600, null, null) + ); + assertNotNull(thrown); + assertEquals("The issuer argument is invalid", thrown.getMessage()); + } + + @Test + void testGenerateClientAssertionJwtEmptySubject() { + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Arrays.asList("https://auth.example.com/token"); + ServerCommonException thrown = assertThrows( + ServerCommonException.class, + () -> jwtService.generateClientAssertionJwt("client-id", "", audience, 3600, null, null) + ); + assertNotNull(thrown); + assertEquals("The subject argument is invalid", thrown.getMessage()); + } + + @Test + void testGenerateClientAssertionJwtNullAudience() { + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + ServerCommonException thrown = assertThrows( + ServerCommonException.class, + () -> jwtService.generateClientAssertionJwt("client-id", "client-id", null, 3600, null, null) + ); + assertNotNull(thrown); + assertEquals("The audience argument is invalid", thrown.getMessage()); + } + + @Test + void testGenerateClientAssertionJwtEmptyAudience() { + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Collections.emptyList(); + ServerCommonException thrown = assertThrows( + ServerCommonException.class, + () -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, 3600, null, null) + ); + assertNotNull(thrown); + assertEquals("The audience argument is invalid", thrown.getMessage()); + } + + @Test + void testGenerateClientAssertionJwtNullExpiresIn() { + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Arrays.asList("https://auth.example.com/token"); + ServerCommonException thrown = assertThrows( + ServerCommonException.class, + () -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, null, null, null) + ); + assertNotNull(thrown); + assertEquals("The expiresIn argument is invalid", thrown.getMessage()); + } + + @Test + void testGenerateClientAssertionJwtZeroExpiresIn() { + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Arrays.asList("https://auth.example.com/token"); + ServerCommonException thrown = assertThrows( + ServerCommonException.class, + () -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, 0, null, null) + ); + assertNotNull(thrown); + assertEquals("The expiresIn argument is invalid", thrown.getMessage()); + } + + @Test + void testGenerateClientAssertionJwtNegativeExpiresIn() { + Client client = Client.builder() + .projectId("test-project") + .managementKey("test-key") + .build(); + JwtService jwtService = new JwtServiceImpl(client); + + List audience = Arrays.asList("https://auth.example.com/token"); + ServerCommonException thrown = assertThrows( + ServerCommonException.class, + () -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, -1, null, null) + ); + assertNotNull(thrown); + assertEquals("The expiresIn argument is invalid", thrown.getMessage()); + } +}