Skip to content

Commit

Permalink
Generate ID token if it is not refreshed
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Nov 12, 2022
1 parent 11b8776 commit 3b87708
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,11 @@ public Uni<SecurityIdentity> apply(Void t) {

context.put(OidcConstants.ACCESS_TOKEN_VALUE, session.getAccessToken());
context.put(AuthorizationCodeTokens.class.getName(), session);
// Default token state manager may have encrypted ID token when it was saved in a cookie
final String currentIdToken = decryptIdTokenIfEncryptedByProvider(configContext, session.getIdToken());
return authenticate(identityProviderManager, context,
new IdTokenCredential(decryptIdTokenIfEncryptedByProvider(configContext, session.getIdToken()),
isInternalIdToken(session.getIdToken(), configContext)))
new IdTokenCredential(currentIdToken,
isInternalIdToken(currentIdToken, configContext)))
.call(new Function<SecurityIdentity, Uni<?>>() {
@Override
public Uni<Void> apply(SecurityIdentity identity) {
Expand Down Expand Up @@ -294,12 +296,14 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
}
LOG.debug("Token has expired, trying to refresh it");
return refreshSecurityIdentity(configContext,
currentIdToken,
session.getRefreshToken(),
context,
identityProviderManager, false, null);
} else if (session.getRefreshToken() != null) {
LOG.debug("Token auto-refresh is starting");
return refreshSecurityIdentity(configContext,
currentIdToken,
session.getRefreshToken(),
context,
identityProviderManager, true,
Expand Down Expand Up @@ -383,6 +387,10 @@ private boolean isInternalIdToken(String idToken, TenantConfigContext configCont
return false;
}

private boolean isIdTokenRequired(TenantConfigContext configContext) {
return configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true);
}

private boolean isJavaScript(RoutingContext context) {
String value = context.request().getHeader("X-Requested-With");
return "JavaScript".equals(value) || "XMLHttpRequest".equals(value);
Expand Down Expand Up @@ -576,20 +584,21 @@ public Uni<SecurityIdentity> apply(final AuthorizationCodeTokens tokens, final T
return Uni.createFrom().failure(new AuthenticationCompletionException(tOuter));
}

boolean internalIdToken = !configContext.oidcConfig.authentication.isIdTokenRequired().orElse(true);
boolean internalIdToken = !isIdTokenRequired(configContext);
if (tokens.getIdToken() == null) {
if (!internalIdToken) {
LOG.errorf("ID token is not available in the authorization code grant response");
return Uni.createFrom().failure(new AuthenticationCompletionException());
} else {
tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig, null));
tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig, null, null));
}
}

context.put(NEW_AUTHENTICATION, Boolean.TRUE);
context.put(OidcConstants.ACCESS_TOKEN_VALUE, tokens.getAccessToken());
context.put(AuthorizationCodeTokens.class.getName(), tokens);

// Default token state manager may have encrypted ID token
final String idToken = decryptIdTokenIfEncryptedByProvider(configContext, tokens.getIdToken());

LOG.debug("Authorization code has been exchanged, verifying ID token");
Expand All @@ -601,7 +610,7 @@ public Uni<Void> apply(SecurityIdentity identity) {
if (internalIdToken && configContext.oidcConfig.allowUserInfoCache
&& configContext.oidcConfig.cacheUserInfoInIdtoken) {
tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig,
identity.getAttribute(OidcUtils.USER_INFO_ATTRIBUTE)));
identity.getAttribute(OidcUtils.USER_INFO_ATTRIBUTE), null));
}
return processSuccessfulAuthentication(context, configContext,
tokens, idToken, identity);
Expand Down Expand Up @@ -652,6 +661,7 @@ public Throwable apply(Throwable tInner) {
}
});
}

});

}
Expand Down Expand Up @@ -680,8 +690,19 @@ private CodeAuthenticationStateBean getCodeAuthenticationBean(String[] parsedSta
return null;
}

