From 3ca144a2b7b1efb77010f5805fe1decd76f81bef Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Mon, 31 Aug 2020 17:02:40 +0100 Subject: [PATCH] OIDC ID token auto-refresh support --- .../io/quarkus/oidc/OidcTenantConfig.java | 10 +++++ .../runtime/CodeAuthenticationMechanism.java | 42 ++++++++++++------- .../oidc/runtime/OidcIdentityProvider.java | 31 ++++++++++++-- .../runtime/TokenAutoRefreshException.java | 16 +++++++ .../it/keycloak/CustomTenantResolver.java | 4 ++ .../it/keycloak/TenantAutoRefresh.java | 15 +++++++ .../src/main/resources/application.properties | 11 +++++ .../io/quarkus/it/keycloak/CodeFlowTest.java | 38 +++++++++++++++++ 8 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenAutoRefreshException.java create mode 100644 integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantAutoRefresh.java diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index aa822335392f7..51d1f0f53c3ad 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -853,6 +853,16 @@ public static Token fromAudience(String... audience) { @ConfigItem public boolean refreshExpired; + /** + * Token auto-refresh interval in seconds during the user re-authentication. + * If this option is set then the valid ID token will be refreshed if it will expire in less than a number of minutes + * set by this option. The user will still be authenticated if the ID token can no longer be refreshed but is still + * valid. + * This option will be ignored if the 'refresh-expired' property is not enabled. + */ + @ConfigItem + public Optional autoRefreshInterval = Optional.empty(); + /** * Forced JWK set refresh interval in minutes. */ 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 64f75629c903d..52de56c5d397d 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 @@ -112,22 +112,31 @@ public SecurityIdentity apply(Throwable throwable) { throw AuthenticationRedirectException.class.cast(throwable); } - Throwable cause = throwable.getCause(); - - if (cause != null && !"expired token".equalsIgnoreCase(cause.getMessage())) { - LOG.debugf("Authentication failure: %s", cause); - throw new AuthenticationCompletionException(cause); - } - if (!configContext.oidcConfig.token.refreshExpired) { - LOG.debug("Token has expired, token refresh is not allowed"); - throw new AuthenticationCompletionException(cause); - } - LOG.debug("Token has expired, trying to refresh it"); - SecurityIdentity identity = trySilentRefresh(configContext, - refreshToken, context, identityProviderManager); - if (identity == null) { - LOG.debug("SecurityIdentity is null after a token refresh"); - throw new AuthenticationCompletionException(); + SecurityIdentity identity = null; + + if (!(throwable instanceof TokenAutoRefreshException)) { + Throwable cause = throwable.getCause(); + + if (cause != null && !"expired token".equalsIgnoreCase(cause.getMessage())) { + LOG.debugf("Authentication failure: %s", cause); + throw new AuthenticationCompletionException(cause); + } + if (!configContext.oidcConfig.token.refreshExpired) { + LOG.debug("Token has expired, token refresh is not allowed"); + throw new AuthenticationCompletionException(cause); + } + LOG.debug("Token has expired, trying to refresh it"); + identity = trySilentRefresh(configContext, refreshToken, context, identityProviderManager); + if (identity == null) { + LOG.debug("SecurityIdentity is null after a token refresh"); + throw new AuthenticationCompletionException(); + } + } else { + identity = trySilentRefresh(configContext, refreshToken, context, identityProviderManager); + if (identity == null) { + LOG.debug("ID token can no longer be refreshed, using the current SecurityIdentity"); + identity = ((TokenAutoRefreshException) throwable).getSecurityIdentity(); + } } return identity; } @@ -135,6 +144,7 @@ public SecurityIdentity apply(Throwable throwable) { } // start a new session by starting the code flow dance + context.put("new_authentication", Boolean.TRUE); return performCodeFlow(identityProviderManager, context, resolver); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index 161f955146b2d..423ac02eea7b8 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -11,6 +11,7 @@ import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.IdTokenCredential; +import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.OidcTenantConfig.Roles.Source; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; @@ -108,7 +109,6 @@ public void handle(AsyncResult event) { // JWK refresh has not finished yet, but the fallback introspection request has succeeded. tokenJson = OidcUtils.decodeJwtContent(tokenCred.getToken()); } - JsonObject userInfo = null; if (resolvedContext.oidcConfig.authentication.isUserInfoRequired()) { userInfo = getUserInfo(event.result(), (String) vertxContext.get("access_token")); @@ -118,9 +118,14 @@ public void handle(AsyncResult event) { JsonObject rolesJson = getRolesJson(vertxContext, resolvedContext, tokenCred, tokenJson, userInfo); try { - uniEmitter.complete( - validateAndCreateIdentity(vertxContext, tokenCred, resolvedContext.oidcConfig, - tokenJson, rolesJson, userInfo)); + SecurityIdentity securityIdentity = validateAndCreateIdentity(vertxContext, tokenCred, + resolvedContext.oidcConfig, + tokenJson, rolesJson, userInfo); + if (tokenAutoRefreshPrepared(tokenJson, vertxContext, resolvedContext.oidcConfig)) { + throw new TokenAutoRefreshException(securityIdentity); + } else { + uniEmitter.complete(securityIdentity); + } } catch (Throwable ex) { uniEmitter.fail(ex); } @@ -156,6 +161,24 @@ public String getName() { }); } + private static boolean tokenAutoRefreshPrepared(JsonObject tokenJson, RoutingContext vertxContext, + OidcTenantConfig oidcConfig) { + if (tokenJson != null + && oidcConfig.token.refreshExpired + && oidcConfig.token.autoRefreshInterval.isPresent() + && vertxContext.get("tokenAutoRefreshInProgress") != Boolean.TRUE + && vertxContext.get("new_authentication") != Boolean.TRUE) { + final long autoRefreshInterval = oidcConfig.token.autoRefreshInterval.get().getSeconds(); + final long expiry = tokenJson.getLong("exp"); + final long now = System.currentTimeMillis() / 1000; + if (now + autoRefreshInterval > expiry) { + vertxContext.put("tokenAutoRefreshInProgress", Boolean.TRUE); + return true; + } + } + return false; + } + @SuppressWarnings("deprecation") private static JsonObject getRolesJson(RoutingContext vertxContext, TenantConfigContext resolvedContext, TokenCredential tokenCred, diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenAutoRefreshException.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenAutoRefreshException.java new file mode 100644 index 0000000000000..4cc039a301b31 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenAutoRefreshException.java @@ -0,0 +1,16 @@ +package io.quarkus.oidc.runtime; + +import io.quarkus.security.identity.SecurityIdentity; + +@SuppressWarnings("serial") +public class TokenAutoRefreshException extends RuntimeException { + private SecurityIdentity securityIdentity; + + public TokenAutoRefreshException(SecurityIdentity securityIdentity) { + this.securityIdentity = securityIdentity; + } + + public SecurityIdentity getSecurityIdentity() { + return securityIdentity; + } +} diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index 9afa335889120..774fd0083fc0a 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -23,6 +23,10 @@ public String resolve(RoutingContext context) { return "tenant-logout"; } + if (path.contains("tenant-autorefresh")) { + return "tenant-autorefresh"; + } + if (path.contains("tenant-https")) { return "tenant-https"; } diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantAutoRefresh.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantAutoRefresh.java new file mode 100644 index 0000000000000..9d5e9b4345070 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantAutoRefresh.java @@ -0,0 +1,15 @@ +package io.quarkus.it.keycloak; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import io.quarkus.security.Authenticated; + +@Path("/tenant-autorefresh") +public class TenantAutoRefresh { + @Authenticated + @GET + public String getTenantLogout() { + return "Tenant AutoRefresh"; + } +} diff --git a/integration-tests/oidc-code-flow/src/main/resources/application.properties b/integration-tests/oidc-code-flow/src/main/resources/application.properties index b040fdf5437b8..17de63d14ae03 100644 --- a/integration-tests/oidc-code-flow/src/main/resources/application.properties +++ b/integration-tests/oidc-code-flow/src/main/resources/application.properties @@ -70,6 +70,14 @@ quarkus.oidc.tenant-logout.logout.path=/tenant-logout/logout quarkus.oidc.tenant-logout.logout.post-logout-path=/tenant-logout/post-logout quarkus.oidc.tenant-logout.token.refresh-expired=true +quarkus.oidc.tenant-autorefresh.auth-server-url=${keycloak.url}/realms/logout-realm +quarkus.oidc.tenant-autorefresh.client-id=quarkus-app +quarkus.oidc.tenant-autorefresh.credentials.secret=secret +quarkus.oidc.tenant-autorefresh.application-type=web-app +quarkus.oidc.tenant-autorefresh.authentication.cookie-path=/tenant-autorefresh +quarkus.oidc.tenant-autorefresh.token.refresh-expired=true +quarkus.oidc.tenant-autorefresh.token.auto-refresh-interval=4S + # Tenant which is used to test that the redirect_uri https scheme is enforced. quarkus.oidc.tenant-https.auth-server-url=${keycloak.url}/realms/quarkus quarkus.oidc.tenant-https.client-id=quarkus-app @@ -93,6 +101,9 @@ quarkus.http.auth.permission.roles1.policy=authenticated quarkus.http.auth.permission.logout.paths=/tenant-logout quarkus.http.auth.permission.logout.policy=authenticated +quarkus.http.auth.permission.logout.paths=/tenant-autorefresh +quarkus.http.auth.permission.logout.policy=authenticated + quarkus.http.auth.permission.https.paths=/tenant-https quarkus.http.auth.permission.https.policy=authenticated diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java index 199ee4efb145e..13b59e77a7855 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -213,6 +213,44 @@ public Boolean call() throws Exception { page = webClient.getPage("http://localhost:8081/tenant-logout"); assertNull(getSessionCookie(webClient, "tenant-logout")); assertEquals("Log in to logout-realm", page.getTitleText()); + webClient.getCookieManager().clearCookies(); + } + } + + @Test + public void testTokenAutoRefresh() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/tenant-autorefresh"); + assertEquals("Log in to logout-realm", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + page = loginForm.getInputByName("login").click(); + assertTrue(page.asText().contains("Tenant AutoRefresh")); + + Cookie sessionCookie = getSessionCookie(webClient, "tenant-autorefresh"); + assertNotNull(sessionCookie); + String idToken = getIdToken(sessionCookie); + + //wait now so that we reach the refresh timeout + await().atMost(5, TimeUnit.SECONDS) + .pollInterval(Duration.ofSeconds(1)) + .until(new Callable() { + @Override + public Boolean call() throws Exception { + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse( + new WebRequest(URI.create("http://localhost:8081/tenant-autorefresh").toURL())); + assertEquals(200, webResponse.getStatusCode()); + assertTrue(webResponse.getContentAsString().contains("Tenant AutoRefresh")); + // Should not redirect to OP but silently refresh token + Cookie newSessionCookie = getSessionCookie(webClient, "tenant-autorefresh"); + assertNotNull(newSessionCookie); + return !idToken.equals(getIdToken(newSessionCookie)); + } + }); + webClient.getCookieManager().clearCookies(); } }