Skip to content

Commit 1acdbbe

Browse files
committed
Jwt Claim Mapping
This introduces a hook for users to customize standard Jwt Claim values in cases where the JWT issuer isn't spec compliant or where the user needs to add or remove claims. Fixes: gh-5223
1 parent 057587e commit 1acdbbe

File tree

4 files changed

+510
-14
lines changed

4 files changed

+510
-14
lines changed
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*
2+
* Copyright 2002-2018 the original author or 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+
17+
package org.springframework.security.oauth2.jwt;
18+
19+
import java.net.MalformedURLException;
20+
import java.net.URI;
21+
import java.net.URL;
22+
import java.time.Instant;
23+
import java.util.Arrays;
24+
import java.util.Collection;
25+
import java.util.Date;
26+
import java.util.HashMap;
27+
import java.util.Map;
28+
import java.util.Objects;
29+
import java.util.stream.Collectors;
30+
31+
import org.springframework.core.convert.converter.Converter;
32+
import org.springframework.util.Assert;
33+
34+
/**
35+
* Converts a JWT claim set, claim by claim. Can be configured with custom converters
36+
* by claim name.
37+
*
38+
* @author Josh Cummings
39+
* @since 5.1
40+
*/
41+
public final class MappedJwtClaimSetConverter
42+
implements Converter<Map<String, Object>, Map<String, Object>> {
43+
44+
private static final Converter<Object, Collection<String>> AUDIENCE_CONVERTER = new AudienceConverter();
45+
private static final Converter<Object, URL> ISSUER_CONVERTER = new IssuerConverter();
46+
private static final Converter<Object, String> STRING_CONVERTER = new StringConverter();
47+
private static final Converter<Object, Instant> TEMPORAL_CONVERTER = new InstantConverter();
48+
49+
private final Map<String, Converter<Object, ?>> claimConverters;
50+
51+
/**
52+
* Constructs a {@link MappedJwtClaimSetConverter} with the provided arguments
53+
*
54+
* This will completely replace any set of default converters.
55+
*
56+
* @param claimConverters The {@link Map} of converters to use
57+
*/
58+
public MappedJwtClaimSetConverter(Map<String, Converter<Object, ?>> claimConverters) {
59+
Assert.notNull(claimConverters, "claimConverters cannot be null");
60+
this.claimConverters = new HashMap<>(claimConverters);
61+
}
62+
63+
/**
64+
* Construct a {@link MappedJwtClaimSetConverter}, overriding individual claim
65+
* converters with the provided {@link Map} of {@link Converter}s.
66+
*
67+
* For example, the following would give an instance that is configured with only the default
68+
* claim converters:
69+
*
70+
* <pre>
71+
* MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());
72+
* </pre>
73+
*
74+
* Or, the following would supply a custom converter for the subject, leaving the other defaults
75+
* in place:
76+
*
77+
* <pre>
78+
* MappedJwtClaimsSetConverter.withDefaults(
79+
* Collections.singletonMap(JwtClaimNames.SUB, new UserDetailsServiceJwtSubjectConverter()));
80+
* </pre>
81+
*
82+
* To completely replace the underlying {@link Map} of converters, {@see MappedJwtClaimSetConverter(Map)}.
83+
*
84+
* @param claimConverters
85+
* @return An instance of {@link MappedJwtClaimSetConverter} that contains the converters provided,
86+
* plus any defaults that were not overridden.
87+
*/
88+
public static MappedJwtClaimSetConverter withDefaults
89+
(Map<String, Converter<Object, ?>> claimConverters) {
90+
Assert.notNull(claimConverters, "claimConverters cannot be null");
91+
92+
Map<String, Converter<Object, ?>> claimNameToConverter = new HashMap<>();
93+
claimNameToConverter.put(JwtClaimNames.AUD, AUDIENCE_CONVERTER);
94+
claimNameToConverter.put(JwtClaimNames.EXP, TEMPORAL_CONVERTER);
95+
claimNameToConverter.put(JwtClaimNames.IAT, TEMPORAL_CONVERTER);
96+
claimNameToConverter.put(JwtClaimNames.ISS, ISSUER_CONVERTER);
97+
claimNameToConverter.put(JwtClaimNames.JTI, STRING_CONVERTER);
98+
claimNameToConverter.put(JwtClaimNames.NBF, TEMPORAL_CONVERTER);
99+
claimNameToConverter.put(JwtClaimNames.SUB, STRING_CONVERTER);
100+
claimNameToConverter.putAll(claimConverters);
101+
102+
return new MappedJwtClaimSetConverter(claimNameToConverter);
103+
}
104+
105+
/**
106+
* {@inheritDoc}
107+
*/
108+
@Override
109+
public Map<String, Object> convert(Map<String, Object> claims) {
110+
Assert.notNull(claims, "claims cannot be null");
111+
112+
Map<String, Object> mappedClaims = new HashMap<>(claims);
113+
114+
for (Map.Entry<String, Converter<Object, ?>> entry : this.claimConverters.entrySet()) {
115+
String claimName = entry.getKey();
116+
Converter<Object, ?> converter = entry.getValue();
117+
if (converter != null) {
118+
Object claim = claims.get(claimName);
119+
Object mappedClaim = converter.convert(claim);
120+
mappedClaims.compute(claimName, (key, value) -> mappedClaim);
121+
}
122+
}
123+
124+
Instant issuedAt = (Instant) mappedClaims.get(JwtClaimNames.IAT);
125+
Instant expiresAt = (Instant) mappedClaims.get(JwtClaimNames.EXP);
126+
if (issuedAt == null && expiresAt != null) {
127+
mappedClaims.put(JwtClaimNames.IAT, expiresAt.minusSeconds(1));
128+
}
129+
130+
return mappedClaims;
131+
}
132+
133+
/**
134+
* Coerces an <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-4.1.3">Audience</a> claim
135+
* into a {@link Collection<String>}, ignoring null values, and throwing an error if its coercion efforts fail.
136+
*/
137+
private static class AudienceConverter implements Converter<Object, Collection<String>> {
138+
139+
@Override
140+
public Collection<String> convert(Object source) {
141+
if (source == null) {
142+
return null;
143+
}
144+
145+
if (source instanceof Collection) {
146+
return ((Collection<?>) source).stream()
147+
.filter(Objects::nonNull)
148+
.map(Objects::toString)
149+
.collect(Collectors.toList());
150+
}
151+
152+
return Arrays.asList(source.toString());
153+
}
154+
}
155+
156+
/**
157+
* Coerces an <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-4.1.1">Issuer</a> claim
158+
* into a {@link URL}, ignoring null values, and throwing an error if its coercion efforts fail.
159+
*/
160+
private static class IssuerConverter implements Converter<Object, URL> {
161+
162+
@Override
163+
public URL convert(Object source) {
164+
if (source == null) {
165+
return null;
166+
}
167+
168+
if (source instanceof URL) {
169+
return (URL) source;
170+
}
171+
172+
if (source instanceof URI) {
173+
return toUrl((URI) source);
174+
}
175+
176+
return toUrl(source.toString());
177+
}
178+
179+
private URL toUrl(URI source) {
180+
try {
181+
return source.toURL();
182+
} catch (MalformedURLException e) {
183+
throw new IllegalStateException("Could not coerce " + source + " into a URL", e);
184+
}
185+
}
186+
187+
private URL toUrl(String source) {
188+
try {
189+
return new URL(source);
190+
} catch (MalformedURLException e) {
191+
throw new IllegalStateException("Could not coerce " + source + " into a URL", e);
192+
}
193+
}
194+
}
195+
196+
/**
197+
* Coerces a claim into an {@link Instant}, ignoring null values, and throwing an error
198+
* if its coercion efforts fail.
199+
*/
200+
private static class InstantConverter implements Converter<Object, Instant> {
201+
@Override
202+
public Instant convert(Object source) {
203+
if (source == null) {
204+
return null;
205+
}
206+
207+
if (source instanceof Instant) {
208+
return (Instant) source;
209+
}
210+
211+
if (source instanceof Date) {
212+
return ((Date) source).toInstant();
213+
}
214+
215+
if (source instanceof Number) {
216+
return Instant.ofEpochSecond(((Number) source).longValue());
217+
}
218+
219+
try {
220+
return Instant.ofEpochSecond(Long.parseLong(source.toString()));
221+
} catch (Exception e) {
222+
throw new IllegalStateException("Could not coerce " + source + " into an Instant", e);
223+
}
224+
}
225+
}
226+
227+
/**
228+
* Coerces a claim into a {@link String}, ignoring null values, and throwing an error if its
229+
* coercion efforts fail.
230+
*/
231+
private static class StringConverter implements Converter<Object, String> {
232+
@Override
233+
public String convert(Object source) {
234+
if (source == null) {
235+
return null;
236+
}
237+
238+
return source.toString();
239+
}
240+
}
241+
}

oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupport.java

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
4141
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
4242

43+
import org.springframework.core.convert.converter.Converter;
4344
import org.springframework.http.HttpHeaders;
4445
import org.springframework.http.HttpMethod;
4546
import org.springframework.http.MediaType;
@@ -78,8 +79,11 @@ public final class NimbusJwtDecoderJwkSupport implements JwtDecoder {
7879
private final ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
7980
private final RestOperationsResourceRetriever jwkSetRetriever = new RestOperationsResourceRetriever();
8081

82+
private Converter<Map<String, Object>, Map<String, Object>> claimSetConverter =
83+
MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());
8184
private OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefault();
8285

86+
8387
/**
8488
* Constructs a {@code NimbusJwtDecoderJwkSupport} using the provided parameters.
8589
*
@@ -134,6 +138,16 @@ public void setJwtValidator(OAuth2TokenValidator<Jwt> jwtValidator) {
134138
this.jwtValidator = jwtValidator;
135139
}
136140

141+
/**
142+
* Use the following {@link Converter} for manipulating the JWT's claim set
143+
*
144+
* @param claimSetConverter the {@link Converter} to use
145+
*/
146+
public final void setClaimSetConverter(Converter<Map<String, Object>, Map<String, Object>> claimSetConverter) {
147+
Assert.notNull(claimSetConverter, "claimSetConverter cannot be null");
148+
this.claimSetConverter = claimSetConverter;
149+
}
150+
137151
private JWT parse(String token) {
138152
try {
139153
return JWTParser.parse(token);
@@ -149,22 +163,12 @@ private Jwt createJwt(String token, JWT parsedJwt) {
149163
// Verify the signature
150164
JWTClaimsSet jwtClaimsSet = this.jwtProcessor.process(parsedJwt, null);
151165

152-
Instant expiresAt = null;
153-
if (jwtClaimsSet.getExpirationTime() != null) {
154-
expiresAt = jwtClaimsSet.getExpirationTime().toInstant();
155-
}
156-
Instant issuedAt = null;
157-
if (jwtClaimsSet.getIssueTime() != null) {
158-
issuedAt = jwtClaimsSet.getIssueTime().toInstant();
159-
} else if (expiresAt != null) {
160-
// Default to expiresAt - 1 second
161-
issuedAt = Instant.from(expiresAt).minusSeconds(1);
162-
}
163-
164166
Map<String, Object> headers = new LinkedHashMap<>(parsedJwt.getHeader().toJSONObject());
167+
Map<String, Object> claims = this.claimSetConverter.convert(jwtClaimsSet.getClaims());
165168

166-
jwt = new Jwt(token, issuedAt, expiresAt, headers, jwtClaimsSet.getClaims());
167-
169+
Instant expiresAt = (Instant) claims.get(JwtClaimNames.EXP);
170+
Instant issuedAt = (Instant) claims.get(JwtClaimNames.IAT);
171+
jwt = new Jwt(token, issuedAt, expiresAt, headers, claims);
168172
} catch (RemoteKeySourceException ex) {
169173
if (ex.getCause() instanceof ParseException) {
170174
throw new JwtException(String.format(DECODING_ERROR_MESSAGE_TEMPLATE, "Malformed Jwk set"));

0 commit comments

Comments
 (0)