Skip to content

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

Merged
merged 1 commit into from
Sep 7, 2018
Merged

Jwt Claim Mapping #5753

merged 1 commit into from
Sep 7, 2018

Conversation

jzheaux
Copy link
Contributor

@jzheaux jzheaux commented Aug 28, 2018

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

@jzheaux
Copy link
Contributor Author

jzheaux commented Aug 28, 2018

@rwinch What do you think of this as an API for doing claims mapping? I like it because it feels similar to JwtAuthenticationConverter.

A little unsure about the name, but I didn't like JwtBuilder since it doesn't really follow the builder pattern (just like JwtAuthenticationConverter doesn't).

* @author Josh Cummings
* @since 5.1
*/
public class JwtCreator {
Copy link
Member

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.

@jzheaux
Copy link
Contributor Author

jzheaux commented Sep 4, 2018

@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):

  • Pro: It is flexible enough to handle any claim, including custom ones.
  • Con: We commit to a constructor that presumes a mapping approach. I don't really know another way around this other than to expose a setter method for each claim.
  • Con: We lose the ability to communicate a type contract for each of the claims, meaning if they performed an incompatible conversion, they would find out at runtime instead of compile time.

And with the sub-class approach (JwtClaimSetConverter):

  • Pro: The contract is clear.
  • Con: We expose a larger API. However, we can expose these passively on an as-needed basis.
  • Con: It doesn't handle custom claims, though I would imagine that a user could easily use a delegate pattern to chain converters, the first being the standard one and the second being their custom claims (and custom implementation of Converter).

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()) {
Copy link
Contributor Author

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 {
Copy link
Contributor Author

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) {
Copy link
Contributor Author

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).

@jzheaux jzheaux force-pushed the gh-5223 branch 4 times, most recently from 3011097 to 479f5af Compare September 5, 2018 23:30
@jzheaux jzheaux removed the wip label Sep 5, 2018
@jzheaux
Copy link
Contributor Author

jzheaux commented Sep 5, 2018

@rwinch This is ready for your review

@jzheaux jzheaux force-pushed the gh-5223 branch 2 times, most recently from 02cd97f to 70f2aeb Compare September 5, 2018 23:58
@@ -907,7 +909,7 @@ public void requestWhenClockSkewSetButJwtStillTooLateThenReportsExpired()
.andExpect(invalidTokenHeader("Jwt expired at"));
}

// -- converter
// -- jwt authentication converter
Copy link
Member

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

Copy link
Contributor Author

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) {
Copy link
Member

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

Copy link
Member

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?

Copy link
Contributor Author

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 {
Copy link
Member

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
Copy link
Member

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);
Copy link
Member

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

Copy link
Contributor Author

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.

@jzheaux
Copy link
Contributor Author

jzheaux commented Sep 6, 2018

@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);
Copy link
Contributor Author

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> {
Copy link
Member

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");
Copy link
Member

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");
Copy link
Member

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");
Copy link
Member

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);
Copy link
Member

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
Copy link
Member

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());
Copy link
Member

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());
Copy link
Member

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) {
Copy link
Member

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> {
Copy link
Member

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.

@jzheaux jzheaux force-pushed the gh-5223 branch 2 times, most recently from 6cacbfb to d1820d2 Compare September 6, 2018 20:34
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
@jzheaux
Copy link
Contributor Author

jzheaux commented Sep 6, 2018

Awesome, @rwinch, thanks for the feedback. I've responded with code changes.

@rwinch rwinch self-assigned this Sep 7, 2018
@rwinch rwinch added status: duplicate A duplicate of another issue in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) labels Sep 7, 2018
@rwinch rwinch added this to the 5.1.0.RC2 milestone Sep 7, 2018
@rwinch rwinch merged commit 9e0f171 into spring-projects:master Sep 7, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: duplicate A duplicate of another issue
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants