Check offline scope is still assigned when performing a refresh

Closes #43734


(cherry picked from commit e0c1f2ee0fd14ba76338d9c2c213d45d0e857450)

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
Ricardo Martin 2025-10-29 13:53:14 +01:00 committed by GitHub
parent 4cd381edbf
commit 50102e50de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 118 additions and 0 deletions

View File

@ -205,6 +205,14 @@ public class AccessTokenIntrospectionProvider<T extends AccessToken> implements
return false;
}
if (userSession.isOffline() && !UserSessionUtil.isOfflineAccessGranted(
session, userSession.getAuthenticatedClientSessionByClient(client.getId()))) {
logger.debugf("Offline session invalid because offline access not granted anymore");
eventBuilder.detail(Details.REASON, "Offline session invalid because offline access not granted anymore");
eventBuilder.error(Errors.SESSION_EXPIRED);
return false;
}
if (!verifyTokenReuse()) {
return false;
}

View File

@ -228,6 +228,10 @@ public class TokenManager {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
}
if (userSession.isOffline() && !UserSessionUtil.isOfflineAccessGranted(session, clientSession)) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Offline session invalid because offline access not granted anymore");
}
// Case when offline token is migrated from previous version
if (oldTokenScope == null && userSession.isOffline()) {
logger.debugf("Migrating offline token of user '%s' for client '%s' of realm '%s'", user.getUsername(), client.getClientId(), realm.getName());

View File

@ -6,12 +6,14 @@ import java.util.Objects;
import java.util.function.Consumer;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.events.Errors;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.ImpersonationSessionNote;
import org.keycloak.models.KeycloakSession;
@ -189,6 +191,16 @@ public class UserSessionUtil {
};
}
public static boolean isOfflineAccessGranted(KeycloakSession session, AuthenticatedClientSessionModel clientSession) {
if (clientSession == null) {
return false;
}
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(
clientSession, OAuth2Constants.OFFLINE_ACCESS, session);
return clientSessionCtx.getClientScopesStream().anyMatch((s -> OAuth2Constants.OFFLINE_ACCESS.equals(s.getName())));
}
private static void attachAuthenticationSession(KeycloakSession session, UserSessionModel userSession, ClientModel client) {
RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(userSession.getRealm());
AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client);

View File

@ -164,6 +164,11 @@ public class ClientAttributeUpdater extends ServerResourceUpdater<ClientAttribut
return this;
}
public ClientAttributeUpdater removeOptionalClientScope(String clientScope) {
rep.getOptionalClientScopes().remove(clientScope);
return this;
}
public ClientAttributeUpdater setDirectAccessGrantsEnabled(Boolean directAccessGranted) {
rep.setDirectAccessGrantsEnabled(directAccessGranted);
return this;

View File

@ -23,6 +23,7 @@ import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
@ -61,6 +62,7 @@ import org.keycloak.testsuite.ProfileAssume;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.auth.page.AuthRealm;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.ClientManager;
@ -71,6 +73,7 @@ import org.keycloak.testsuite.util.RoleBuilder;
import org.keycloak.testsuite.util.TokenSignatureUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.oauth.IntrospectionResponse;
import org.keycloak.testsuite.util.oauth.LogoutResponse;
import org.keycloak.testsuite.utils.tls.TLSUtils;
import org.keycloak.util.TokenUtil;
@ -1561,4 +1564,90 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
changeOfflineSessionSettings(false, prevOfflineSession[0], prevOfflineSession[1], prevOfflineSession[2], prevOfflineSession[3]);
}
}
@Test
public void offlineRefreshWhenNoOfflineScope() throws Exception {
// login to obtain a refresh token
oauth.scope("openid " + OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(offlineClientAppUri);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
EventRepresentation loginEvent = events.expectLogin()
.client("offline-client")
.detail(Details.REDIRECT_URI, offlineClientAppUri)
.assertEvent();
events.expectCodeToToken(loginEvent.getDetails().get(Details.CODE_ID), loginEvent.getSessionId())
.client("offline-client")
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
.assertEvent();
// check refresh is successful
RefreshToken offlineToken = oauth.parseRefreshToken(response.getRefreshToken());
oauth.scope(null);
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(200, response.getStatusCode());
Assert.assertEquals(0, response.getRefreshExpiresIn());
events.expectRefresh(offlineToken.getId(), loginEvent.getSessionId())
.client("offline-client")
.user(userId)
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
.detail(Details.REFRESH_TOKEN_ID, offlineToken.getId())
.assertEvent();
offlineToken = oauth.parseRefreshToken(response.getRefreshToken());
IntrospectionResponse introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken());
assertTrue(introspectionResponse.asJsonNode().get("active").asBoolean());
events.expect(EventType.INTROSPECT_TOKEN)
.client("offline-client")
.session(loginEvent.getSessionId())
.assertEvent();
introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getRefreshToken());
assertTrue(introspectionResponse.asJsonNode().get("active").asBoolean());
events.expect(EventType.INTROSPECT_TOKEN)
.client("offline-client")
.session(loginEvent.getSessionId())
.assertEvent();
// remove offline scope from the client and perform a second refresh
try (ClientAttributeUpdater updater = ClientAttributeUpdater.forClient(adminClient, TEST, "offline-client")
.removeOptionalClientScope("offline_access").update()) {
introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken());
assertFalse(introspectionResponse.asJsonNode().get("active").asBoolean());
events.expect(EventType.INTROSPECT_TOKEN_ERROR)
.client("offline-client")
.session(loginEvent.getSessionId())
.error(Errors.SESSION_EXPIRED)
.detail(Details.REASON, "Offline session invalid because offline access not granted anymore")
.assertEvent();
introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getRefreshToken());
assertFalse(introspectionResponse.asJsonNode().get("active").asBoolean());
events.expect(EventType.INTROSPECT_TOKEN_ERROR)
.client("offline-client")
.session(loginEvent.getSessionId())
.error(Errors.SESSION_EXPIRED)
.detail(Details.REASON, "Offline session invalid because offline access not granted anymore")
.assertEvent();
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(400, response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_GRANT, response.getError());
assertEquals("Offline session invalid because offline access not granted anymore", response.getErrorDescription());
events.expect(EventType.REFRESH_TOKEN_ERROR)
.client("offline-client")
.session(loginEvent.getSessionId())
.user((String) null)
.error(Errors.INVALID_TOKEN)
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
.detail(Details.REFRESH_TOKEN_ID, offlineToken.getId())
.detail(Details.REASON, "Offline session invalid because offline access not granted anymore")
.assertEvent();
}
}
}