Skip to content

Commit

Permalink
OIDC: Preserve the refresh token if no new refresh token is returned
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Sep 19, 2022
1 parent e19f65e commit cc48515
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ public Uni<? extends SecurityIdentity> 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());
Expand All @@ -262,13 +267,18 @@ public Uni<? extends SecurityIdentity> apply(Throwable t) {
session.getRefreshToken(),
context,
identityProviderManager, false, null);
} else {
} else if (session.getRefreshToken() != null) {
LOG.debug("Token auto-refresh is starting");
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());
}
}
});
Expand Down Expand Up @@ -894,7 +904,15 @@ public Throwable apply(Throwable tInner) {
}

private Uni<AuthorizationCodeTokens> refreshTokensUni(TenantConfigContext configContext, String refreshToken) {
return configContext.provider.refreshTokens(refreshToken);
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);
}

});
}

private Uni<AuthorizationCodeTokens> getCodeFlowTokensUni(RoutingContext context, TenantConfigContext configContext,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.it.keycloak;

import java.time.Duration;
import java.util.function.Supplier;

import javax.enterprise.context.ApplicationScoped;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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\"," +
Expand All @@ -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\"," +
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Boolean>() {
@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()) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit cc48515

Please sign in to comment.