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 0bf4a5e8866c5..de2d31f987103 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 @@ -394,26 +394,19 @@ public Uni apply(Throwable t) { currentIdToken)); } if (!configContext.oidcConfig.token.refreshExpired) { - // Token has expired and the refresh is not allowed, check if the session expired page is available - if (configContext.oidcConfig.authentication.getSessionExpiredPath() - .isPresent()) { - return redirectToSessionExpiredPage(context, configContext); - } LOG.debug( "Token has expired, token refresh is not allowed, redirecting to re-authenticate"); - return Uni.createFrom() - .failure(new AuthenticationFailedException(t.getCause())); + return refreshIsNotPossible(context, configContext, t); } if (session.getRefreshToken() == null) { - // Token has expired but no refresh token is available, check if the session expired page is available - if (configContext.oidcConfig.authentication.getSessionExpiredPath() - .isPresent()) { - return redirectToSessionExpiredPage(context, configContext); - } LOG.debug( "Token has expired, token refresh is not possible because the refresh token is null"); - return Uni.createFrom() - .failure(new AuthenticationFailedException(t.getCause())); + return refreshIsNotPossible(context, configContext, t); + } + if (OidcUtils.isJwtTokenExpired(session.getRefreshToken())) { + LOG.debug( + "Token has expired, token refresh is not possible because the refresh token has expired"); + return refreshIsNotPossible(context, configContext, t); } LOG.debug("Token has expired, trying to refresh it"); return refreshSecurityIdentity(configContext, @@ -431,35 +424,55 @@ public Uni apply(Throwable t) { new LogoutCall(context, configContext, session.getIdToken())); } - if (session.getRefreshToken() != null) { - // Token has nearly expired, try to refresh - LOG.debug("Token auto-refresh is starting"); - return refreshSecurityIdentity(configContext, - currentIdToken, - session.getRefreshToken(), - context, - identityProviderManager, true, - currentIdentity); - } else { + // Token has nearly expired, try to refresh + + if (session.getRefreshToken() == null) { LOG.debug( "Token auto-refresh is required but is not possible because the refresh token is null"); - // Auto-refreshing is not possible, just continue with the current security identity - if (currentIdentity != null) { - return Uni.createFrom().item(currentIdentity); - } else { - return Uni.createFrom() - .failure(new AuthenticationFailedException(t.getCause())); - } + return autoRefreshIsNotPossible(context, configContext, currentIdentity, t); + } + + if (OidcUtils.isJwtTokenExpired(session.getRefreshToken())) { + LOG.debug( + "Token auto-refresh is required but is not possible because the refresh token has expired"); + return autoRefreshIsNotPossible(context, configContext, currentIdentity, t); } + + LOG.debug("Token auto-refresh is starting"); + return refreshSecurityIdentity(configContext, + currentIdToken, + session.getRefreshToken(), + context, + identityProviderManager, true, + currentIdentity); } } - }); } }); } + private Uni refreshIsNotPossible(RoutingContext context, TenantConfigContext configContext, + Throwable t) { + if (configContext.oidcConfig.authentication.getSessionExpiredPath() + .isPresent()) { + return redirectToSessionExpiredPage(context, configContext); + } + return Uni.createFrom() + .failure(new AuthenticationFailedException(t.getCause())); + } + + private Uni autoRefreshIsNotPossible(RoutingContext context, TenantConfigContext configContext, + SecurityIdentity currentIdentity, Throwable t) { + // Auto-refreshing is not possible, just continue with the current security identity + if (currentIdentity != null) { + return Uni.createFrom().item(currentIdentity); + } else { + return refreshIsNotPossible(context, configContext, t); + } + } + private Uni redirectToSessionExpiredPage(RoutingContext context, TenantConfigContext configContext) { URI absoluteUri = URI.create(context.request().absoluteURI()); StringBuilder sessionExpired = new StringBuilder(buildUri(context, 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 2f169837cface..d8c6c28f08ae8 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 @@ -846,4 +846,29 @@ public static T getAttribute(SecurityIdentity identity, String name) { } return attribute; } + + public static boolean isJwtTokenExpired(String token) { + if (!isOpaqueToken(token)) { + JsonObject claims = decodeJwtContent(token); + Long expiresAt = getJwtExpiresAtClaim(claims); + if (expiresAt == null) { + return false; + } + final long nowSecs = System.currentTimeMillis() / 1000; + return nowSecs > expiresAt; + } + return false; + } + + private static Long getJwtExpiresAtClaim(JsonObject claims) { + if (claims == null || !claims.containsKey(Claims.exp.name())) { + return null; + } + try { + return claims.getLong(Claims.exp.name()); + } catch (IllegalArgumentException ex) { + LOG.debug("Refresh JWT expiry claim can not be converted to Long"); + return null; + } + } } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java index eae1b792e0bd1..144554dc7a654 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java @@ -7,6 +7,7 @@ import org.eclipse.microprofile.jwt.JsonWebToken; +import io.quarkus.oidc.RefreshToken; import io.quarkus.oidc.UserInfo; import io.quarkus.oidc.runtime.DefaultTokenIntrospectionUserInfoCache; import io.quarkus.security.Authenticated; @@ -32,6 +33,9 @@ public class CodeFlowUserInfoResource { @Inject DefaultTokenIntrospectionUserInfoCache tokenCache; + @Inject + RefreshToken refreshToken; + @GET @Path("/code-flow-user-info-only") public String access() { @@ -51,7 +55,8 @@ public String accessGitHub() { @GET @Path("/code-flow-user-info-github-cached-in-idtoken") public String accessGitHubCachedInIdToken() { - return access(); + return access() + + (refreshToken.getToken() != null ? ", refresh_token:" + refreshToken.getToken() : ""); } @GET diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 834b96d7030c1..99e3ed8687730 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -7,6 +7,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.matching; import static com.github.tomakehurst.wiremock.client.WireMock.notContaining; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; import static org.awaitility.Awaitility.given; @@ -34,6 +35,7 @@ import javax.crypto.SecretKey; import org.awaitility.core.ThrowingRunnable; +import org.eclipse.microprofile.jwt.Claims; import org.hamcrest.Matchers; import org.htmlunit.SilentCssErrorHandler; import org.htmlunit.TextPage; @@ -306,7 +308,8 @@ public void testCodeFlowUserInfo() throws Exception { @Test public void testCodeFlowUserInfoCachedInIdToken() throws Exception { // Internal ID token, allow in memory cache = false, cacheUserInfoInIdtoken = true - defineCodeFlowUserInfoCachedInIdTokenStub(); + final String refreshJwtToken = generateAlreadyExpiredRefreshToken(); + defineCodeFlowUserInfoCachedInIdTokenStub(refreshJwtToken); try (final WebClient webClient = createWebClient()) { webClient.getOptions().setRedirectEnabled(true); HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken"); @@ -324,7 +327,8 @@ public void testCodeFlowUserInfoCachedInIdToken() throws Exception { TextPage textPage = form.getInputByValue("login").click(); - assertEquals("alice:alice:alice, cache size: 0, TenantConfigResolver: false", textPage.getContent()); + assertEquals("alice:alice:alice, cache size: 0, TenantConfigResolver: false, refresh_token:refresh1234", + textPage.getContent()); assertNull(getStateCookie(webClient, "code-flow-user-info-github-cached-in-idtoken")); @@ -343,10 +347,16 @@ public void testCodeFlowUserInfoCachedInIdToken() throws Exception { // be returned to Quarkus, analyzed and refreshed assertTrue(date.toInstant().getEpochSecond() - issuedAt <= 299 + 300 + 3); - // refresh + // This is the initial call to the token endpoint where the code was exchanged for tokens + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/auth/realms/quarkus/access_token_refreshed"))); + wireMockServer.resetRequests(); + + // refresh: refresh token in JWT format Thread.sleep(3000); textPage = webClient.getPage("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken"); - assertEquals("alice:alice:bob, cache size: 0, TenantConfigResolver: false", textPage.getContent()); + assertEquals("alice:alice:bob, cache size: 0, TenantConfigResolver: false, refresh_token:" + refreshJwtToken, + textPage.getContent()); idTokenClaims = decryptIdToken(webClient, "code-flow-user-info-github-cached-in-idtoken"); assertNotNull(idTokenClaims.getJsonObject(OidcUtils.USER_INFO_ATTRIBUTE)); @@ -360,6 +370,27 @@ public void testCodeFlowUserInfoCachedInIdToken() throws Exception { assertTrue(date.toInstant().getEpochSecond() - issuedAt >= 305 + 300); assertTrue(date.toInstant().getEpochSecond() - issuedAt <= 305 + 300 + 3); + // access token must've been refreshed + wireMockServer.verify(1, + postRequestedFor(urlPathMatching("/auth/realms/quarkus/access_token_refreshed"))); + wireMockServer.resetRequests(); + + Thread.sleep(3000); + // Refresh token is available but it is expired, so no token endpoint call is expected + assertTrue((System.currentTimeMillis() / 1000) > OidcUtils.decodeJwtContent(refreshJwtToken) + .getLong(Claims.exp.name())); + + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse(new WebRequest( + URI.create("http://localhost:8081/code-flow-user-info-github-cached-in-idtoken").toURL())); + assertEquals(302, webResponse.getStatusCode()); + + // no another token endpoint call is made: + wireMockServer.verify(0, + postRequestedFor(urlPathMatching("/auth/realms/quarkus/access_token_refreshed"))); + wireMockServer.resetRequests(); + webClient.getCookieManager().clearCookies(); } @@ -573,7 +604,7 @@ private void defineCodeFlowAuthorizationOauth2TokenStub() { } - private void defineCodeFlowUserInfoCachedInIdTokenStub() { + private void defineCodeFlowUserInfoCachedInIdTokenStub(String expiredRefreshToken) { wireMockServer .stubFor(WireMock.post(urlPathMatching("/auth/realms/quarkus/access_token_refreshed")) .withHeader("X-Custom", matching("XCustomHeaderValue")) @@ -610,7 +641,9 @@ private void defineCodeFlowUserInfoCachedInIdTokenStub() { .withBody("{\n" + " \"access_token\": \"" + OidcWiremockTestResource.getAccessToken("bob", Set.of()) + "\"," - + "\"expires_in\": 305" + + " \"expires_in\": 305," + + " \"refresh_token\": \"" + + expiredRefreshToken + "\"" + "}"))); wireMockServer.stubFor( @@ -627,6 +660,10 @@ private void defineCodeFlowUserInfoCachedInIdTokenStub() { } + private String generateAlreadyExpiredRefreshToken() { + return Jwt.claims().expiresIn(0).signWithSecret("0123456789ABCDEF0123456789ABCDEF"); + } + private void defineCodeFlowTokenIntrospectionStub() { wireMockServer .stubFor(WireMock.post("/auth/realms/quarkus/access_token")