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 7fdfea032c413b..50a6d87f461ce5 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,6 +248,11 @@ public Uni apply(Throwable t) { LOG.debugf("Authentication failure: %s", t.getCause()); throw new AuthenticationCompletionException(t.getCause()); } + if (session.getRefreshToken() == null) { + LOG.debug( + "Token has expired, token refresh is not possible because the refresh token is null"); + throw new AuthenticationFailedException(t.getCause()); + } if (!configContext.oidcConfig.token.refreshExpired) { LOG.debug("Token has expired, token refresh is not allowed"); throw new AuthenticationFailedException(t.getCause()); @@ -257,12 +262,18 @@ public Uni apply(Throwable t) { session.getRefreshToken(), context, identityProviderManager, false, null); - } else { + } else if (session.getRefreshToken() != null) { + LOG.debug("Token has nearly expired, attempting to auto-refresh it"); return refreshSecurityIdentity(configContext, session.getRefreshToken(), context, identityProviderManager, true, ((TokenAutoRefreshException) t).getSecurityIdentity()); + } else { + LOG.debug( + "Token auto-refresh is required it is not possible because the refresh token is null"); + // Auto-refreshing is not possible, just continue with the current security identity + return Uni.createFrom().item(((TokenAutoRefreshException) t).getSecurityIdentity()); } } }); @@ -878,7 +889,15 @@ public Throwable apply(Throwable tInner) { } private Uni refreshTokensUni(TenantConfigContext configContext, String refreshToken) { - return configContext.provider.refreshTokens(refreshToken); + 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); + } + + }); } private Uni getCodeFlowTokensUni(RoutingContext context, TenantConfigContext configContext, 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 69e58df5b50c6c..2c3b1347f85a50 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 @@ -1,5 +1,6 @@ package io.quarkus.it.keycloak; +import java.time.Duration; import java.util.function.Supplier; import javax.enterprise.context.ApplicationScoped; @@ -118,6 +119,25 @@ public OidcTenantConfig get() { config.token.setAllowOpaqueTokenIntrospection(false); config.setClientId("client"); return config; + } else if ("tenant-web-app-refresh".equals(tenantId)) { + OidcTenantConfig config = new OidcTenantConfig(); + config.setTenantId("tenant-web-app-refresh"); + config.setApplicationType(ApplicationType.WEB_APP); + config.getToken().setRefreshExpired(true); + config.setAuthServerUrl(getIssuerUrl() + "/realms/quarkus-webapp"); + config.setClientId("quarkus-app-webapp"); + config.getCredentials().setSecret("secret"); + + // Let Keycloak issue a login challenge but use the test token endpoint + String uri = context.request().absoluteURI(); + String tokenUri = uri.replace("/tenant-refresh/tenant-web-app-refresh/api/user", "/oidc/token"); + config.setTokenPath(tokenUri); + String jwksUri = uri.replace("/tenant-refresh/tenant-web-app-refresh/api/user", "/oidc/jwks"); + config.setJwksPath(jwksUri); + config.getToken().setIssuer("any"); + config.tokenStateManager.setSplitTokens(true); + config.getAuthentication().setSessionAgeExtension(Duration.ofMinutes(1)); + return config; } else if ("tenant-web-app-dynamic".equals(tenantId)) { OidcTenantConfig config = new OidcTenantConfig(); config.setTenantId("tenant-web-app-dynamic"); 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 6b7c4d98acd932..6702ef9c537cd5 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 @@ -1,9 +1,11 @@ package io.quarkus.it.keycloak; import java.security.PublicKey; +import java.time.Duration; import java.util.Base64; import javax.annotation.PostConstruct; +import javax.ws.rs.BadRequestException; import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; @@ -39,6 +41,7 @@ public class OidcResource { private volatile int revokeEndpointCallCount; private volatile int userInfoEndpointCallCount; private volatile boolean enableDiscovery = true; + private volatile int refreshEndpointCallCount; @PostConstruct public void init() throws Exception { @@ -193,7 +196,38 @@ public String userinfo() { @POST @Path("token") @Produces("application/json") - public String token(@QueryParam("kid") String kid) { + public String token(@FormParam("grant_type") String grantType) { + if ("authorization_code".equals(grantType)) { + return "{\"id_token\": \"" + jwt("1") + "\"," + + "\"access_token\": \"" + jwt("1") + "\"," + + " \"token_type\": \"Bearer\"," + + " \"refresh_token\": \"123456789\"," + + " \"expires_in\": 300 }"; + } else if ("refresh_token".equals(grantType)) { + // Emulate the case where the provider returns the refresh token only once + // 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") + "\"," + + " \"token_type\": \"Bearer\"," + + " \"expires_in\": 300 }"; + } else { + // force an error to test the case where the refresh token eventually becomes invalid + // quarkus-oidc should redirect the user to authenticate again if refreshing the token fails + throw new BadRequestException(); + } + } else { + // unexpected grant request + throw new BadRequestException(); + } + } + + @POST + @Path("accesstoken") + @Produces("application/json") + public String testAccessToken(@QueryParam("kid") String kid) { return "{\"access_token\": \"" + jwt(kid) + "\"," + " \"token_type\": \"Bearer\"," + " \"refresh_token\": \"123456789\"," + @@ -203,7 +237,7 @@ public String token(@QueryParam("kid") String kid) { @POST @Path("opaque-token") @Produces("application/json") - public String opaqueToken(@QueryParam("kid") String kid) { + public String testOpaqueToken(@QueryParam("kid") String kid) { return "{\"access_token\": \"987654321\"," + " \"token_type\": \"Bearer\"," + " \"refresh_token\": \"123456789\"," + @@ -258,6 +292,7 @@ private String jwt(String kid) { .upn("alice") .preferredUserName("alice") .groups("user") + .expiresIn(Duration.ofSeconds(4)) .jws().keyId(kid) .sign(key.getPrivateKey()); } diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantRefreshTokenResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantRefreshTokenResource.java new file mode 100644 index 00000000000000..dca3207a36284f --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantRefreshTokenResource.java @@ -0,0 +1,36 @@ +package io.quarkus.it.keycloak; + +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.IdToken; +import io.quarkus.oidc.RefreshToken; + +@Path("/tenant-refresh") +public class TenantRefreshTokenResource { + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + JsonWebToken accessToken; + + @Inject + RefreshToken refreshToken; + + @GET + @Path("/tenant-web-app-refresh/api/user") + @RolesAllowed("user") + public String checkTokens() { + return "userName: " + idToken.getName() + + ", idToken: " + (idToken.getRawToken() != null) + + ", accessToken: " + (accessToken.getRawToken() != null) + + ", refreshToken: " + (refreshToken.getToken() != null); + } + +} diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index cc941e1a29ebba..85bc3b1ac78541 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -113,3 +113,6 @@ quarkus.http.auth.proactive=false quarkus.native.additional-build-args=-H:IncludeResources=.*\\.pem + +quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".min-level=TRACE +quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".level=TRACE \ No newline at end of file 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 b480351c97ee9b..52180c12ac61b9 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 @@ -1,5 +1,6 @@ package io.quarkus.it.keycloak; +import static org.awaitility.Awaitility.await; import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -8,6 +9,9 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.keycloak.representations.AccessTokenResponse; @@ -99,6 +103,67 @@ public void testResolveTenantIdentifierWebApp2() throws IOException { } } + @Test + public void testCodeFlowRefreshTokens() throws IOException, InterruptedException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/tenant-refresh/tenant-web-app-refresh/api/user"); + assertEquals("Sign in to quarkus-webapp", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + page = loginForm.getInputByName("login").click(); + + assertEquals("userName: alice, idToken: true, accessToken: true, refreshToken: true", + page.getBody().asText()); + + assertNotNull(getSessionCookie(page.getWebClient(), "tenant-web-app-refresh")); + 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); + + page = webClient.getPage("http://localhost:8081/tenant-refresh/tenant-web-app-refresh/api/user"); + assertEquals("userName: alice, idToken: true, accessToken: true, refreshToken: true", + page.getBody().asText()); + + 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"); + 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) + .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-refresh/tenant-web-app-refresh/api/user") + .toURL())); + // Should redirect to login page given that session is now expired and + // the 2nd refresh token is expected to fail in the test OidcResource + return 302 == webResponse.getStatusCode(); + } + }); + + assertNull(getSessionCookie(webClient, "tenant-web-app-refresh")); + assertNull(getSessionAtCookie(webClient, "tenant-web-app-refresh")); + assertNull(getSessionRtCookie(webClient, "tenant-web-app-refresh")); + + webClient.getCookieManager().clearCookies(); + } + } + @Test public void testHybridWebApp() throws IOException { try (final WebClient webClient = createWebClient()) { @@ -540,8 +605,9 @@ private String getAccessTokenFromSimpleOidc(String kid) { String json = RestAssured .given() .queryParam("kid", kid) + .formParam("grant_type", "authorization_code") .when() - .post("/oidc/token") + .post("/oidc/accesstoken") .body().asString(); JsonObject object = new JsonObject(json); return object.getString("access_token"); @@ -574,4 +640,12 @@ private String getStateCookieSavedPath(WebClient webClient, String tenantId) { String[] parts = getStateCookie(webClient, tenantId).getValue().split("\\|"); return parts.length == 2 ? parts[1] : null; } + + private Cookie getSessionAtCookie(WebClient webClient, String tenantId) { + return webClient.getCookieManager().getCookie("q_session_at" + (tenantId == null ? "_Default_test" : "_" + tenantId)); + } + + private Cookie getSessionRtCookie(WebClient webClient, String tenantId) { + return webClient.getCookieManager().getCookie("q_session_rt" + (tenantId == null ? "_Default_test" : "_" + tenantId)); + } } diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index f932a58b858f03..eadda60bd9e563 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -119,6 +119,10 @@ quarkus.oidc.bearer-wrong-role-path.roles.role-claim-path=path quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".min-level=TRACE quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".level=TRACE +quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".min-level=TRACE +quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".level=TRACE +quarkus.log.category."io.quarkus.oidc.runtime.OidcProviderClient".min-level=TRACE +quarkus.log.category."io.quarkus.oidc.runtime.OidcProviderClient".level=TRACE quarkus.http.auth.permission.logout.paths=/code-flow/logout quarkus.http.auth.permission.logout.policy=authenticated