Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid an OIDC refresh token call if the JWT refresh token has expired #43081

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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