-
Notifications
You must be signed in to change notification settings - Fork 6.1k
Jwt Claim Mapping #5753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Jwt Claim Mapping #5753
Conversation
@rwinch What do you think of this as an API for doing claims mapping? I like it because it feels similar to A little unsure about the name, but I didn't like |
* @author Josh Cummings | ||
* @since 5.1 | ||
*/ | ||
public class JwtCreator { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I"m not very keen on using a class for conversion because the whole point is to allow the user to customize the conversion which means they will likely want a new implementation. For that reason, our main hook should be an interface.
I don't like the idea of introducing a custom interface just for this either.
Perhaps the basis could be Converter<Jtw, Jwt> such that we take in the default Jwt but then they can convert it into one that has converted types on headers or claims that they want.
@rwinch I've implemented two different approaches here, just continuing the conversation we were having on slack. Here are the tradeoffs I see with the mapped approach (MappedJwtClaimSetConverter):
And with the sub-class approach (JwtClaimSetConverter):
Personally, I lean slightly towards JwtClaimSetConverter because I like the type safety, and I also think that the API is simple enough that I'm not worried about exposing a method per standard claim. I also find the mapped converter a bit abstract for my taste. |
Map<String, Object> mappedClaims = new HashMap<>(source); | ||
JwtClaimAccessor claimAccessor = () -> mappedClaims; | ||
|
||
for (Map.Entry<String, Converter<JwtClaimAccessor, ?>> entry : this.attributeConverters.entrySet()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Iterating through the converters instead of the claims because this allows converters to add and remove values as well as convert them.
return mappedClaims; | ||
} | ||
|
||
public static class ConverterEntry { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I liked this because it made the constructor very simple. I also find working with var args much simpler than having to build a map and then pass it in.
private class IssuedAtConverter implements Converter<JwtClaimAccessor, Instant> { | ||
|
||
@Override | ||
public Instant convert(JwtClaimAccessor claimAccessor) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is necessary so that we continue to convert issuedAt in the same way we have currently released (e.g. falling back to expiresAt).
3011097
to
479f5af
Compare
@rwinch This is ready for your review |
02cd97f
to
70f2aeb
Compare
@@ -907,7 +909,7 @@ public void requestWhenClockSkewSetButJwtStillTooLateThenReportsExpired() | |||
.andExpect(invalidTokenHeader("Jwt expired at")); | |||
} | |||
|
|||
// -- converter | |||
// -- jwt authentication converter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests shouldn't be added because they are much slower than unit tests and they do not have anything to do with Java Configuration. We should only have tests added to NimbusJwtDecoderJwkSupport with a Mock converter and tests in MappedJwtClaimSetConverterTests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just curious, would these be appropriate here if they were fast and didn't use a mock web server? A number of the tests in this class are there for user demonstration and also verification of a documentable feature.
Adding or changing a claim do not seem like corner cases to me, and so where would we place a test that verifies this configuration combination (or why would we not want to verify it)?
Would it be appropriate to create a ticket to add these tests after we have a NimbusJwtDecoder implementation that can easily mock the Nimbus JwtProcessor (thus making these tests fast and not require a mock web server)?
* plus any defaults that were not overridden. | ||
*/ | ||
public static MappedJwtClaimSetConverter withDefaults | ||
(Map<String, Converter<Map<String, Object>, ?>> attributeConverters) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer to name a map based upon the key and value. For example, claimIdToConverter
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had imagined the converters to operate on an individual key value pair. By doing this, we could have a standard Instant converter, List converter, etc. Is there a reason/benefit to having it operate on the entire Map
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason that they take a Map (the claim set) is so that the implementer can access all attributes in making their conversion decision. Taking a Map doesn't prevent having a standard Instant converter, etc., unless I misunderstand your thinking. The PR has TemporalConverter and StringConverter already, for example. Are these less than ideal, and if so, why?
To connect this to another comment that you made (apologies for any noise, but I believe they go together), I think that the decision to have issuedAt fall back to one second before expiresAt is implementation-specific and likely to be overridden. As a user, I would personally prefer to override this to simply accept and convert whatever issuedAt is, and so it's isolated to the conversion of that property.
Given that, an example of where having the entire map available is with issuedAt where we fall back to expiresAt, and thus need to look up expiresAt to correctly derive issuedAt. (See my later comments for more discussion on this specific matter. I only include it here, too, to hopefully make it easier to tie the two points together.)
I agree that converters that simply take and return a single attribute would be simpler. What are your thoughts on future passivity if converters need to look at more than one attribute to make their conversion decision? (Presuming hypothetically that we can find a more elegant way to allow users to override our defaulting of issuedAt, removing the immediate need for this requirement).
As a side note, these originally looked something like this:
withDefaults(Map<String, Converter<JwtClaimAccessor, ?>> overrides) {
// ...
withDefaults.put(JwtClaimNames.SUB, accessor -> accessor.getSubject());
withDefaults.put(JwtClaimNames.JTI, accessor -> accessor.getId());
// ...
}
which may be something still considering, though I think we'd still want the conversion logic to reside here in the converter, even if we use JwtClaimAccessor as the conversion source.
} | ||
} | ||
|
||
private static class IssuedAtConverter extends TemporalConverter { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Favor composition to inheritance since Java only lets you inherit from a single class. Instead the temporal converter could accept an actual value (instead of the entire Map).
return expiresAt.minusSeconds(1); | ||
} | ||
|
||
public void setAttributeConverters |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should likely be a constructor argument since is it is required. A few alternatives to consider is injecting only the required converter or perhaps the converted value.
return issuedAt; | ||
} | ||
|
||
Instant expiresAt = getExpiresAt(source); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really view this as something distinct from conversion of the attributes. It is really defaulting the value. I think it might be best to place this in MappedJwtClaimSetConverter
directly. This will also allow simplification of the API as suggested above
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that this is more about defaults than conversion. I also think that we should focus just on conversion and not concern ourselves with defaulting properties in the first place. My motive, then, for placing this logic in the converter is to make it easy for users to override this defaulting.
Since this has the potential to change the contract (as discussed above), I'd like to hear your thoughts on other ways that users could easily override this defaulting of issuedAt. My concern is that I cannot do the fallback before conversion since it includes date arithmetic. And so, if it is after the set of conversions, then the user needs to completely reimplement the converter (or the JwtDecoder) in order to remove that defaulting.
@rwinch I simplified the contract to focus on individual attributes. If a user needs the entire map to make a decision, they can just wire an additional converter that delegates to this one and then does whatever other work it needs to do. Let me know what you think. |
} | ||
} | ||
|
||
Instant issuedAt = (Instant) mappedClaims.get(JwtClaimNames.IAT); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Separate ticket maybe, but I like the idea of also having IssuedAtDefaultingClaimSetConverter which delegates to MappedJwtSetConverter and performs these steps on the map, e.g.:
public class IssuedAtDefaultingClaimSetConverter implements Converter<...> {
private final Conveter<...> delegate = MappedJwtClaimSetConverter.withDefaults(...);
public Map<String, Object> convert(Map<String, Object> claims) {
Map<String, Object> mappedClaims = this.delegate.convert(claims);
Instant issuedAt = (Instant) mappedClaims.get(JwtClaimNames.IAT);
Instant expiresAt = (Instant) mappedClaims.get(JwtClaimNames.EXP);
if (issuedAt == null && expiresAt != null) {
mappedClaims.put(JwtClaimNames.IAT, expiresAt.minusSeconds(1));
}
return mappedClaims;
}
This way, users can easily do
Converter<...> converter = MappedJwtClaimSetConverter.withDefaults(...);
NimbusJwtDecoder#setClaimSetConverter(converter);
} | ||
} | ||
|
||
private static class StringConverter implements Converter<Object, String> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aren't the inputs almost always a String anyways? Is this necessary?
try { | ||
return issuer.toURL(); | ||
} catch (MalformedURLException e) { | ||
throw new IllegalStateException("Could not coerce iss value to URL"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please include the value in the exception message
try { | ||
return new URL(issuer); | ||
} catch (MalformedURLException e) { | ||
throw new IllegalStateException("Could not coerce iss value to URL"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please include the value in the error message
try { | ||
return Instant.ofEpochSecond(Long.parseLong(temporal.toString())); | ||
} catch (Exception e) { | ||
throw new IllegalStateException("Could not coerce this value into Instant"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please include the value and the caused by
*/ | ||
public MappedJwtClaimSetConverter(Map<String, Converter<Object, ?>> attributeConverters) { | ||
Assert.notNull(attributeConverters, "attributeConverters cannot be null"); | ||
this.attributeConverters.putAll(attributeConverters); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer to make this more explicit that the attributeConverters does not contain any values already and keep the initialization logic in one place. Please change this to new HashMap<>(attributeConverters)
import org.springframework.util.Assert; | ||
|
||
/** | ||
* Converts a JWT claim set attribute by attribute. Can be configured with custom converters |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
by attribute
to
by attribute name.
Map<String, Object> mappedClaims = new HashMap<>(claims); | ||
|
||
for (Map.Entry<String, Converter<Object, ?>> entry : this.attributeConverters.entrySet()) { | ||
Converter<Object, ?> converter = this.attributeConverters.get(entry.getKey()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can get the converter using entry.getValue()
which will be slightly better performance.
Map<String, Object> mappedClaims = new HashMap<>(claims); | ||
|
||
for (Map.Entry<String, Converter<Object, ?>> entry : this.attributeConverters.entrySet()) { | ||
Converter<Object, ?> converter = this.attributeConverters.get(entry.getKey()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
entry.getKey() is used in multiple places and is not very readable. Please assign to something like attributeName
} | ||
} | ||
|
||
private void put(Map<String, Object> map, String key, Object value) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove this and use Map.compute directly
} | ||
} | ||
|
||
private static class TemporalConverter implements Converter<Object, Instant> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason this is called TemporalConverter? It converts to an Instant which is what the other converters are named after.
6cacbfb
to
d1820d2
Compare
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: spring-projectsgh-5223
Awesome, @rwinch, thanks for the feedback. I've responded with code changes. |
This introduces a hook for users to customize standard Jwt Claim
values in cases where the JWT issuer isn't spec compliant.
Fixes: gh-5223