private String generateInternalIdToken(OidcTenantConfig oidcConfig, UserInfo userInfo) {
private String generateInternalIdToken(OidcTenantConfig oidcConfig, UserInfo userInfo, String currentIdToken) {
JwtClaimsBuilder builder = Jwt.claims();
if (currentIdToken != null) {
AbstractJsonObjectResponse currentIdTokenJson = new AbstractJsonObjectResponse(
OidcUtils.decodeJwtContentAsString(currentIdToken)) {
};
for (String claim : currentIdTokenJson.getPropertyNames()) {
// Ignore "iat"(issued at) and "exp"(expiry) claims, new "iat" and "exp" claims will be generated
if (!claim.equals(Claims.iat.name()) && !claim.equals(Claims.exp.name())) {
builder.claim(claim, currentIdTokenJson.get(claim));
}
}
}
if (userInfo != null) {
builder.claim(OidcUtils.USER_INFO_ATTRIBUTE, userInfo.getJsonObject());
}
Expand Down Expand Up @@ -877,11 +898,13 @@ private boolean isLogout(RoutingContext context, TenantConfigContext configConte
return false;
}

private Uni<SecurityIdentity> refreshSecurityIdentity(TenantConfigContext configContext, String refreshToken,
private Uni<SecurityIdentity> refreshSecurityIdentity(TenantConfigContext configContext, String currentIdToken,
String refreshToken,
RoutingContext context, IdentityProviderManager identityProviderManager, boolean autoRefresh,
SecurityIdentity fallback) {

Uni<AuthorizationCodeTokens> refreshedTokensUni = refreshTokensUni(configContext, refreshToken);
Uni<AuthorizationCodeTokens> refreshedTokensUni = refreshTokensUni(configContext, currentIdToken, refreshToken,
autoRefresh);

return refreshedTokensUni
.onItemOrFailure()
Expand All @@ -901,11 +924,13 @@ public Uni<SecurityIdentity> apply(final AuthorizationCodeTokens tokens, final T
context.put(AuthorizationCodeTokens.class.getName(), tokens);
context.put(REFRESH_TOKEN_GRANT_RESPONSE, Boolean.TRUE);

// Default token state manager may have encrypted the refreshed ID token
final String idToken = decryptIdTokenIfEncryptedByProvider(configContext, tokens.getIdToken());

LOG.debug("Verifying the refreshed ID token");
return authenticate(identityProviderManager, context,
new IdTokenCredential(idToken))
new IdTokenCredential(idToken,
isInternalIdToken(idToken, configContext)))
.call(new Function<SecurityIdentity, Uni<?>>() {
@Override
public Uni<Void> apply(SecurityIdentity identity) {
Expand Down Expand Up @@ -935,13 +960,33 @@ public Throwable apply(Throwable tInner) {
});
}

private Uni<AuthorizationCodeTokens> refreshTokensUni(TenantConfigContext configContext, String refreshToken) {
private Uni<AuthorizationCodeTokens> refreshTokensUni(TenantConfigContext configContext,
String currentIdToken, String refreshToken, boolean autoRefresh) {
return configContext.provider.refreshTokens(refreshToken).onItem()
.transform(new Function<AuthorizationCodeTokens, AuthorizationCodeTokens>() {
@Override
public AuthorizationCodeTokens apply(AuthorizationCodeTokens tokens) {
return tokens.getRefreshToken() != null ? tokens
: new AuthorizationCodeTokens(tokens.getIdToken(), tokens.getAccessToken(), refreshToken);

if (tokens.getRefreshToken() == null) {
tokens.setRefreshToken(refreshToken);
}

if (tokens.getIdToken() == null) {
if (isIdTokenRequired(configContext)) {
if (!autoRefresh) {
LOG.debugf(
"ID token is not returned in the refresh token grant response, re-authentication is required");
throw new AuthenticationFailedException();
} else {
// Auto-refresh is triggered while current ID token is still valid, continue using it.
tokens.setIdToken(currentIdToken);
}
} else {
tokens.setIdToken(generateInternalIdToken(configContext.oidcConfig, null, currentIdToken));
}
}

return tokens;
}

});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ public static boolean isOpaqueToken(String token) {
}

public static JsonObject decodeJwtContent(String jwt) {
String encodedContent = getJwtContentPart(jwt);
if (encodedContent == null) {
return null;
}
return decodeAsJsonObject(encodedContent);
}

public static String decodeJwtContentAsString(String jwt) {
StringTokenizer tokens = new StringTokenizer(jwt, ".");
// part 1: skip the token headers
tokens.nextToken();
Expand All @@ -100,17 +108,42 @@ public static JsonObject decodeJwtContent(String jwt) {
if (tokens.countTokens() != 1) {
return null;
}
return decodeAsJsonObject(encodedContent);
try {
return base64UrlDecode(encodedContent);
} catch (IllegalArgumentException ex) {
return null;
}
}

public static String getJwtContentPart(String jwt) {
StringTokenizer tokens = new StringTokenizer(jwt, ".");
// part 1: skip the token headers
tokens.nextToken();
if (!tokens.hasMoreTokens()) {
return null;
}
// part 2: token content
String encodedContent = tokens.nextToken();

// let's check only 1 more signature part is available
if (tokens.countTokens() != 1) {
return null;
}
return encodedContent;
}

private static JsonObject decodeAsJsonObject(String encodedContent) {
try {
return new JsonObject(new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8));
return new JsonObject(base64UrlDecode(encodedContent));
} catch (IllegalArgumentException ex) {
return null;
}
}

private static String base64UrlDecode(String encodedContent) {
return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8);
}

public static JsonObject decodeJwtHeaders(String jwt) {
StringTokenizer tokens = new StringTokenizer(jwt, ".");
return decodeAsJsonObject(tokens.nextToken());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ public OidcTenantConfig get() {
config.getToken().setRefreshExpired(true);
config.setAuthServerUrl(getIssuerUrl() + "/realms/quarkus-webapp");
config.setClientId("quarkus-app-webapp");
config.getCredentials().setSecret("secret");
config.getCredentials().setSecret(
"AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow");

// Let Keycloak issue a login challenge but use the test token endpoint
String uri = context.request().absoluteURI();
Expand All @@ -137,6 +138,7 @@ public OidcTenantConfig get() {
config.getToken().setIssuer("any");
config.tokenStateManager.setSplitTokens(true);
config.getAuthentication().setSessionAgeExtension(Duration.ofMinutes(1));
config.getAuthentication().setIdTokenRequired(false);
return config;
} else if ("tenant-web-app-dynamic".equals(tenantId)) {
OidcTenantConfig config = new OidcTenantConfig();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,8 @@ public String token(@FormParam("grant_type") String grantType) {
// and does not recycle refresh tokens during the refresh token grant request.

if (refreshEndpointCallCount++ == 0) {
// first refresh token request
return "{\"id_token\": \"" + jwt("1") + "\"," +
"\"access_token\": \"" + jwt("1") + "\"," +
// first refresh token request, check the original ID token is used
return "{\"access_token\": \"" + jwt("1") + "\"," +
" \"token_type\": \"Bearer\"," +
" \"expires_in\": 300 }";
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ quarkus.oidc.tenant-public-key.client-id=test
quarkus.oidc.tenant-public-key.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB

smallrye.jwt.sign.key.location=/privateKey.pem
smallrye.jwt.new-token.lifespan=5

quarkus.log.category."com.gargoylesoftware.htmlunit.javascript.host.css.CSSStyleSheet".level=FATAL
quarkus.http.auth.proactive=false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,35 @@ public void testCodeFlowRefreshTokens() throws IOException, InterruptedException
assertEquals("userName: alice, idToken: true, accessToken: true, refreshToken: true",
page.getBody().asText());

assertNotNull(getSessionCookie(page.getWebClient(), "tenant-web-app-refresh"));
Cookie sessionCookie = getSessionCookie(page.getWebClient(), "tenant-web-app-refresh");
assertNotNull(sessionCookie);
assertNotNull(getSessionAtCookie(page.getWebClient(), "tenant-web-app-refresh"));
Cookie rtCookie = getSessionRtCookie(page.getWebClient(), "tenant-web-app-refresh");
assertNotNull(rtCookie);

// Wait till the session expires - which should cause the first and also last token refresh request,
// id and access tokens should have new values, refresh token value should remain the same.
// No new sign-in process is required.
await().atLeast(6, TimeUnit.SECONDS);
//await().atLeast(6, TimeUnit.SECONDS);
Thread.sleep(6 * 1000);

page = webClient.getPage("http://localhost:8081/tenant-refresh/tenant-web-app-refresh/api/user");
webClient.getOptions().setRedirectEnabled(false);
WebResponse webResponse = webClient
.loadWebResponse(new WebRequest(
URI.create("http://localhost:8081/tenant-refresh/tenant-web-app-refresh/api/user")
.toURL()));
assertEquals("userName: alice, idToken: true, accessToken: true, refreshToken: true",
page.getBody().asText());
webResponse.getContentAsString());

assertNotNull(getSessionCookie(page.getWebClient(), "tenant-web-app-refresh"));
assertNotNull(getSessionAtCookie(page.getWebClient(), "tenant-web-app-refresh"));
Cookie rtCookie2 = getSessionRtCookie(page.getWebClient(), "tenant-web-app-refresh");
Cookie sessionCookie2 = getSessionCookie(webClient, "tenant-web-app-refresh");
assertNotNull(sessionCookie2);
assertNotEquals(sessionCookie2.getValue(), sessionCookie.getValue());
assertNotNull(getSessionAtCookie(webClient, "tenant-web-app-refresh"));
Cookie rtCookie2 = getSessionRtCookie(webClient, "tenant-web-app-refresh");
assertNotNull(rtCookie2);
assertEquals(rtCookie2.getValue(), rtCookie.getValue());

//Verify all the cookies are cleared after the session timeout
webClient.getOptions().setRedirectEnabled(false);
webClient.getCache().clear();

await().atMost(10, TimeUnit.SECONDS)
Expand Down

0 comments on commit 3b87708

Please sign in to comment.