Skip to content

Commit 3f1a2f6

Browse files
authored
Better JWT token processing (#733)
Signed-off-by: Dmitrii Tikhomirov <[email protected]>
1 parent e77b71a commit 3f1a2f6

File tree

7 files changed

+310
-17
lines changed

7 files changed

+310
-17
lines changed

impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/OAuth2AuthProvider.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,14 @@ public Builder build(
5252
@Override
5353
public void preRequest(
5454
Invocation.Builder builder, WorkflowContext workflow, TaskContext task, WorkflowModel model) {
55-
JWT token = requestBuilder.build(workflow, task, model).validateAndGet();
56-
String tokenType = (String) token.getClaim("typ");
55+
JWT jwt = requestBuilder.build(workflow, task, model).validateAndGet();
56+
String type =
57+
jwt.claim("typ", String.class)
58+
.map(String::trim)
59+
.filter(t -> !t.isEmpty())
60+
.orElseThrow(() -> new IllegalStateException("Token type is not present"));
61+
5762
builder.header(
58-
AuthProviderFactory.AUTH_HEADER_NAME,
59-
String.format(BEARER_TOKEN, tokenType, token.getToken()));
63+
AuthProviderFactory.AUTH_HEADER_NAME, String.format(BEARER_TOKEN, type, jwt.token()));
6064
}
6165
}

impl/http/src/main/java/io/serverlessworkflow/impl/executors/http/oauth/AccessTokenProvider.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,13 @@ public JWT validateAndGet() {
4747
Map<String, Object> token = tokenResponseHandler.apply(invocation, context);
4848
JWT jwt = jwtConverter.fromToken((String) token.get("access_token"));
4949
if (!(issuers == null || issuers.isEmpty())) {
50-
String tokenIssuer = (String) jwt.getClaim("iss");
51-
if (tokenIssuer == null || tokenIssuer.isEmpty() || !issuers.contains(tokenIssuer)) {
52-
throw new IllegalStateException("Token issuer is not valid: " + tokenIssuer);
53-
}
50+
jwt.issuer()
51+
.ifPresent(
52+
issuer -> {
53+
if (!issuers.contains(issuer)) {
54+
throw new IllegalStateException("Token issuer is not valid: " + issuer);
55+
}
56+
});
5457
}
5558
return jwt;
5659
}

impl/jwt-impl/pom.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@
2121
<groupId>com.fasterxml.jackson.core</groupId>
2222
<artifactId>jackson-databind</artifactId>
2323
</dependency>
24+
<dependency>
25+
<groupId>org.junit.jupiter</groupId>
26+
<artifactId>junit-jupiter-api</artifactId>
27+
<scope>test</scope>
28+
</dependency>
29+
<dependency>
30+
<groupId>org.junit.jupiter</groupId>
31+
<artifactId>junit-jupiter-engine</artifactId>
32+
<scope>test</scope>
33+
</dependency>
34+
<dependency>
35+
<groupId>org.junit.jupiter</groupId>
36+
<artifactId>junit-jupiter-params</artifactId>
37+
<scope>test</scope>
38+
</dependency>
39+
<dependency>
40+
<groupId>org.assertj</groupId>
41+
<artifactId>assertj-core</artifactId>
42+
<scope>test</scope>
43+
</dependency>
2444
</dependencies>
2545

2646
</project>

impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTConverter.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,24 @@ public class JacksonJWTConverter implements JWTConverter {
2929

3030
@Override
3131
public JWT fromToken(String token) throws IllegalArgumentException {
32+
if (token == null || token.isBlank()) {
33+
throw new IllegalArgumentException("JWT token must not be null or blank");
34+
}
35+
3236
String[] parts = token.split("\\.");
3337
if (parts.length < 2) {
3438
throw new IllegalArgumentException("Invalid JWT token format");
3539
}
3640
try {
41+
String headerJson =
42+
new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);
3743
String payloadJson =
3844
new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
39-
return new JacksonJWTImpl(token, MAPPER.readValue(payloadJson, Map.class));
45+
46+
Map<String, Object> header = MAPPER.readValue(headerJson, Map.class);
47+
Map<String, Object> claims = MAPPER.readValue(payloadJson, Map.class);
48+
49+
return new JacksonJWTImpl(token, header, claims);
4050
} catch (JsonProcessingException e) {
4151
throw new IllegalArgumentException("Failed to parse JWT token payload: " + e.getMessage(), e);
4252
}

impl/jwt-impl/src/main/java/io/serverlessworkflow/impl/http/jwt/JacksonJWTImpl.java

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,132 @@
1616
package io.serverlessworkflow.impl.http.jwt;
1717

1818
import io.serverlessworkflow.http.jwt.JWT;
19+
import java.time.Instant;
20+
import java.util.Arrays;
21+
import java.util.Collection;
22+
import java.util.Collections;
23+
import java.util.List;
1924
import java.util.Map;
25+
import java.util.Objects;
26+
import java.util.Optional;
2027

2128
public class JacksonJWTImpl implements JWT {
2229

30+
private final Map<String, Object> header;
2331
private final Map<String, Object> claims;
32+
2433
private final String token;
2534

26-
JacksonJWTImpl(String token, Map<String, Object> claims) {
35+
JacksonJWTImpl(String token, Map<String, Object> header, Map<String, Object> claims) {
36+
Objects.requireNonNull(token, "token");
37+
Objects.requireNonNull(header, "header");
38+
Objects.requireNonNull(claims, "claims");
2739
this.token = token;
28-
this.claims = claims;
40+
this.header = Collections.unmodifiableMap(header);
41+
this.claims = Collections.unmodifiableMap(claims);
2942
}
3043

3144
@Override
32-
public String getToken() {
45+
public String token() {
3346
return token;
3447
}
3548

3649
@Override
37-
public Object getClaim(String name) {
38-
if (claims == null || claims.isEmpty()) {
50+
public Map<String, Object> header() {
51+
return header;
52+
}
53+
54+
@Override
55+
public Map<String, Object> claims() {
56+
return claims;
57+
}
58+
59+
@Override
60+
public <T> Optional<T> claim(String name, Class<T> type) {
61+
if (name == null || type == null) return Optional.empty();
62+
Object value = claims.get(name);
63+
return type.isInstance(value) ? Optional.of(type.cast(value)) : Optional.empty();
64+
}
65+
66+
@Override
67+
public Optional<String> issuer() {
68+
return Optional.ofNullable(asString(claims.get("iss")));
69+
}
70+
71+
@Override
72+
public Optional<String> subject() {
73+
return Optional.ofNullable(asString(claims.get("sub")));
74+
}
75+
76+
@Override
77+
public List<String> audience() {
78+
Object aud = claims.get("aud");
79+
if (aud == null) {
80+
return List.of();
81+
} else if (aud instanceof String asString) {
82+
return List.of(asString);
83+
} else if (aud instanceof String[] asStringArray) {
84+
return Arrays.asList(asStringArray);
85+
}
86+
return ((Collection<?>) aud).stream().map(String::valueOf).toList();
87+
}
88+
89+
@Override
90+
public Optional<Instant> issuedAt() {
91+
return Optional.ofNullable(toInstant(claims.get("iat")));
92+
}
93+
94+
@Override
95+
public Optional<Instant> expiresAt() {
96+
return Optional.ofNullable(toInstant(claims.get("exp")));
97+
}
98+
99+
@Override
100+
public Optional<String> type() {
101+
if (header.containsKey("typ")) {
102+
return Optional.of(asString(header.get("typ")));
103+
}
104+
return Optional.ofNullable(asString(claims.get("typ")));
105+
}
106+
107+
private static Instant toInstant(Object v) {
108+
if (v == null) {
39109
return null;
40110
}
41-
return claims.get(name);
111+
if (v instanceof Instant i) {
112+
return i;
113+
}
114+
if (v instanceof Number n) {
115+
return Instant.ofEpochSecond(n.longValue());
116+
}
117+
if (v instanceof String s) {
118+
try {
119+
long sec = Long.parseLong(s.trim());
120+
return Instant.ofEpochSecond(sec);
121+
} catch (NumberFormatException ignored) {
122+
try {
123+
return Instant.parse(s.trim());
124+
} catch (Exception e) {
125+
throw new IllegalArgumentException("Cannot parse time claim: " + s, e);
126+
}
127+
}
128+
}
129+
throw new IllegalArgumentException("Unsupported time claim type: " + v.getClass());
130+
}
131+
132+
private static String asString(Object v) {
133+
return (v == null) ? null : String.valueOf(v).trim();
134+
}
135+
136+
@Override
137+
public boolean equals(Object o) {
138+
if (this == o) return true;
139+
if (!(o instanceof JacksonJWTImpl that)) return false;
140+
return Objects.equals(token, that.token);
141+
}
142+
143+
@Override
144+
public int hashCode() {
145+
return Objects.hash(token);
42146
}
43147
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright 2020-Present The Serverless Workflow Specification Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.serverlessworkflow.impl.http.jwt;
17+
18+
import static org.junit.jupiter.api.Assertions.*;
19+
20+
import io.serverlessworkflow.http.jwt.JWT;
21+
import java.time.Instant;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Optional;
25+
import org.junit.jupiter.api.Test;
26+
27+
public class JacksonJWTImplTest {
28+
29+
private static String JWT_TOKEN =
30+
"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJTY1c4RGI5RThtSnlvTUNqZHVtek5VR21FcG9MYURDb19ralZkVHJ2NDdVIn0.eyJleHAiOjE3NTYxNDgyNTcsImlhdCI6MTc1NjE0Nzk1NywianRpIjoiOWU4YzZjMWItZDBmZS00NGNhLThlOTgtNzNkZTY4OTdjYzE4IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDg4L3JlYWxtcy90ZXN0LXJlYWxtIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImExZDdlMWVlLTVmZjMtNDc3Yi1hMmRhLTNhNDZkYThlZmRkMSIsInR5cCI6IkJlYXJlciIsImF6cCI6InNlcnZlcmxlc3Mtd29ya2Zsb3ciLCJzZXNzaW9uX3N0YXRlIjoiNDdiYzIxZDEtYTMyYi00OGJlLWI2OWQtZDM5MjE5MjViZTlmIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwOi8vbG9jYWxob3N0OjgwODAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtdGVzdC1yZWFsbSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiI0N2JjMjFkMS1hMzJiLTQ4YmUtYjY5ZC1kMzkyMTkyNWJlOWYiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJEbWl0cmlpIFRpa2hvbWlyb3YiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2ZXJsZXNzLXdvcmtmbG93LXRlc3QiLCJnaXZlbl9uYW1lIjoiRG1pdHJpaSIsImZhbWlseV9uYW1lIjoiVGlraG9taXJvdiIsImVtYWlsIjoiYW55QGV4YW1wbGUub3JnIn0.bY9DqVt5jPcfsY5-tL4JbXXptnyFBBLLKuViBH3xQBXpHSmtXKK78BjBdUOcCAg6SMatdQ4XK13RJJNPpjPDCkuBeLDMwsdCrzKcfaUWY7JCMfam4gIiC989m_S8y8jtJovDst8sjIkn95f5izuuiAxRYw69_IcY__cfBw7k0OhL7Y_YXCcwwz7l2yrbplmpLiakTTuzhbiCER5EPohguy9BkYG_u2RDUZg0Rvy3EzbyVhTQQyfKRFjoAxTa1Se484n2lXqgMn1JHTZLwrgXAjcMDVaktktLb_cn276ygAeuqPj7dOsQGEoLR-8enRz1eZKBPgO70LNqGkkkyHiyOA";
31+
32+
private static JacksonJWTConverter converter = new JacksonJWTConverter();
33+
34+
@Test
35+
void tokenRoundTrip() {
36+
JWT jwt = converter.fromToken(JWT_TOKEN);
37+
assertEquals(JWT_TOKEN, jwt.token());
38+
}
39+
40+
@Test
41+
void headerAndType() {
42+
JWT jwt = converter.fromToken(JWT_TOKEN);
43+
44+
Map<String, Object> h = jwt.header();
45+
assertEquals("RS256", h.get("alg"));
46+
assertTrue(h.containsKey("kid"));
47+
assertEquals("JWT", h.get("typ"));
48+
49+
assertEquals(Optional.of("JWT"), jwt.type(), "type() must be a header.typ");
50+
}
51+
52+
@Test
53+
void payloadTypIsArbitraryClaim() {
54+
JWT jwt = converter.fromToken(JWT_TOKEN);
55+
56+
assertEquals(Optional.of("Bearer"), jwt.claim("typ", String.class));
57+
assertEquals("JWT", jwt.header().get("typ"));
58+
}
59+
60+
@Test
61+
void standardClaims() {
62+
JWT jwt = converter.fromToken(JWT_TOKEN);
63+
64+
assertEquals(Optional.of("http://localhost:8088/realms/test-realm"), jwt.issuer());
65+
assertEquals(Optional.of("a1d7e1ee-5ff3-477b-a2da-3a46da8efdd1"), jwt.subject());
66+
assertEquals(List.of("account"), jwt.audience());
67+
}
68+
69+
@Test
70+
void timeClaims() {
71+
JWT jwt = converter.fromToken(JWT_TOKEN);
72+
73+
assertEquals(Instant.ofEpochSecond(1756148257L), jwt.expiresAt().orElseThrow());
74+
assertEquals(Instant.ofEpochSecond(1756147957L), jwt.issuedAt().orElseThrow());
75+
}
76+
77+
@Test
78+
void typedClaimAccess() {
79+
JWT jwt = converter.fromToken(JWT_TOKEN);
80+
81+
assertEquals(Optional.of("[email protected]"), jwt.claim("email", String.class));
82+
assertEquals(Optional.of(false), jwt.claim("email_verified", Boolean.class));
83+
assertTrue(jwt.claim("nonexistent", String.class).isEmpty());
84+
assertTrue(jwt.claim("email_verified", String.class).isEmpty());
85+
}
86+
87+
@Test
88+
void mapsAreUnmodifiable() {
89+
JWT jwt = converter.fromToken(JWT_TOKEN);
90+
91+
assertThrows(UnsupportedOperationException.class, () -> jwt.claims().put("x", 1));
92+
assertThrows(UnsupportedOperationException.class, () -> jwt.header().put("x", 1));
93+
}
94+
95+
@Test
96+
void nullToken() {
97+
assertThrows(IllegalArgumentException.class, () -> converter.fromToken(null));
98+
}
99+
100+
@Test
101+
void blankToken() {
102+
assertThrows(IllegalArgumentException.class, () -> converter.fromToken(" "));
103+
}
104+
105+
@Test
106+
void wrongPartsCount() {
107+
assertThrows(IllegalArgumentException.class, () -> converter.fromToken("a.b"));
108+
assertThrows(IllegalArgumentException.class, () -> converter.fromToken("a.b.c.d"));
109+
}
110+
111+
@Test
112+
void badBase64Header() {
113+
String bad = "###." + JWT_TOKEN.split("\\.")[1] + "." + JWT_TOKEN.split("\\.")[2];
114+
assertThrows(IllegalArgumentException.class, () -> converter.fromToken(bad));
115+
}
116+
117+
@Test
118+
void badBase64Payload() {
119+
String[] p = JWT_TOKEN.split("\\.");
120+
String bad = p[0] + ".###." + p[2];
121+
assertThrows(IllegalArgumentException.class, () -> converter.fromToken(bad));
122+
}
123+
124+
@Test
125+
void badJson() {
126+
String[] p = JWT_TOKEN.split("\\.");
127+
String notJsonB64url = "bm90LWpzb24";
128+
String bad = p[0] + "." + notJsonB64url + "." + p[2];
129+
assertThrows(IllegalArgumentException.class, () -> converter.fromToken(bad));
130+
}
131+
}

impl/jwt/src/main/java/io/serverlessworkflow/http/jwt/JWT.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,30 @@
1515
*/
1616
package io.serverlessworkflow.http.jwt;
1717

18+
import java.time.Instant;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.Optional;
22+
1823
public interface JWT {
1924

20-
String getToken();
25+
String token();
26+
27+
List<String> audience();
28+
29+
Map<String, Object> claims();
30+
31+
<T> Optional<T> claim(String name, Class<T> type);
32+
33+
Optional<Instant> expiresAt();
34+
35+
Map<String, Object> header();
36+
37+
Optional<Instant> issuedAt();
38+
39+
Optional<String> issuer();
40+
41+
Optional<String> subject();
2142

22-
Object getClaim(String name);
43+
Optional<String> type();
2344
}

0 commit comments

Comments
 (0)