Skip to content

Externalize coercion in ClaimAccessor #6799

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

Closed

Conversation

jgrandja
Copy link
Contributor

Fixes gh-6245

@jgrandja jgrandja requested review from jzheaux and rwinch April 18, 2019 19:39
Map<String, Object> source = new HashMap<>();
source.put(JwtClaimNames.EXP, exp);
Copy link
Contributor Author

@jgrandja jgrandja Apr 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jzheaux The original test did not pass in any claims but the resulting claim set added iss claim. This does not seem right. If no claims are passed in than I would expect nothing to happen. See #6800

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. Did you mean the iat claim? This was to remain backward compatible with NimbusJwtDecoderJwkSupport, though there are perhaps other ways to achieve this.

@@ -140,30 +138,6 @@ public void convertWhenUsingCustomConverterThenAllOtherDefaultsAreStillUsed() th
assertThat(target.get(JwtClaimNames.SUB)).isEqualTo("1234");
}

@Test
public void convertWhenConverterReturnsNullThenClaimIsRemoved() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jzheaux I removed this test as described in #6800

}

@Test
public void convertWhenConverterReturnsValueWhenEntryIsMissingThenEntryIsAdded() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jzheaux I removed this test as described in #6800

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not keen on removing these test until we come to an agreement on #6800. This PR isn't about changing how MappedJwtClaimSetConverter behaves.

Copy link
Contributor

@jzheaux jzheaux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glad to see us taking this step forward! I've left some feedback inline.

*
* @return an {@code Object} to {@code String} {@link Converter}
*/
public static Converter<Object, String> stringConverter() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts around

public static final Converter<Object, String> STRING_CONVERTER = ObjectConverters::asString

It seems like a constant lookup would perform faster than a method invocation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like a constant lookup would perform faster than a method invocation

Not sure about this? Do you think the static method approach would be too slow?

Also keep in mind that this follows the same pattern as RsaKeyConverters.pkcs8() and RsaKeyConverters.x509 and being consistent is key.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason in RsaKeyConverters is so that each gets its own instance of KeyFactory, but I didn't see any state in the ObjectConverters.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a bit of a tradeoff. Holding static values improves performance a bit as it won't need to be garbage collected. However, it cannot be garbage collected when there continues to be a reference to it, so that means there is additional memory overhead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rwinch It's not clear to me whether you are requesting changes here? If you are, can you please provide more detail? With the current design, garbage collection won't happen after the class is loaded. For example, using stringConverter() as an example, which is static and it references the static method asString. Are you suggesting that stringConverter() be changed to non-static, more like a factory method?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't really suggest one way or the other, but pointed out the tradeoffs.

With the current design, garbage collection won't happen after the class is loaded.

It will happen every time stringConverter() is used and the reference is no longer held. For example, here GC could happen as soon as foo() completed:

private void foo() {
    Converter<Object, String> toString = stringConverter();
    ...
}

If foo() is invoked a lot then there is going to be a lot of GC that can happen.

On the flip side ObjectConverters creates static members and returns the same instance, there would not be GC happening and multiple invocations of stringConverter() would return the same object. This is a plus. However, the static values will never be GC'd which is a tradeoff if we anticipate the values only being used at startup.

Given this is likely to be used outside of startup, I would suggest using the static members as @jzheaux suggested.

@@ -49,7 +46,7 @@
*/
default Boolean containsClaim(String claim) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that these are converted up front, what are your thoughts on adding a method:

default <T> T getClaim(String claim)

It'd be really nice for users to be able to do something like:

List<String> scopes = jwt.getClaim("scope");

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'm not sure how we would implement it with the latest implementation that uses a ClaimConversionService. We would need to supply the targetType using ClaimConversionService.getSharedInstance().convert(claimValue, targetType), which must be captured via the return type of caller. Off the top of my head, I'm not aware how we can capture the generic return type? If it's even possible?

Either way, this would be a feature enhancement as it's not required for this PR. Feel free to log a ticket if you would like this enhancement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jgrandja I think this can be a separate issue. However, I agree that this would be a nice feature.

Correct me if I'm wrong @jzheaux but I think the idea would be that it presumes it is already converted to the type. No conversion is done when getClaim is invoked.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that it can be a separate issue. Yes, @rwinch this would simply do:

default <T> T getClaim(String claim) {
    return (T) this.getClaims().get(claim);
}

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 now understand what you are suggesting @jzheaux. As @rwinch mentioned

the idea would be that it presumes it is already converted to the type

I agree that this would be a nice improvement as long as the user is aware that the claim has already been converted otherwise a ClassCastException will occur.

I'll add a new ticket for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #6947

((List<?>) this.getClaims().get(claim)).forEach(e -> claimValues.add(e.toString()));
return claimValues;
Object claimValue = getClaims().get(claim);
List<String> convertedValue = ObjectConverters.listStringConverter().convert(claimValue);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Optional is just a bit more readable in situations like these:

return Optional.ofNullable(ObjectConverters.listStringConverter().convert(claimValue))
        .orElseThrow(() -> new IllegalArgumentException...

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 think this is a matter of preference. The current implementation is easily readable as well and it has the benefit of not creating an extra object (Optional) as part of the check.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this is a matter of preference. Personally, I find Optional harder to read. For me this is especially the case when doing more than one thing on a single line. It is unfortunate, but this often happens because the way IDEs try to format the code with method chaining.

Note that using Optional throughout the code base adds quite a bit of object creation which means it can impact GC.

Map<String, Object> source = new HashMap<>();
source.put(JwtClaimNames.EXP, exp);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. Did you mean the iat claim? This was to remain backward compatible with NimbusJwtDecoderJwkSupport, though there are perhaps other ways to achieve this.

}

