Repository: quarkusio/quarkus
Issue: OidcClient has failed to complete the refresh_token grant request: "error_description":"Refresh token expired"

### Describe the bug

It seems like there's no processing of expired refresh_tokens implemented. So "refresh token expired" error appears and oidc-client is unable to get new access_token.

### Expected behavior

OidcClient could have some refresh_token processing logic.
1. Test refresh_token for being expired
2. If true , get new refresh_token, await
3. If got new refresh_token, then get new access_token

### Actual behavior

oidc-client is unable to get new access_token with stale refresh_token , and refresh_token itself is not requested.

### How to Reproduce?

rest-client + oidc-client  (via OidcClientRequestFilter) + keycloak 12hrs refresh_token exp.

https://github.com/tttomat19/quarkus-quickstarts/tree/main/rest-client-quickstart

### Output of `uname -a` or `ver`

_No response_

### Output of `java -version`

_No response_

### GraalVM version (if different from Java)

_No response_

### Quarkus version or git rev

_No response_

### Build tool (ie. output of `mvnw --version` or `gradlew --version`)

_No response_

### Additional information

_No response_

Changed files:
integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java
extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java
extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java
integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java
extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java
extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java
extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java

diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java
index d751d649823..90257b7efd4 100644
--- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java
+++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfig.java
@@ -113,11 +113,17 @@ public String getGrantType() {
         public String refreshTokenProperty = OidcConstants.REFRESH_TOKEN_VALUE;
 
         /**
-         * Refresh token property name in a token grant response
+         * Access token expiry property name in a token grant response
          */
         @ConfigItem(defaultValue = OidcConstants.EXPIRES_IN)
         public String expiresInProperty = OidcConstants.EXPIRES_IN;
 
+        /**
+         * Refresh token expiry property name in a token grant response
+         */
+        @ConfigItem(defaultValue = OidcConstants.REFRESH_EXPIRES_IN)
+        public String refreshExpiresInProperty = OidcConstants.REFRESH_EXPIRES_IN;
+
         public Type getType() {
             return type;
         }
@@ -149,6 +155,14 @@ public String getExpiresInProperty() {
         public void setExpiresInProperty(String expiresInProperty) {
             this.expiresInProperty = expiresInProperty;
         }
+
+        public String getRefreshExpiresInProperty() {
+            return refreshExpiresInProperty;
+        }
+
+        public void setRefreshExpiresInProperty(String refreshExpiresInProperty) {
+            this.refreshExpiresInProperty = refreshExpiresInProperty;
+        }
     }
 
     /**
diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java
index 37ca13c3133..fa2a7989107 100644
--- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java
+++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java
@@ -12,14 +12,16 @@ public class Tokens {
     final private Long accessTokenExpiresAt;
     final private Long refreshTokenTimeSkew;
     final private String refreshToken;
+    final Long refreshTokenExpiresAt;
     final private JsonObject grantResponse;
 
     public Tokens(String accessToken, Long accessTokenExpiresAt, Duration refreshTokenTimeSkewDuration, String refreshToken,
-            JsonObject grantResponse) {
+            Long refreshTokenExpiresAt, JsonObject grantResponse) {
         this.accessToken = accessToken;
         this.accessTokenExpiresAt = accessTokenExpiresAt;
         this.refreshTokenTimeSkew = refreshTokenTimeSkewDuration == null ? null : refreshTokenTimeSkewDuration.getSeconds();
         this.refreshToken = refreshToken;
+        this.refreshTokenExpiresAt = refreshTokenExpiresAt;
         this.grantResponse = grantResponse;
     }
 
@@ -44,11 +46,11 @@ public Long getRefreshTokenTimeSkew() {
     }
 
     public boolean isAccessTokenExpired() {
-        if (accessTokenExpiresAt == null) {
-            return false;
-        }
-        final long nowSecs = System.currentTimeMillis() / 1000;
-        return nowSecs > accessTokenExpiresAt;
+        return isExpired(accessTokenExpiresAt);
+    }
+
+    public boolean isRefreshTokenExpired() {
+        return isExpired(refreshTokenExpiresAt);
     }
 
     public boolean isAccessTokenWithinRefreshInterval() {
@@ -58,4 +60,12 @@ public boolean isAccessTokenWithinRefreshInterval() {
         final long nowSecs = System.currentTimeMillis() / 1000;
         return nowSecs + refreshTokenTimeSkew > accessTokenExpiresAt;
     }
+
+    private static boolean isExpired(Long expiresAt) {
+        if (expiresAt == null) {
+            return false;
+        }
+        final long nowSecs = System.currentTimeMillis() / 1000;
+        return nowSecs > expiresAt;
+    }
 }
diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java
index 89aa4137f2f..c87c9476866 100644
--- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java
+++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java
@@ -124,20 +124,16 @@ private Tokens emitGrantTokens(HttpResponse<Buffer> resp, boolean refresh) {
         if (resp.statusCode() == 200) {
             LOG.debugf("%s OidcClient has %s the tokens", oidcConfig.getId().get(), (refresh ? "refreshed" : "acquired"));
             JsonObject json = resp.bodyAsJsonObject();
+            // access token
             final String accessToken = json.getString(oidcConfig.grant.accessTokenProperty);
+            final Long accessTokenExpiresAt = getExpiresAtValue(accessToken, json.getValue(oidcConfig.grant.expiresInProperty));
+
             final String refreshToken = json.getString(oidcConfig.grant.refreshTokenProperty);
-            final Object expiresInValue = json.getValue(oidcConfig.grant.expiresInProperty);
-            Long accessTokenExpiresAt;
-            if (expiresInValue != null) {
-                long accessTokenExpiresIn = expiresInValue instanceof Number ? ((Number) expiresInValue).longValue()
-                        : Long.parseLong(expiresInValue.toString());
-                accessTokenExpiresAt = oidcConfig.absoluteExpiresIn ? accessTokenExpiresIn
-                        : Instant.now().getEpochSecond() + accessTokenExpiresIn;
-            } else {
-                accessTokenExpiresAt = getExpiresJwtClaim(accessToken);
-            }
+            final Long refreshTokenExpiresAt = getExpiresAtValue(refreshToken,
+                    json.getValue(oidcConfig.grant.refreshExpiresInProperty));
+
             return new Tokens(accessToken, accessTokenExpiresAt, oidcConfig.refreshTokenTimeSkew.orElse(null), refreshToken,
-                    json);
+                    refreshTokenExpiresAt, json);
         } else {
             String errorMessage = resp.bodyAsString();
             LOG.debugf("%s OidcClient has failed to complete the %s grant request:  status: %d, error message: %s",
@@ -147,8 +143,19 @@ private Tokens emitGrantTokens(HttpResponse<Buffer> resp, boolean refresh) {
         }
     }
 
-    private static Long getExpiresJwtClaim(String accessToken) {
-        JsonObject claims = decodeJwtToken(accessToken);
+    private Long getExpiresAtValue(String token, Object expiresInValue) {
+        if (expiresInValue != null) {
+            long tokenExpiresIn = expiresInValue instanceof Number ? ((Number) expiresInValue).longValue()
+                    : Long.parseLong(expiresInValue.toString());
+            return oidcConfig.absoluteExpiresIn ? tokenExpiresIn
+                    : Instant.now().getEpochSecond() + tokenExpiresIn;
+        } else {
+            return token != null ? getExpiresJwtClaim(token) : null;
+        }
+    }
+
+    private static Long getExpiresJwtClaim(String token) {
+        JsonObject claims = decodeJwtToken(token);
         if (claims != null) {
             try {
                 return claims.getLong(Claims.exp.name());
diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java
index 67c05375981..ed41d5082e3 100644
--- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java
+++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java
@@ -40,9 +40,10 @@ public Uni<Tokens> getTokens(OidcClient oidcClient) {
             } else {
                 Tokens tokens = currentState.tokens;
                 if (tokens.isAccessTokenExpired() || tokens.isAccessTokenWithinRefreshInterval()) {
-                    newState = new TokenRequestState(prepareUni(tokens.getRefreshToken() != null
-                            ? oidcClient.refreshTokens(tokens.getRefreshToken())
-                            : oidcClient.getTokens()));
+                    newState = new TokenRequestState(
+                            prepareUni((tokens.getRefreshToken() != null && !tokens.isRefreshTokenExpired())
+                                    ? oidcClient.refreshTokens(tokens.getRefreshToken())
+                                    : oidcClient.getTokens()));
                     if (tokenRequestStateUpdater.compareAndSet(this, currentState, newState)) {
                         return newState.tokenUni;
                     }
diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java
index bcb7271ed7c..8251c56579b 100644
--- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java
+++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java
@@ -48,4 +48,5 @@ public final class OidcConstants {
     public static final String EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
 
     public static final String EXPIRES_IN = "expires_in";
+    public static final String REFRESH_EXPIRES_IN = "refresh_expires_in";
 }
diff --git a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java
index a2ca7c12116..ea8b059a722 100644
--- a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java
+++ b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java
@@ -50,7 +50,7 @@ public Map<String, String> start() {
                         .aResponse()
                         .withHeader("Content-Type", MediaType.APPLICATION_JSON)
                         .withBody(
-                                "{\\"access_token\\":\\"access_token_2\\", \\"expires_in\\":4, \\"refresh_token\\":\\"refresh_token_1\\"}")));
+                                "{\\"access_token\\":\\"access_token_2\\", \\"expires_in\\":4, \\"refresh_token\\":\\"refresh_token_2\\", \\"refresh_expires_in\\":1}")));
 
         server.stubFor(WireMock.post("/refresh-token-only")
                 .withRequestBody(matching("grant_type=refresh_token&refresh_token=shared_refresh_token"))
diff --git a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java
index f5a955b8549..0a274ca502f 100644
--- a/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java
+++ b/integration-tests/oidc-client-wiremock/src/test/java/io/quarkus/it/keycloak/OidcClientTest.java
@@ -30,12 +30,41 @@ public class OidcClientTest {
 
     @Test
     public void testEchoAndRefreshTokens() {
+        // access_token_1 and refresh_token_1 are acquired using a password grant request.
+        // access_token_1 expires in 4 seconds, refresh_token_1 has no lifespan limit as no `refresh_expires_in` property is returned.
+        // "Default OidcClient has acquired the tokens" record is added to the log
         RestAssured.when().get("/frontend/echoToken")
                 .then()
                 .statusCode(200)
                 .body(equalTo("access_token_1"));
 
-        // Wait until the access token has expired
+        // Wait until the access_token_1 has expired
+        waitUntillAccessTokenHasExpired();
+
+        // access_token_1 has expired, refresh_token_1 is assumed to be valid and used to acquire access_token_2 and refresh_token_2.
+        // access_token_2 expires in 4 seconds, but refresh_token_2 - in 1 sec - it will expire by the time access_token_2 has expired 
+        // "Default OidcClient has refreshed the tokens" record is added to the log
+        RestAssured.when().get("/frontend/echoToken")
+                .then()
+                .statusCode(200)
+                .body(equalTo("access_token_2"));
+
+        // Wait until the access_token_2 has expired
+        waitUntillAccessTokenHasExpired();
+
+        // Both access_token_2 and refresh_token_2 have now expired therefore a password grant request is repeated,
+        // as opposed to using a refresh token grant.
+        // access_token_1 is returned again - as the same token URL and grant properties are used and Wiremock stub returns access_token_1 
+        // 2nd "Default OidcClient has acquired the tokens" record is added to the log
+        RestAssured.when().get("/frontend/echoToken")
+                .then()
+                .statusCode(200)
+                .body(equalTo("access_token_1"));
+
+        checkLog();
+    }
+
+    private static void waitUntillAccessTokenHasExpired() {
         long expiredTokenTime = System.currentTimeMillis() + 5000;
         await().atMost(10, TimeUnit.SECONDS)
                 .pollInterval(Duration.ofSeconds(3))
@@ -45,12 +74,6 @@ public Boolean call() throws Exception {
                         return System.currentTimeMillis() > expiredTokenTime;
                     }
                 });
-
-        RestAssured.when().get("/frontend/echoToken")
-                .then()
-                .statusCode(200)
-                .body(equalTo("access_token_2"));
-        checkLog();
     }
 
     @Test
@@ -109,8 +132,8 @@ public void run() throws Throwable {
 
                             }
                         }
-                        assertEquals(1, tokenAcquisitionCount,
-                                "Log file must contain a single OidcClientImpl token acquisition confirmation");
+                        assertEquals(2, tokenAcquisitionCount,
+                                "Log file must contain two OidcClientImpl token acquisition confirmations");
                         assertEquals(1, tokenRefreshedCount,
                                 "Log file must contain a single OidcClientImpl token refresh confirmation");
                     }