From 1864cf1827ddd613bc6abc643e66621308932038 Mon Sep 17 00:00:00 2001 From: mposolda Date: Fri, 12 Jul 2024 16:05:55 +0200 Subject: [PATCH] Offline tokens created in Keycloak 14 or earlier will not work on Keycloak 25 closes #31224 Signed-off-by: mposolda --- .../org/keycloak/representations/IDToken.java | 4 +- .../representations/RefreshToken.java | 7 + .../oauth/OfflineTokenMigrationTest.java | 127 ++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenMigrationTest.java 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 { + } +}