@Test
public void convertWhenConverterReturnsValueWhenEntryIsMissingThenEntryIsAdded() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not keen on removing these test until we come to an agreement on #6800. This PR isn't about changing how MappedJwtClaimSetConverter behaves.

@@ -819,56 +817,6 @@ JwtDecoder jwtDecoder() {
```
This will keep all the defaults, except it will override the default claim converter for `sub`.

[[oauth2resourceserver-claimsetmapping-add]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem to me that this PR is about changing how MappedClaimSetConverter works. We should defer changing its documentation to maybe #6800.

* @since 5.2
* @see Converter
*/
public final class ObjectConverters {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we looked into using built in Spring types (i.e. ConversionService)? It seems we should probably try to use that over writing the conversion ourselves.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I did originally check Converter impl's in framework when I first wrote ClaimAccessor but did not find anything that met the needs. I will check once again to make sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rwinch I checked again and the reason I did not reuse Converter(s) in framework is because most (if not all) Converter implementations in org.springframework.core.convert.support are package-private.

Also, I don't feel it's necessary to reuse ConversionService (or GenericConversionService) as it provides additional API's that I won't use so I'd like to keep things as simple as possible.

Copy link
Member

@rwinch rwinch May 7, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry about adding these converters as we will likely need to support more complex things like timestamps that already have support for conversion. What's more is this is rather general, so it means that as we need more types of conversions we need to support them directly vs relying on ConversionService. Perhaps we could still leverage ConversionService, but the API itself accept a Converter that was adapted from ConversionService

*
* @return an {@code Object} to {@code String} {@link Converter}
*/
public static Converter<Object, String> stringConverter() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a bit of a tradeoff. Holding static values improves performance a bit as it won't need to be garbage collected. However, it cannot be garbage collected when there continues to be a reference to it, so that means there is additional memory overhead.

@jgrandja jgrandja force-pushed the gh-6245-claim-accessor branch from f112b72 to bc5c91d Compare May 12, 2019 23:56
@jgrandja
Copy link
Contributor Author

@rwinch @jzheaux I applied all changes and it's ready for next review. FYI, I force pushed as I had to remove code related to #6800 that should be excluded. @rwinch I also made use of ConversionService via the new ClaimConversionService and removed ObjectConverters. Thanks for persisting on that as this is a much better solution.

Copy link
Member

@rwinch rwinch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commented inline. I'm not sure we need changes, but am going to mark as request changes until we discuss the point.

@@ -61,17 +70,55 @@
put(MacAlgorithm.HS512, "HmacSHA512");
}
};
private static final Converter<Map<String, Object>, Map<String, Object>> DEFAULT_CLAIM_TYPE_CONVERTER =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking out loud a bit, but this API doesn't account for the fact that conversion may be a blocking operation. For example, if we have an InputStream that needs converted to an Object (i.e. reading in JSON) that is blocking and the API does not return a reactive type.

I'm wondering if in practice this is a concern in this situation since we have likely already done all the IO (it probably is converting from a String not an InputStream). What do we think the likelihood is that we will need to do a blocking operation during conversion? Are there other reasons we might need to block during conversion?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rwinch

I'm wondering if in practice this is a concern in this situation since we have likely already done all the IO

The source of claims passed into the Converter should have already been parsed from it's JSON representation. For the ID Token use case, Nimbus's JSON parser would have read/parsed all claims in the raw ID Token (including custom claims) into an instance of JWTClaimsSet. Even for cases where there is a custom claim and it's a JSON object, Nimbus would parse it into a Map.

Are there other reasons we might need to block during conversion?

At this point, I do not see a use case where a blocking operation could happen with the default NimbusReactiveJwtDecoder.


return new MappedJwtClaimSetConverter(claimNameToConverter);
}

private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
return source -> ClaimConversionService.getSharedInstance().convert(source, sourceDescriptor, targetDescriptor);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since ClaimAccessor is an interface, its methods need to retrieve the instance each time.

However, could this class instead keep it as state (e.g. private final ConversionService conversionService = ClaimConversionService.getSharedInstance())? I believe it would make things more readable.


return new MappedJwtClaimSetConverter(claimNameToConverter);
}

private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
final TypeDescriptor sourceDescriptor = TypeDescriptor.valueOf(Object.class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move these TypeDescriptor declarations out to the class level, e.g. private static final TypeDescriptor OBJECT = TypeDescriptor.valueOf(Object.class)?

@jgrandja jgrandja added in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: duplicate A duplicate of another issue labels Jun 4, 2019
@jgrandja jgrandja added this to the 5.2.0.M3 milestone Jun 4, 2019
@jgrandja
Copy link
Contributor Author

jgrandja commented Jun 4, 2019

Thanks @jzheaux. I applied your feedback.

@jgrandja jgrandja closed this Jun 4, 2019
@jgrandja jgrandja deleted the gh-6245-claim-accessor branch June 4, 2019 23:35
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.

Improve ClaimAccessor and externalize coercion
3 participants