Skip to content

Commit 614ba57

Browse files
authored
feat: add jwt decoder (#15)
* feat: add jwt decoder * chore: remove jwt verification and expose the required info via context * test: add test for JwtParser * chore:address review comments
1 parent d47c113 commit 614ba57

File tree

5 files changed

+173
-5
lines changed

5 files changed

+173
-5
lines changed

grpc-context-utils/build.gradle.kts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ tasks.test {
1212
dependencies {
1313
// grpc
1414
implementation("io.grpc:grpc-core:1.36.0")
15-
constraints {
16-
implementation("com.google.guava:guava:30.0-jre") {
17-
because("https://snyk.io/vuln/SNYK-JAVA-COMGOOGLEGUAVA-1015415")
18-
}
19-
}
15+
16+
implementation("com.auth0:java-jwt:3.14.0")
17+
implementation("com.auth0:jwks-rsa:0.17.0")
18+
implementation("com.google.guava:guava:30.1-jre")
2019

2120
// Logging
2221
implementation("org.slf4j:slf4j-api:1.7.30")
2322
// End Logging
2423

2524
testImplementation("org.junit.jupiter:junit-jupiter:5.7.0")
25+
testImplementation("org.mockito:mockito-core:3.8.0")
2626
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.hypertrace.core.grpcutils.context;
2+
3+
import java.util.Optional;
4+
5+
interface Jwt {
6+
Optional<String> getUserId();
7+
8+
Optional<String> getName();
9+
10+
Optional<String> getPictureUrl();
11+
12+
Optional<String> getEmail();
13+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package org.hypertrace.core.grpcutils.context;
2+
3+
import com.auth0.jwt.JWT;
4+
import com.auth0.jwt.interfaces.DecodedJWT;
5+
import com.google.common.cache.Cache;
6+
import com.google.common.cache.CacheBuilder;
7+
import java.util.Optional;
8+
import java.util.concurrent.ExecutionException;
9+
import java.util.concurrent.TimeUnit;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
13+
class JwtParser {
14+
private static final Logger LOG = LoggerFactory.getLogger(JwtParser.class);
15+
private static final String BEARER_TOKEN_PREFIX = "Bearer ";
16+
17+
private final Cache<String, Optional<Jwt>> jwtCache =
18+
CacheBuilder.newBuilder().maximumSize(1000).expireAfterAccess(1, TimeUnit.HOURS).build();
19+
20+
Optional<Jwt> fromAuthHeader(String authHeaderValue) {
21+
if (authHeaderValue.startsWith(BEARER_TOKEN_PREFIX)) {
22+
return this.fromJwt(authHeaderValue.substring(BEARER_TOKEN_PREFIX.length()));
23+
}
24+
return Optional.empty();
25+
}
26+
27+
Optional<Jwt> fromJwt(String jwtValue) {
28+
try {
29+
return this.jwtCache.get(jwtValue, () -> this.decode(jwtValue));
30+
} catch (ExecutionException e) {
31+
LOG.warn("Exception loading jwt from cache", e);
32+
return Optional.empty();
33+
}
34+
}
35+
36+
private Optional<Jwt> decode(String jwtString) {
37+
try {
38+
DecodedJWT jwt = JWT.decode(jwtString);
39+
return Optional.of(new DefaultJwt(jwt));
40+
} catch (Throwable t) {
41+
LOG.warn("Failed to verify JWT", t);
42+
return Optional.empty();
43+
}
44+
}
45+
46+
private static final class DefaultJwt implements Jwt {
47+
private final DecodedJWT jwt;
48+
private static final String SUBJECT_CLAIM = "sub";
49+
private static final String NAME_CLAIM = "name";
50+
private static final String PICTURE_CLAIM = "picture";
51+
private static final String EMAIL_CLAIM = "email";
52+
53+
private DefaultJwt(DecodedJWT jwt) {
54+
this.jwt = jwt;
55+
}
56+
57+
@Override
58+
public Optional<String> getUserId() {
59+
return Optional.ofNullable(jwt.getClaim(SUBJECT_CLAIM).asString());
60+
}
61+
62+
@Override
63+
public Optional<String> getName() {
64+
return Optional.ofNullable(jwt.getClaim(NAME_CLAIM).asString());
65+
}
66+
67+
@Override
68+
public Optional<String> getPictureUrl() {
69+
return Optional.ofNullable(jwt.getClaim(PICTURE_CLAIM).asString());
70+
}
71+
72+
@Override
73+
public Optional<String> getEmail() {
74+
return Optional.ofNullable(jwt.getClaim(EMAIL_CLAIM).asString());
75+
}
76+
}
77+
}

grpc-context-utils/src/main/java/org/hypertrace/core/grpcutils/context/RequestContext.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,33 @@ public static RequestContext forTenantId(String tenantId) {
2121
}
2222

2323
private final Map<String, String> headers = new HashMap<>();
24+
private final JwtParser jwtParser = new JwtParser();
2425

2526
/** Reads tenant id from this RequestContext based on the tenant id http header and returns it. */
2627
public Optional<String> getTenantId() {
2728
return get(RequestContextConstants.TENANT_ID_HEADER_KEY);
2829
}
2930

31+
public Optional<String> getUserId() {
32+
return getJwt().flatMap(Jwt::getUserId);
33+
}
34+
35+
public Optional<String> getName() {
36+
return getJwt().flatMap(Jwt::getName);
37+
}
38+
39+
public Optional<String> getPictureUrl() {
40+
return getJwt().flatMap(Jwt::getPictureUrl);
41+
}
42+
43+
public Optional<String> getEmail() {
44+
return getJwt().flatMap(Jwt::getEmail);
45+
}
46+
47+
private Optional<Jwt> getJwt() {
48+
return get(RequestContextConstants.AUTHORIZATION_HEADER).flatMap(jwtParser::fromAuthHeader);
49+
}
50+
3051
/** Method to read all GRPC request headers from this RequestContext. */
3152
public Map<String, String> getRequestHeaders() {
3253
return getAll();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package org.hypertrace.core.grpcutils.context;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
5+
import static org.mockito.ArgumentMatchers.anyString;
6+
import static org.mockito.Mockito.mock;
7+
import static org.mockito.Mockito.times;
8+
import static org.mockito.Mockito.verify;
9+
import static org.mockito.Mockito.when;
10+
11+
import java.util.Optional;
12+
import org.junit.jupiter.api.Test;
13+
import org.mockito.ArgumentMatchers;
14+
15+
class JwtParserTest {
16+
private final String testJwt =
17+
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MjEzNjM1OTcsImV4cCI6MTY1Mjg5OTU5NywiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJuYW1lIjoiSm9obm55IFJvY2tldCIsImVtYWlsIjoianJvY2tldEBleGFtcGxlLmNvbSIsInBpY3R1cmUiOiJ3d3cuZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.aesOuNIamZkTMR30CBt0J9NMZZt9iLRETa5ayN_EcVs";
18+
private final String testJwtUserId = "[email protected]";
19+
private final String testJwtName = "Johnny Rocket";
20+
private final String testJwtPictureUrl = "www.example.com";
21+
private final String testJwtEmail = "[email protected]";
22+
23+
@Test
24+
void testGoodJwtParse() {
25+
JwtParser parser = new JwtParser();
26+
Optional<Jwt> jwt = parser.fromJwt(testJwt);
27+
assertEquals(Optional.of(testJwtUserId), jwt.flatMap(Jwt::getUserId));
28+
assertEquals(Optional.of(testJwtName), jwt.flatMap(Jwt::getName));
29+
assertEquals(Optional.of(testJwtPictureUrl), jwt.flatMap(Jwt::getPictureUrl));
30+
assertEquals(Optional.of(testJwtEmail), jwt.flatMap(Jwt::getEmail));
31+
}
32+
33+
@Test
34+
void testBadJwtParse() {
35+
JwtParser parser = new JwtParser();
36+
Optional<Jwt> jwt = parser.fromJwt("fake jwt");
37+
assertTrue(jwt.isEmpty());
38+
}
39+
40+
@Test
41+
void testExtractBearerTokenPassesThrough() {
42+
JwtParser parser = mock(JwtParser.class);
43+
when(parser.fromAuthHeader(anyString())).thenCallRealMethod();
44+
45+
parser.fromAuthHeader("Bearer foobar");
46+
verify(parser).fromJwt("foobar");
47+
}
48+
49+
@Test
50+
void testExtractBearerTokenReturnsEmptyOnMalformed() {
51+
JwtParser parser = mock(JwtParser.class);
52+
when(parser.fromAuthHeader(anyString())).thenCallRealMethod();
53+
54+
assertEquals(Optional.empty(), parser.fromAuthHeader("Bad header"));
55+
verify(parser, times(0)).fromJwt(ArgumentMatchers.any());
56+
}
57+
}

0 commit comments

Comments
 (0)