Skip to content

Commit

Permalink
Avoid an OIDC refresh token call if the JWT refresh token has expired
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Sep 6, 2024
1 parent 441c98d commit d6f491f
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -394,26 +394,19 @@ public Uni<? extends SecurityIdentity> 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,
Expand All @@ -431,35 +424,55 @@ public Uni<? extends SecurityIdentity> 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<SecurityIdentity> 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<SecurityIdentity> 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<SecurityIdentity> redirectToSessionExpiredPage(RoutingContext context, TenantConfigContext configContext) {
URI absoluteUri = URI.create(context.request().absoluteURI());
StringBuilder sessionExpired = new StringBuilder(buildUri(context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -846,4 +846,29 @@ public static <T> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,9 @@ public class CodeFlowUserInfoResource {
@Inject
DefaultTokenIntrospectionUserInfoCache tokenCache;

@Inject
RefreshToken refreshToken;

@GET
@Path("/code-flow-user-info-only")
public String access() {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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"));

Expand All @@ -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));
Expand All @@ -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();
}

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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(
Expand All @@ -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")
Expand Down

0 comments on commit d6f491f

Please sign in to comment.