Skip to content

Commit 33caaef

Browse files
jamesnethertonmaximilian
andauthored
Add jwt_fetch_api_key support
Fixes #308 Co-authored-by: maximilian <maxmilian@audriga.com>
1 parent e0a21cc commit 33caaef

9 files changed

Lines changed: 259 additions & 0 deletions

File tree

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<!-- Project dependency versions -->
2020
<httpcomponents.version>5.6</httpcomponents.version>
2121
<jackson.version>2.21.1</jackson.version>
22+
<nimbus.jose.jwt.version>10.8</nimbus.jose.jwt.version>
2223

2324
<!-- Project test dependency versions -->
2425
<awaitility.version>4.3.0</awaitility.version>
@@ -96,6 +97,11 @@
9697
<artifactId>httpclient5</artifactId>
9798
<version>${httpcomponents.version}</version>
9899
</dependency>
100+
<dependency>
101+
<groupId>com.nimbusds</groupId>
102+
<artifactId>nimbus-jose-jwt</artifactId>
103+
<version>${nimbus.jose.jwt.version}</version>
104+
</dependency>
99105

100106
<dependency>
101107
<groupId>org.junit.jupiter</groupId>

src/main/java/com/github/jamesnetherton/zulip/client/api/server/ServerService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import com.github.jamesnetherton.zulip.client.api.server.request.GetLinkifiersApiRequest;
1818
import com.github.jamesnetherton.zulip.client.api.server.request.GetProfileFieldsApiRequest;
1919
import com.github.jamesnetherton.zulip.client.api.server.request.GetServerSettingsApiRequest;
20+
import com.github.jamesnetherton.zulip.client.api.server.request.JwtFetchApiKeyApiRequest;
2021
import com.github.jamesnetherton.zulip.client.api.server.request.RegisterE2EMobilePushDevice;
2122
import com.github.jamesnetherton.zulip.client.api.server.request.RemoveApnsDeviceTokenApiRequest;
2223
import com.github.jamesnetherton.zulip.client.api.server.request.RemoveCodePlaygroundApiRequest;
@@ -219,6 +220,18 @@ public GetApiKeyApiRequest getApiKey(String username, String password) {
219220
return new GetApiKeyApiRequest(this.client, username, password);
220221
}
221222

223+
/**
224+
* Fetches a Zulip API key using a JWT token.
225+
*
226+
* @see <a href="https://zulip.com/api/jwt-fetch-api-key">https://zulip.com/api/jwt-fetch-api-key</a>
227+
*
228+
* @param token The JWT token
229+
* @return The {@link JwtFetchApiKeyApiRequest} builder object
230+
*/
231+
public JwtFetchApiKeyApiRequest jwtFetchApiKey(String token) {
232+
return new JwtFetchApiKeyApiRequest(this.client, token);
233+
}
234+
222235
/**
223236
* Adds a code playground.
224237
*
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.github.jamesnetherton.zulip.client.api.server.request;
2+
3+
import static com.github.jamesnetherton.zulip.client.api.server.request.ServerRequestConstants.JWT_FETCH_API_KEY;
4+
5+
import com.github.jamesnetherton.zulip.client.api.core.ExecutableApiRequest;
6+
import com.github.jamesnetherton.zulip.client.api.core.ZulipApiRequest;
7+
import com.github.jamesnetherton.zulip.client.api.server.response.JwtFetchApiKeyResponse;
8+
import com.github.jamesnetherton.zulip.client.exception.ZulipClientException;
9+
import com.github.jamesnetherton.zulip.client.http.ZulipHttpClient;
10+
11+
/**
12+
* Zulip API request builder for fetching an API key using a JWT token.
13+
*
14+
* @see <a href=
15+
* "https://zulip.com/api/jwt-fetch-api-key#usage-examples">https://zulip.com/api/jwt-fetch-api-key#usage-examples</a>
16+
*/
17+
public class JwtFetchApiKeyApiRequest extends ZulipApiRequest implements ExecutableApiRequest<JwtFetchApiKeyResponse> {
18+
19+
public static final String TOKEN = "token";
20+
public static final String INCLUDE_PROFILE = "include_profile";
21+
22+
/**
23+
* Constructs a {@link JwtFetchApiKeyApiRequest}.
24+
*
25+
* @param client The Zulip HTTP client
26+
* @param token The JWT token
27+
*/
28+
public JwtFetchApiKeyApiRequest(ZulipHttpClient client, String token) {
29+
super(client);
30+
putParam(TOKEN, token);
31+
}
32+
33+
/**
34+
* Sets whether to include the user profile data in the response.
35+
*
36+
* @param includeProfile {@code true} to include the user profile data in the response.
37+
* {@code false} to not include the user profile data in the response
38+
* @return This {@link JwtFetchApiKeyApiRequest} instance
39+
*/
40+
public JwtFetchApiKeyApiRequest withIncludeProfile(boolean includeProfile) {
41+
putParam(INCLUDE_PROFILE, includeProfile);
42+
return this;
43+
}
44+
45+
/**
46+
* Executes the Zulip API request for fetching an API key using a JWT token.
47+
*
48+
* @return {@link JwtFetchApiKeyResponse} containing the API key, email and optionally user data
49+
* @throws ZulipClientException if the request was not successful
50+
*/
51+
@Override
52+
public JwtFetchApiKeyResponse execute() throws ZulipClientException {
53+
return client().post(JWT_FETCH_API_KEY, getParams(), JwtFetchApiKeyResponse.class);
54+
}
55+
}

src/main/java/com/github/jamesnetherton/zulip/client/api/server/request/ServerRequestConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ final class ServerRequestConstants {
66
public static final String EXPORT_REALM = "export/realm";
77
public static final String EXPORT_REALM_CONSENTS = "export/realm/consents";
88
public static final String FETCH_API_KEY = "fetch_api_key";
9+
public static final String JWT_FETCH_API_KEY = "jwt/fetch_api_key";
910
public static final String MOBILE_PUSH = "mobile_push";
1011
public static final String MOBILE_PUSH_REGISTER = MOBILE_PUSH + "/register";
1112
public static final String MOBILE_PUSH_TEST = MOBILE_PUSH + "/test_notification";
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.github.jamesnetherton.zulip.client.api.server.response;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.github.jamesnetherton.zulip.client.api.core.ZulipApiResponse;
5+
import com.github.jamesnetherton.zulip.client.api.user.response.UserApiResponse;
6+
7+
/**
8+
* Zulip API response class for fetching an API key via JWT.
9+
*
10+
* @see <a href="https://zulip.com/api/jwt-fetch-api-key#response">https://zulip.com/api/jwt-fetch-api-key#response</a>
11+
*/
12+
public class JwtFetchApiKeyResponse extends ZulipApiResponse {
13+
@JsonProperty("api_key")
14+
private String apiKey;
15+
16+
@JsonProperty
17+
private String email;
18+
19+
@JsonProperty
20+
private UserApiResponse user;
21+
22+
public String getApiKey() {
23+
return apiKey;
24+
}
25+
26+
public String getEmail() {
27+
return email;
28+
}
29+
30+
public UserApiResponse getUser() {
31+
return user;
32+
}
33+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.github.jamesnetherton.zulip.client.util;
2+
3+
import com.nimbusds.jose.JOSEException;
4+
import com.nimbusds.jose.JOSEObjectType;
5+
import com.nimbusds.jose.JWSAlgorithm;
6+
import com.nimbusds.jose.JWSHeader;
7+
import com.nimbusds.jose.JWSSigner;
8+
import com.nimbusds.jose.crypto.MACSigner;
9+
import com.nimbusds.jwt.JWTClaimsSet;
10+
import com.nimbusds.jwt.SignedJWT;
11+
import java.nio.charset.StandardCharsets;
12+
import java.security.SecureRandom;
13+
import java.util.Base64;
14+
15+
/**
16+
* A utility class to create a JWT for an email and create a secret key.
17+
*/
18+
public class JwtUtils {
19+
20+
private JwtUtils() {
21+
}
22+
23+
/**
24+
* Creates a random JWT secret.
25+
*/
26+
public static String createRandomJwtSecret() {
27+
byte[] secret = new byte[32];
28+
new SecureRandom().nextBytes(secret);
29+
return Base64.getUrlEncoder().withoutPadding().encodeToString(secret);
30+
}
31+
32+
/**
33+
* Creates a signed JWT for the given email.
34+
*/
35+
public static String createJwtForEmail(String email, String secret) {
36+
if (email == null || email.isBlank()) {
37+
throw new IllegalArgumentException("email cannot be null or blank");
38+
}
39+
40+
if (secret == null || secret.isBlank()) {
41+
throw new IllegalArgumentException("secret cannot be null or blank");
42+
}
43+
44+
try {
45+
JWSSigner signer = new MACSigner(secret.getBytes(StandardCharsets.UTF_8));
46+
47+
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
48+
.claim("email", email)
49+
.build();
50+
51+
SignedJWT signedJWT = new SignedJWT(
52+
new JWSHeader.Builder(JWSAlgorithm.HS256)
53+
.type(JOSEObjectType.JWT)
54+
.build(),
55+
claimsSet);
56+
57+
signedJWT.sign(signer);
58+
return signedJWT.serialize();
59+
} catch (JOSEException e) {
60+
throw new IllegalStateException("Failed to create JWT", e);
61+
}
62+
}
63+
}

src/test/java/com/github/jamesnetherton/zulip/client/api/server/ZulipServerApiTest.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.github.jamesnetherton.zulip.client.api.server.request.CreateDataExportApiRequest;
2020
import com.github.jamesnetherton.zulip.client.api.server.request.CreateProfileFieldApiRequest;
2121
import com.github.jamesnetherton.zulip.client.api.server.request.GetApiKeyApiRequest;
22+
import com.github.jamesnetherton.zulip.client.api.server.request.JwtFetchApiKeyApiRequest;
2223
import com.github.jamesnetherton.zulip.client.api.server.request.RegisterE2EMobilePushDevice;
2324
import com.github.jamesnetherton.zulip.client.api.server.request.RemoveApnsDeviceTokenApiRequest;
2425
import com.github.jamesnetherton.zulip.client.api.server.request.RemoveFcmRegistrationTokenApiRequest;
@@ -27,6 +28,7 @@
2728
import com.github.jamesnetherton.zulip.client.api.server.request.SendMobilePushTestNotification;
2829
import com.github.jamesnetherton.zulip.client.api.server.request.TestWelcomeBotCustomMessageApiRequest;
2930
import com.github.jamesnetherton.zulip.client.api.server.request.UpdateRealmNewUserDefaultSettingsApiRequest;
31+
import com.github.jamesnetherton.zulip.client.api.server.response.JwtFetchApiKeyResponse;
3032
import com.github.jamesnetherton.zulip.client.api.user.ColorScheme;
3133
import com.github.jamesnetherton.zulip.client.api.user.DemoteInactiveStreamOption;
3234
import com.github.jamesnetherton.zulip.client.api.user.DesktopIconCountDisplay;
@@ -36,6 +38,7 @@
3638
import com.github.jamesnetherton.zulip.client.api.user.WebAnimateImageOption;
3739
import com.github.jamesnetherton.zulip.client.api.user.WebChannelView;
3840
import com.github.jamesnetherton.zulip.client.api.user.WebHomeView;
41+
import com.github.jamesnetherton.zulip.client.api.user.response.UserApiResponse;
3942
import com.github.tomakehurst.wiremock.matching.StringValuePattern;
4043
import java.io.File;
4144
import java.util.Collections;
@@ -353,6 +356,59 @@ public void getProductionApiKey() throws Exception {
353356
assertEquals("abc123zxy", key);
354357
}
355358

359+
@Test
360+
public void jwtFetchApiKey() throws Exception {
361+
Map<String, StringValuePattern> params = QueryParams.create()
362+
.add(JwtFetchApiKeyApiRequest.TOKEN, "test-jwt-token")
363+
.get();
364+
365+
stubZulipResponse(POST, "/jwt/fetch_api_key", params, "jwtFetchApiKey.json");
366+
367+
JwtFetchApiKeyResponse response = zulip.server()
368+
.jwtFetchApiKey("test-jwt-token")
369+
.execute();
370+
371+
assertNotNull(response);
372+
assertEquals("test-api-key", response.getApiKey());
373+
assertEquals("test@example.com", response.getEmail());
374+
assertNull(response.getUser());
375+
}
376+
377+
@Test
378+
public void jwtFetchApiKeyWithProfile() throws Exception {
379+
Map<String, StringValuePattern> params = QueryParams.create()
380+
.add(JwtFetchApiKeyApiRequest.TOKEN, "test-jwt-token")
381+
.add(JwtFetchApiKeyApiRequest.INCLUDE_PROFILE, "true")
382+
.get();
383+
384+
stubZulipResponse(POST, "/jwt/fetch_api_key", params, "jwtFetchApiKeyWithProfile.json");
385+
386+
JwtFetchApiKeyResponse response = zulip.server()
387+
.jwtFetchApiKey("test-jwt-token")
388+
.withIncludeProfile(true)
389+
.execute();
390+
391+
assertNotNull(response);
392+
assertEquals("test-api-key", response.getApiKey());
393+
assertEquals("test@example.com", response.getEmail());
394+
assertNotNull(response.getUser());
395+
396+
UserApiResponse user = response.getUser();
397+
assertEquals(5L, user.getUserId());
398+
assertEquals("test@example.com", user.getDeliveryEmail());
399+
assertEquals("test@example.com", user.getEmail());
400+
assertEquals("Test123", user.getFullName());
401+
assertEquals("2019-10-20T07:50:53.728864+00:00", user.getDateJoined());
402+
assertEquals("Europe/London", user.getTimezone());
403+
assertEquals("https://secure.gravatar.com/avatar/test?d=identicon&version=1", user.getAvatarUrl());
404+
assertEquals(1, user.getAvatarVersion());
405+
assertEquals(false, user.isBot());
406+
assertEquals(false, user.isGuest());
407+
assertEquals(false, user.isOwner());
408+
assertEquals(true, user.isAdmin());
409+
assertEquals(true, user.isActive());
410+
}
411+
356412
@Test
357413
public void addCodePlayground() throws Exception {
358414
Map<String, StringValuePattern> params = QueryParams.create()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"result": "success",
3+
"msg": "",
4+
"api_key": "test-api-key",
5+
"email": "test@example.com"
6+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"result": "success",
3+
"msg": "",
4+
"api_key": "test-api-key",
5+
"email": "test@example.com",
6+
"user": {
7+
"user_id": 5,
8+
"delivery_email": "test@example.com",
9+
"email": "test@example.com",
10+
"full_name": "Test123",
11+
"date_joined": "2019-10-20T07:50:53.728864+00:00",
12+
"is_active": true,
13+
"is_owner": false,
14+
"is_admin": true,
15+
"is_guest": false,
16+
"is_bot": false,
17+
"bot_type": null,
18+
"bot_owner_id": null,
19+
"role": 400,
20+
"timezone": "Europe/London",
21+
"avatar_url": "https://secure.gravatar.com/avatar/test?d=identicon&version=1",
22+
"avatar_version": 1,
23+
"is_imported_stub": false,
24+
"profile_data": {}
25+
}
26+
}

0 commit comments

Comments
 (0)