While we have a solution to this, we want to know if there is a better solution because ours is ugly and feels super wrong. So, does anyone know of a better solution using Spring Security?
Problem
We maintain an application that manages user accounts. At login, we produce two JWTs - an access token and a refresh token. We would like the payload of the access token to contain a different set of fields than the refresh token. For example, if the payload of the access token is:
{
"a" : "foo",
"b" : "bar",
}
we would like the payload of the refresh token to be:
{
"a" : "foo",
"x" : "baz",
}
What we did
The only solution we could think of was to implement a custom token enhancer which extends JwtAccessTokenConverter
and overrides the enhance
method such that the contents of the super class's method is modified ever so slightly to control the contents of each payload. Here is the code:
public class CustomJwtAccessTokenConverter extends JwtAccessTokenConverter {
@Override
public OAuth2AccessToken enhance(
OAuth2AccessToken accessToken,
OAuth2Authentication authentication
) {
DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken(accessToken);
Map<String, Object> info = new LinkedHashMap<>(accessToken.getAdditionalInformation());
// <custom-code>
info.remove("x");
// </custom-code>
String tokenId = result.getValue();
if (!info.containsKey(TOKEN_ID)) {
info.put(TOKEN_ID, tokenId);
} else {
tokenId = (String) info.get(TOKEN_ID);
}
result.setAdditionalInformation(info);
result.setValue(encode(result, authentication));
OAuth2RefreshToken refreshToken = result.getRefreshToken();
if (refreshToken != null) {
DefaultOAuth2AccessToken encodedRefreshToken = new DefaultOAuth2AccessToken(accessToken);
encodedRefreshToken.setValue(refreshToken.getValue());
// Refresh tokens do not expire unless explicitly of the right type
encodedRefreshToken.setExpiration(null);
try {
Map<String, Object> claims = jsonParser
.parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims());
if (claims.containsKey(TOKEN_ID)) {
encodedRefreshToken.setValue(claims.get(TOKEN_ID).toString());
}
} catch (IllegalArgumentException e) {
}
Map<String, Object> refreshTokenInfo = new LinkedHashMap<>(
accessToken.getAdditionalInformation());
// <custom-code>
refreshTokenInfo.remove("b");
// </custom-code>
refreshTokenInfo.put(TOKEN_ID, encodedRefreshToken.getValue());
refreshTokenInfo.put(ACCESS_TOKEN_ID, tokenId);
encodedRefreshToken.setAdditionalInformation(refreshTokenInfo);
DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken(
encode(encodedRefreshToken, authentication));
if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration();
encodedRefreshToken.setExpiration(expiration);
token = new DefaultExpiringOAuth2RefreshToken(encode(encodedRefreshToken, authentication), expiration);
}
result.setRefreshToken(token);
}
return result;
}
}
Context for different payloads
We have a mobile application which needs to pass its access and refresh tokens to a web view. The application running in the web view has no backing server (React Javascript application served from a CDN). Our access and refresh tokens contain nested tokens issued from a separate single sign-on system of which we are effectively an enhanced proxy. As a result, the tokens we are issuing are fairly large - I generated a 2000+ character access token in my own testing. To pass the tokens to the web view, we are passing them via a query parameter. As some browsers have URL limits, we want the SSO's access token to be present only in our access token and the SSO's refresh token to be present only in our refresh token as this is will mitigate the URL bloat - the SSO's access tokens are full-blown JWTs while their refresh tokens are simple GUIDs.
We know that passing a JWT via a query parameter isn't best practice and have read the RFCs. We have hard deadlines and this was the considered the quickest feasible solution. We are designing a token exchange microservice right now to get around this in the future.