From 7f3f66647b5a1eae6e7d2147ab6de394a1045704 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Fri, 11 Nov 2022 17:54:50 +0000 Subject: [PATCH] Generate ID token if it is not refreshed (cherry picked from commit 3b87708f7aed30f935441cb69708d73227861820) --- .../runtime/CodeAuthenticationMechanism.java | 69 +++++++++++++++---- .../io/quarkus/oidc/runtime/OidcUtils.java | 37 +++++++++- .../keycloak/CustomTenantConfigResolver.java | 4 +- .../io/quarkus/it/keycloak/OidcResource.java | 5 +- .../src/main/resources/application.properties | 1 + .../BearerTokenAuthorizationTest.java | 23 ++++--- 6 files changed, 113 insertions(+), 26 deletions(-) diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index 68e53d0a981bd..a5c541034b5b4 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -248,9 +248,11 @@ public Uni 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>() { @Override public Uni apply(SecurityIdentity identity) { @@ -294,12 +296,14 @@ public Uni 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, @@ -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); @@ -576,13 +584,13 @@ public Uni 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)); } } @@ -590,6 +598,7 @@ public Uni apply(final AuthorizationCodeTokens tokens, final T 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"); @@ -601,7 +610,7 @@ public Uni 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); @@ -652,6 +661,7 @@ public Throwable apply(Throwable tInner) { } }); } + }); } @@ -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()); } @@ -877,11 +898,13 @@ private boolean isLogout(RoutingContext context, TenantConfigContext configConte return false; } - private Uni refreshSecurityIdentity(TenantConfigContext configContext, String refreshToken, + private Uni refreshSecurityIdentity(TenantConfigContext configContext, String currentIdToken, + String refreshToken, RoutingContext context, IdentityProviderManager identityProviderManager, boolean autoRefresh, SecurityIdentity fallback) { - Uni refreshedTokensUni = refreshTokensUni(configContext, refreshToken); + Uni refreshedTokensUni = refreshTokensUni(configContext, currentIdToken, refreshToken, + autoRefresh); return refreshedTokensUni .onItemOrFailure() @@ -901,11 +924,13 @@ public Uni 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>() { @Override public Uni apply(SecurityIdentity identity) { @@ -935,13 +960,33 @@ public Throwable apply(Throwable tInner) { }); } - private Uni refreshTokensUni(TenantConfigContext configContext, String refreshToken) { + private Uni refreshTokensUni(TenantConfigContext configContext, + String currentIdToken, String refreshToken, boolean autoRefresh) { return configContext.provider.refreshTokens(refreshToken).onItem() .transform(new Function() { @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; } }); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index d9747dd05f547..a9b245bf4e1da 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -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(); @@ -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()); diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java index 2c3b1347f85a5..41945e66c614b 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java @@ -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(); @@ -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(); diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java index 6702ef9c537cd..3ab87468213a8 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java @@ -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 { diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index 85bc3b1ac7854..20a4673716dab 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -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 diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 52180c12ac61b..019cdc86f0362 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -116,7 +116,8 @@ 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); @@ -124,20 +125,26 @@ public void testCodeFlowRefreshTokens() throws IOException, InterruptedException // 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)