Skip to content

Commit 1007ece

Browse files
mposoldaahus1
authored andcommitted
Offline tokens created in Keycloak 14 or earlier will not work on Keycloak 25
closes #31224 Signed-off-by: mposolda <[email protected]> (cherry picked from commit 1864cf1)
1 parent cd50c8a commit 1007ece

File tree

3 files changed

+136
-2
lines changed

3 files changed

+136
-2
lines changed

core/src/main/java/org/keycloak/representations/IDToken.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ public class IDToken extends JsonWebToken {
140140
// Financial API - Part 2: Read and Write API Security Profile
141141
// http://openid.net/specs/openid-financial-api-part-2.html#authorization-server
142142
@JsonProperty(S_HASH)
143-
protected String stateHash;
143+
protected String stateHash;
144144

145145
public String getNonce() {
146146
return nonce;
@@ -172,7 +172,7 @@ public void setSessionId(String sessionId) {
172172
@Deprecated
173173
@JsonIgnore
174174
public String getSessionState() {
175-
return sessionId;
175+
return getSessionId();
176176
}
177177

178178
public String getAccessTokenHash() {

core/src/main/java/org/keycloak/representations/RefreshToken.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,11 @@ public RefreshToken(AccessToken token) {
5353
public TokenCategory getCategory() {
5454
return TokenCategory.INTERNAL;
5555
}
56+
57+
@Override
58+
public String getSessionId() {
59+
String sessionId = super.getSessionId();
60+
// Fallback as offline tokens created in Keycloak 14 or earlier have only the "session_state" claim, but not "sid"
61+
return sessionId != null ? sessionId : (String) getOtherClaims().get(IDToken.SESSION_STATE);
62+
}
5663
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2024 Red Hat, Inc. and/or its affiliates
3+
* and other contributors as indicated by the @author tags.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
*
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*
18+
*/
19+
20+
package org.keycloak.testsuite.oauth;
21+
22+
import java.io.Serializable;
23+
import java.util.Map;
24+
import java.util.function.BiFunction;
25+
26+
import org.jboss.arquillian.graphene.page.Page;
27+
import org.junit.Assert;
28+
import org.junit.Test;
29+
import org.keycloak.OAuth2Constants;
30+
import org.keycloak.crypto.SignatureProvider;
31+
import org.keycloak.crypto.SignatureSignerContext;
32+
import org.keycloak.jose.jws.JWSBuilder;
33+
import org.keycloak.jose.jws.JWSInput;
34+
import org.keycloak.models.KeycloakSession;
35+
import org.keycloak.representations.AccessToken;
36+
import org.keycloak.representations.IDToken;
37+
import org.keycloak.representations.RefreshToken;
38+
import org.keycloak.representations.idm.RealmRepresentation;
39+
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
40+
import org.keycloak.testsuite.pages.LoginPage;
41+
import org.keycloak.testsuite.runonserver.FetchOnServer;
42+
import org.keycloak.testsuite.runonserver.FetchOnServerWrapper;
43+
import org.keycloak.testsuite.util.OAuthClient;
44+
import org.keycloak.util.JsonSerialization;
45+
46+
/**
47+
* Test for simulating token refresh with the offline tokens created in older Keycloak versions.
48+
*
49+
* Keycloak supports refresh of the offline tokens, which were created in older Keycloak versions than current Keycloak version. But
50+
* testing real migration is sometimes hard to achieve as it requires running of the old Keycloak server, which is sometimes not feasible.
51+
*
52+
* 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
53+
*
54+
* @author <a href="mailto:[email protected]">Marek Posolda</a>
55+
*/
56+
public class OfflineTokenMigrationTest extends AbstractTestRealmKeycloakTest {
57+
58+
@Page
59+
protected LoginPage loginPage;
60+
61+
@Override
62+
public void configureTestRealm(RealmRepresentation testRealm) {
63+
64+
}
65+
66+
// Issue 31224
67+
// Test refresh with the offline-token created in Keycloak 14 works
68+
@Test
69+
public void testOfflineTokenMigrationFromKeycloak14() throws Exception {
70+
OfflineTokenConverter convertOfflineTokenToKeycloak14Format = (session, oldOfflineToken) -> {
71+
try {
72+
RefreshToken refreshToken = session.tokens().decode(oldOfflineToken, RefreshToken.class);
73+
String sessionId = refreshToken.getSessionId();
74+
String signatureAlgorithm = new JWSInput(oldOfflineToken).getHeader().getAlgorithm().toString();
75+
76+
Map<String, String> asMap = JsonSerialization.readValue(JsonSerialization.writeValueAsString(refreshToken), Map.class);
77+
asMap.remove(IDToken.SESSION_ID);
78+
asMap.put(IDToken.SESSION_STATE, sessionId);
79+
80+
SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, signatureAlgorithm);
81+
SignatureSignerContext signer = signatureProvider.signer();
82+
83+
String type = "JWT";
84+
return new JWSBuilder().type(type).jsonContent(asMap).sign(signer);
85+
} catch (Exception ioe) {
86+
throw new RuntimeException(ioe);
87+
}
88+
};
89+
90+
testOfflineTokenMigration(convertOfflineTokenToKeycloak14Format);
91+
}
92+
93+
private void testOfflineTokenMigration(OfflineTokenConverter offlineTokenConverter) throws Exception {
94+
// Send request to obtain offline token
95+
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
96+
oauth.clientId("direct-grant");
97+
98+
OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("password", "test-user@localhost", "password");
99+
Assert.assertNull(tokenResponse.getErrorDescription());
100+
String offlineTokenString = tokenResponse.getRefreshToken();
101+
102+
// Convert offline token to the format of some old Keycloak version
103+
FetchOnServerWrapper<String> fetch = new FetchOnServerWrapper<>() {
104+
105+
@Override
106+
public FetchOnServer getRunOnServer() {
107+
return session -> offlineTokenConverter.apply(session, offlineTokenString);
108+
}
109+
110+
@Override
111+
public Class<String> getResultClass() {
112+
return String.class;
113+
}
114+
115+
};
116+
String modifiedOfflineToken = testingClient.server("test").fetch(fetch);
117+
getLogger().infof("Modified offline token: %s", modifiedOfflineToken);
118+
119+
// Check it is possible to successfully refresh with the modified offline token
120+
OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(modifiedOfflineToken, "password");
121+
AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken());
122+
Assert.assertEquals(200, response.getStatusCode());
123+
}
124+
125+
public interface OfflineTokenConverter extends Serializable, BiFunction<KeycloakSession, String, String> {
126+
}
127+
}

0 commit comments

Comments
 (0)