Skip to content

Commit 3f49abb

Browse files
authored
feat(jwt): add client assertion jwt generation for oauth 2.0 client cred (#295)
* feat: add client assertion JWT request/response models * feat: implement client assertion JWT generation in JwtService * test: add comprehensive tests for client assertion JWT generation * docs: update README with client assertion JWT usage example * docs: restore removed JWT generation examples and bump version to 1.0.61 --------- Co-authored-by: Aviad Lichtenstadt <aviadl@users.noreply.github.com>
1 parent 6b6425b commit 3f49abb

8 files changed

Lines changed: 311 additions & 1 deletion

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,25 @@ mgmtSignUpUser.setCustomClaims(new HashMap<String, Object>() {{
12471247
AuthenticationInfo res = jwtService.signUpOrIn("Dummy", mgmtSignUpUser);
12481248
```
12491249

1250+
Generate a client assertion JWT for OAuth 2.0 client credentials flow.
1251+
1252+
```java
1253+
JwtService jwtService = descopeClient.getManagementServices().getJwtService();
1254+
try {
1255+
ClientAssertionResponse response = jwtService.generateClientAssertionJwt(
1256+
"client-id",
1257+
"client-id",
1258+
Arrays.asList("https://auth.example.com/token"),
1259+
3600,
1260+
false,
1261+
"RS256"
1262+
);
1263+
String jwt = response.getJwt();
1264+
} catch (DescopeException de) {
1265+
// Handle the error
1266+
}
1267+
```
1268+
12501269
### Audit
12511270

12521271
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.

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<groupId>com.descope</groupId>
66
<artifactId>java-sdk</artifactId>
77
<modelVersion>4.0.0</modelVersion>
8-
<version>1.0.60</version>
8+
<version>1.0.61</version>
99
<name>${project.groupId}:${project.artifactId}</name>
1010
<description>Java library used to integrate with Descope.</description>
1111
<url>https://github.com/descope/descope-java</url>

src/main/java/com/descope/literals/Routes.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ public static class ManagementEndPoints {
166166

167167
// JWT
168168
public static final String UPDATE_JWT_LINK = "/v1/mgmt/jwt/update";
169+
public static final String CLIENT_ASSERTION = "/v1/mgmt/token/clientassertion";
169170
public static final String MANAGEMENT_SIGN_IN = "/v1/mgmt/auth/signin";
170171
public static final String MANAGEMENT_SIGN_UP = "/v1/mgmt/auth/signup";
171172
public static final String MANAGEMENT_SIGN_UP_OR_IN = "/v1/mgmt/auth/signup-in";
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.descope.model.jwt.request;
2+
3+
import java.util.List;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Data;
7+
import lombok.NoArgsConstructor;
8+
9+
@Data
10+
@Builder
11+
@NoArgsConstructor
12+
@AllArgsConstructor
13+
public class ClientAssertionRequest {
14+
String issuer;
15+
String subject;
16+
List<String> audience;
17+
Integer expiresIn;
18+
Boolean flattenAudience;
19+
String algorithm;
20+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.descope.model.jwt.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Data;
5+
import lombok.NoArgsConstructor;
6+
7+
@Data
8+
@NoArgsConstructor
9+
@AllArgsConstructor
10+
public class ClientAssertionResponse {
11+
private String jwt;
12+
}

src/main/java/com/descope/sdk/mgmt/JwtService.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import com.descope.model.jwt.MgmtSignUpUser;
66
import com.descope.model.jwt.Token;
77
import com.descope.model.jwt.request.AnonymousUserRequest;
8+
import com.descope.model.jwt.response.ClientAssertionResponse;
89
import com.descope.model.magiclink.LoginOptions;
10+
import java.util.List;
911
import java.util.Map;
1012

1113
/** Provide functions for manipulating valid JWT. */
@@ -31,4 +33,21 @@ AuthenticationInfo signUpOrIn(String loginId, MgmtSignUpUser signUpUserDetails)
3133
AuthenticationInfo signIn(String loginId, LoginOptions loginOptions) throws DescopeException;
3234

3335
AuthenticationInfo anonymous(AnonymousUserRequest request) throws DescopeException;
36+
37+
/**
38+
* Generate a client assertion JWT for OAuth 2.0 client credentials flow.
39+
* This JWT can be used as a client authentication method when requesting access tokens.
40+
*
41+
* @param issuer - The issuer of the JWT (typically the client ID)
42+
* @param subject - The subject of the JWT (typically the client ID)
43+
* @param audience - The audience of the JWT (typically the authorization server)
44+
* @param expiresIn - Expiration time in seconds
45+
* @param flattenAudience - Whether to flatten the audience array to a single string (optional)
46+
* @param algorithm - The signing algorithm to use: RS256, RS384, or ES384 (optional)
47+
* @return ClientAssertionResponse containing the generated JWT
48+
* @throws DescopeException if validation fails or the request cannot be completed
49+
*/
50+
ClientAssertionResponse generateClientAssertionJwt(String issuer, String subject,
51+
List<String> audience, Integer expiresIn, Boolean flattenAudience, String algorithm)
52+
throws DescopeException;
3453
}

src/main/java/com/descope/sdk/mgmt/impl/JwtServiceImpl.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.descope.sdk.mgmt.impl;
22

3+
import static com.descope.literals.Routes.ManagementEndPoints.CLIENT_ASSERTION;
34
import static com.descope.literals.Routes.ManagementEndPoints.MANAGEMENT_ANONYMOUS_USER;
45
import static com.descope.literals.Routes.ManagementEndPoints.MANAGEMENT_SIGN_IN;
56
import static com.descope.literals.Routes.ManagementEndPoints.MANAGEMENT_SIGN_UP;
@@ -14,15 +15,18 @@
1415
import com.descope.model.jwt.MgmtSignUpUser;
1516
import com.descope.model.jwt.Token;
1617
import com.descope.model.jwt.request.AnonymousUserRequest;
18+
import com.descope.model.jwt.request.ClientAssertionRequest;
1719
import com.descope.model.jwt.request.ManagementSignInRequest;
1820
import com.descope.model.jwt.request.ManagementSignUpRequest;
1921
import com.descope.model.jwt.request.UpdateJwtRequest;
22+
import com.descope.model.jwt.response.ClientAssertionResponse;
2023
import com.descope.model.jwt.response.JWTResponse;
2124
import com.descope.model.jwt.response.UpdateJwtResponse;
2225
import com.descope.model.magiclink.LoginOptions;
2326
import com.descope.proxy.ApiProxy;
2427
import com.descope.sdk.mgmt.JwtService;
2528
import java.net.URI;
29+
import java.util.List;
2630
import java.util.Map;
2731
import org.apache.commons.lang3.StringUtils;
2832

@@ -140,4 +144,36 @@ private AuthenticationInfo validateAndCreateAuthInfo(JWTResponse jwtResponse) th
140144
private URI composeUpdateJwtUri() {
141145
return getUri(UPDATE_JWT_LINK);
142146
}
147+
148+
@Override
149+
public ClientAssertionResponse generateClientAssertionJwt(String issuer, String subject,
150+
List<String> audience, Integer expiresIn, Boolean flattenAudience, String algorithm)
151+
throws DescopeException {
152+
if (StringUtils.isBlank(issuer)) {
153+
throw ServerCommonException.invalidArgument("issuer");
154+
}
155+
if (StringUtils.isBlank(subject)) {
156+
throw ServerCommonException.invalidArgument("subject");
157+
}
158+
if (audience == null || audience.isEmpty()) {
159+
throw ServerCommonException.invalidArgument("audience");
160+
}
161+
if (expiresIn == null || expiresIn <= 0) {
162+
throw ServerCommonException.invalidArgument("expiresIn");
163+
}
164+
165+
ClientAssertionRequest request = ClientAssertionRequest.builder()
166+
.issuer(issuer)
167+
.subject(subject)
168+
.audience(audience)
169+
.expiresIn(expiresIn)
170+
.flattenAudience(flattenAudience)
171+
.algorithm(algorithm)
172+
.build();
173+
174+
URI uri = getUri(CLIENT_ASSERTION);
175+
ApiProxy apiProxy = getApiProxy();
176+
ClientAssertionResponse response = apiProxy.post(uri, request, ClientAssertionResponse.class);
177+
return response;
178+
}
143179
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package com.descope.sdk.mgmt.impl;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNotNull;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
import static org.mockito.ArgumentMatchers.any;
7+
import static org.mockito.ArgumentMatchers.eq;
8+
import static org.mockito.Mockito.doReturn;
9+
import static org.mockito.Mockito.mock;
10+
import static org.mockito.Mockito.mockStatic;
11+
12+
import com.descope.exception.ServerCommonException;
13+
import com.descope.model.client.Client;
14+
import com.descope.model.jwt.response.ClientAssertionResponse;
15+
import com.descope.proxy.ApiProxy;
16+
import com.descope.proxy.impl.ApiProxyBuilder;
17+
import com.descope.sdk.mgmt.JwtService;
18+
import java.util.Arrays;
19+
import java.util.Collections;
20+
import java.util.List;
21+
import org.junit.jupiter.api.Test;
22+
import org.mockito.MockedStatic;
23+
24+
public class JwtServiceImplClientAssertionTest {
25+
26+
@Test
27+
void testGenerateClientAssertionJwtSuccess() {
28+
ApiProxy apiProxy = mock(ApiProxy.class);
29+
ClientAssertionResponse mockResponse = new ClientAssertionResponse("mock.jwt.token");
30+
doReturn(mockResponse).when(apiProxy).post(any(), any(), eq(ClientAssertionResponse.class));
31+
32+
try (MockedStatic<ApiProxyBuilder> mockedBuilder = mockStatic(ApiProxyBuilder.class)) {
33+
mockedBuilder.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy);
34+
35+
Client client = Client.builder()
36+
.projectId("test-project")
37+
.managementKey("test-key")
38+
.build();
39+
JwtService jwtService = new JwtServiceImpl(client);
40+
41+
List<String> audience = Arrays.asList("https://auth.example.com/token");
42+
ClientAssertionResponse response = jwtService.generateClientAssertionJwt(
43+
"client-id",
44+
"client-id",
45+
audience,
46+
3600,
47+
null,
48+
null
49+
);
50+
51+
assertNotNull(response);
52+
assertEquals("mock.jwt.token", response.getJwt());
53+
}
54+
}
55+
56+
@Test
57+
void testGenerateClientAssertionJwtWithOptionalParams() {
58+
ApiProxy apiProxy = mock(ApiProxy.class);
59+
ClientAssertionResponse mockResponse = new ClientAssertionResponse("mock.jwt.token");
60+
doReturn(mockResponse).when(apiProxy).post(any(), any(), eq(ClientAssertionResponse.class));
61+
62+
try (MockedStatic<ApiProxyBuilder> mockedBuilder = mockStatic(ApiProxyBuilder.class)) {
63+
mockedBuilder.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy);
64+
65+
Client client = Client.builder()
66+
.projectId("test-project")
67+
.managementKey("test-key")
68+
.build();
69+
JwtService jwtService = new JwtServiceImpl(client);
70+
71+
List<String> audience = Arrays.asList("https://auth.example.com/token");
72+
ClientAssertionResponse response = jwtService.generateClientAssertionJwt(
73+
"client-id",
74+
"client-id",
75+
audience,
76+
3600,
77+
true,
78+
"RS256"
79+
);
80+
81+
assertNotNull(response);
82+
assertEquals("mock.jwt.token", response.getJwt());
83+
}
84+
}
85+
86+
@Test
87+
void testGenerateClientAssertionJwtEmptyIssuer() {
88+
Client client = Client.builder()
89+
.projectId("test-project")
90+
.managementKey("test-key")
91+
.build();
92+
JwtService jwtService = new JwtServiceImpl(client);
93+
94+
List<String> audience = Arrays.asList("https://auth.example.com/token");
95+
ServerCommonException thrown = assertThrows(
96+
ServerCommonException.class,
97+
() -> jwtService.generateClientAssertionJwt("", "client-id", audience, 3600, null, null)
98+
);
99+
assertNotNull(thrown);
100+
assertEquals("The issuer argument is invalid", thrown.getMessage());
101+
}
102+
103+
@Test
104+
void testGenerateClientAssertionJwtEmptySubject() {
105+
Client client = Client.builder()
106+
.projectId("test-project")
107+
.managementKey("test-key")
108+
.build();
109+
JwtService jwtService = new JwtServiceImpl(client);
110+
111+
List<String> audience = Arrays.asList("https://auth.example.com/token");
112+
ServerCommonException thrown = assertThrows(
113+
ServerCommonException.class,
114+
() -> jwtService.generateClientAssertionJwt("client-id", "", audience, 3600, null, null)
115+
);
116+
assertNotNull(thrown);
117+
assertEquals("The subject argument is invalid", thrown.getMessage());
118+
}
119+
120+
@Test
121+
void testGenerateClientAssertionJwtNullAudience() {
122+
Client client = Client.builder()
123+
.projectId("test-project")
124+
.managementKey("test-key")
125+
.build();
126+
JwtService jwtService = new JwtServiceImpl(client);
127+
128+
ServerCommonException thrown = assertThrows(
129+
ServerCommonException.class,
130+
() -> jwtService.generateClientAssertionJwt("client-id", "client-id", null, 3600, null, null)
131+
);
132+
assertNotNull(thrown);
133+
assertEquals("The audience argument is invalid", thrown.getMessage());
134+
}
135+
136+
@Test
137+
void testGenerateClientAssertionJwtEmptyAudience() {
138+
Client client = Client.builder()
139+
.projectId("test-project")
140+
.managementKey("test-key")
141+
.build();
142+
JwtService jwtService = new JwtServiceImpl(client);
143+
144+
List<String> audience = Collections.emptyList();
145+
ServerCommonException thrown = assertThrows(
146+
ServerCommonException.class,
147+
() -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, 3600, null, null)
148+
);
149+
assertNotNull(thrown);
150+
assertEquals("The audience argument is invalid", thrown.getMessage());
151+
}
152+
153+
@Test
154+
void testGenerateClientAssertionJwtNullExpiresIn() {
155+
Client client = Client.builder()
156+
.projectId("test-project")
157+
.managementKey("test-key")
158+
.build();
159+
JwtService jwtService = new JwtServiceImpl(client);
160+
161+
List<String> audience = Arrays.asList("https://auth.example.com/token");
162+
ServerCommonException thrown = assertThrows(
163+
ServerCommonException.class,
164+
() -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, null, null, null)
165+
);
166+
assertNotNull(thrown);
167+
assertEquals("The expiresIn argument is invalid", thrown.getMessage());
168+
}
169+
170+
@Test
171+
void testGenerateClientAssertionJwtZeroExpiresIn() {
172+
Client client = Client.builder()
173+
.projectId("test-project")
174+
.managementKey("test-key")
175+
.build();
176+
JwtService jwtService = new JwtServiceImpl(client);
177+
178+
List<String> audience = Arrays.asList("https://auth.example.com/token");
179+
ServerCommonException thrown = assertThrows(
180+
ServerCommonException.class,
181+
() -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, 0, null, null)
182+
);
183+
assertNotNull(thrown);
184+
assertEquals("The expiresIn argument is invalid", thrown.getMessage());
185+
}
186+
187+
@Test
188+
void testGenerateClientAssertionJwtNegativeExpiresIn() {
189+
Client client = Client.builder()
190+
.projectId("test-project")
191+
.managementKey("test-key")
192+
.build();
193+
JwtService jwtService = new JwtServiceImpl(client);
194+
195+
List<String> audience = Arrays.asList("https://auth.example.com/token");
196+
ServerCommonException thrown = assertThrows(
197+
ServerCommonException.class,
198+
() -> jwtService.generateClientAssertionJwt("client-id", "client-id", audience, -1, null, null)
199+
);
200+
assertNotNull(thrown);
201+
assertEquals("The expiresIn argument is invalid", thrown.getMessage());
202+
}
203+
}

0 commit comments

Comments
 (0)