diff --git a/core/src/main/java/org/keycloak/representations/IDToken.java b/core/src/main/java/org/keycloak/representations/IDToken.java
index 33c54b70ef..8f7f25958f 100755
--- a/core/src/main/java/org/keycloak/representations/IDToken.java
+++ b/core/src/main/java/org/keycloak/representations/IDToken.java
@@ -140,7 +140,7 @@ public class IDToken extends JsonWebToken {
// Financial API - Part 2: Read and Write API Security Profile
// http://openid.net/specs/openid-financial-api-part-2.html#authorization-server
@JsonProperty(S_HASH)
- protected String stateHash;
+ protected String stateHash;
public String getNonce() {
return nonce;
@@ -172,7 +172,7 @@ public class IDToken extends JsonWebToken {
@Deprecated
@JsonIgnore
public String getSessionState() {
- return sessionId;
+ return getSessionId();
}
public String getAccessTokenHash() {
diff --git a/core/src/main/java/org/keycloak/representations/RefreshToken.java b/core/src/main/java/org/keycloak/representations/RefreshToken.java
index b7c0f77b13..2f6b5a1b75 100755
--- a/core/src/main/java/org/keycloak/representations/RefreshToken.java
+++ b/core/src/main/java/org/keycloak/representations/RefreshToken.java
@@ -53,4 +53,11 @@ public class RefreshToken extends AccessToken {
public TokenCategory getCategory() {
return TokenCategory.INTERNAL;
}
+
+ @Override
+ public String getSessionId() {
+ String sessionId = super.getSessionId();
+ // Fallback as offline tokens created in Keycloak 14 or earlier have only the "session_state" claim, but not "sid"
+ return sessionId != null ? sessionId : (String) getOtherClaims().get(IDToken.SESSION_STATE);
+ }
}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenMigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenMigrationTest.java
new file mode 100644
index 0000000000..93fa9f0150
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenMigrationTest.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.keycloak.testsuite.oauth;
+
+import java.io.Serializable;
+import java.util.Map;
+import java.util.function.BiFunction;
+
+import org.jboss.arquillian.graphene.page.Page;
+import org.junit.Assert;
+import org.junit.Test;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.crypto.SignatureProvider;
+import org.keycloak.crypto.SignatureSignerContext;
+import org.keycloak.jose.jws.JWSBuilder;
+import org.keycloak.jose.jws.JWSInput;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.IDToken;
+import org.keycloak.representations.RefreshToken;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
+import org.keycloak.testsuite.pages.LoginPage;
+import org.keycloak.testsuite.runonserver.FetchOnServer;
+import org.keycloak.testsuite.runonserver.FetchOnServerWrapper;
+import org.keycloak.testsuite.util.OAuthClient;
+import org.keycloak.util.JsonSerialization;
+
+/**
+ * Test for simulating token refresh with the offline tokens created in older Keycloak versions.
+ *
+ * Keycloak supports refresh of the offline tokens, which were created in older Keycloak versions than current Keycloak version. But
+ * testing real migration is sometimes hard to achieve as it requires running of the old Keycloak server, which is sometimes not feasible.
+ *
+ * This test just simulates the refresh with the old offline-token by manually converting offline-token to the offline-token format, which was used by the specified old Keycloak version
+ *
+ * @author Marek Posolda
+ */
+public class OfflineTokenMigrationTest extends AbstractTestRealmKeycloakTest {
+
+ @Page
+ protected LoginPage loginPage;
+
+ @Override
+ public void configureTestRealm(RealmRepresentation testRealm) {
+
+ }
+
+ // Issue 31224
+ // Test refresh with the offline-token created in Keycloak 14 works
+ @Test
+ public void testOfflineTokenMigrationFromKeycloak14() throws Exception {
+ OfflineTokenConverter convertOfflineTokenToKeycloak14Format = (session, oldOfflineToken) -> {
+ try {
+ RefreshToken refreshToken = session.tokens().decode(oldOfflineToken, RefreshToken.class);
+ String sessionId = refreshToken.getSessionId();
+ String signatureAlgorithm = new JWSInput(oldOfflineToken).getHeader().getAlgorithm().toString();
+
+ Map asMap = JsonSerialization.readValue(JsonSerialization.writeValueAsString(refreshToken), Map.class);
+ asMap.remove(IDToken.SESSION_ID);
+ asMap.put(IDToken.SESSION_STATE, sessionId);
+
+ SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, signatureAlgorithm);
+ SignatureSignerContext signer = signatureProvider.signer();
+
+ String type = "JWT";
+ return new JWSBuilder().type(type).jsonContent(asMap).sign(signer);
+ } catch (Exception ioe) {
+ throw new RuntimeException(ioe);
+ }
+ };
+
+ testOfflineTokenMigration(convertOfflineTokenToKeycloak14Format);
+ }
+
+ private void testOfflineTokenMigration(OfflineTokenConverter offlineTokenConverter) throws Exception {
+ // Send request to obtain offline token
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.clientId("direct-grant");
+
+ OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password");
+ Assert.assertNull(tokenResponse.getErrorDescription());
+ String offlineTokenString = tokenResponse.getRefreshToken();
+
+ // Convert offline token to the format of some old Keycloak version
+ FetchOnServerWrapper fetch = new FetchOnServerWrapper<>() {
+
+ @Override
+ public FetchOnServer getRunOnServer() {
+ return session -> offlineTokenConverter.apply(session, offlineTokenString);
+ }
+
+ @Override
+ public Class getResultClass() {
+ return String.class;
+ }
+
+ };
+ String modifiedOfflineToken = testingClient.server("test").fetch(fetch);
+ getLogger().infof("Modified offline token: %s", modifiedOfflineToken);
+
+ // Check it is possible to successfully refresh with the modified offline token
+ OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(modifiedOfflineToken, "password");
+ AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken());
+ Assert.assertEquals(200, response.getStatusCode());
+ }
+
+ public interface OfflineTokenConverter extends Serializable, BiFunction {
+ }
+}