Remove session_state from tokens

Closes #27624

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2024-04-02 10:49:49 +02:00 committed by Marek Posolda
parent 811c70d136
commit b4f791b632
15 changed files with 154 additions and 43 deletions

View file

@ -17,7 +17,6 @@
package org.keycloak.representations;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.TokenCategory;
@ -66,10 +65,8 @@ public class IDToken extends JsonWebToken {
protected Long auth_time;
// session_state is deprecated, sid should be used instead
@JsonProperty(SESSION_STATE)
@JsonAlias(SESSION_ID)
protected String sessionState;
@JsonProperty(SESSION_ID)
protected String sessionId;
@JsonProperty(AT_HASH)
protected String accessTokenHash;
@ -177,17 +174,22 @@ public class IDToken extends JsonWebToken {
this.auth_time = Long.valueOf(authTime);
}
@JsonProperty(SESSION_ID)
public String getSessionId() {
return sessionState;
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
/**
* @deprecated Use {@link #getSessionId()} instead.
*/
@Deprecated
@JsonIgnore
public String getSessionState() {
return sessionState;
}
public void setSessionState(String sessionState) {
this.sessionState = sessionState;
return sessionId;
}
public String getAccessTokenHash() {

View file

@ -43,7 +43,7 @@ public class RefreshToken extends AccessToken {
this.issuer = token.issuer;
this.subject = token.subject;
this.issuedFor = token.issuedFor;
this.sessionState = token.sessionState;
this.sessionId = token.sessionId;
this.nonce = token.nonce;
this.audience = new String[] { token.issuer };
this.scope = token.scope;

View file

@ -75,7 +75,7 @@ Further, when the resource server acquires the PII removed from the access token
Information that cannot be removed from a lightweight access token::
Protocol mappers can controls which information is put onto an access token and the lightweight access token use the protocol mappers. Therefore, the following information cannot be removed from the lightweight access. +
`exp`, `iat`, `jti`, `iss`, `sub`, `typ`, `azp`, `nonce`, `session_state`, `sid`, `scope`, `cnf`
`exp`, `iat`, `jti`, `iss`, `sub`, `typ`, `azp`, `nonce`, `sid`, `scope`, `cnf`
Using a lightweight access token in {project_name}::
By applying `use-lightweight-access-token` executor of <<_client_policies, client policies>> to a client, the client can receive a lightweight access token instead of an access token. The lightweight access token contains a claim controlled by a protocol mapper where its setting `Add to lightweight access token`(default OFF) is turned ON. Also, by turning ON its setting `Add to token introspection` of the protocol mapper, the client can obtain the claim by sending the access token to {project_name}'s token introspection endpoint.

View file

@ -77,4 +77,14 @@ This scope contains preconfigured protocol mappers for the following claims:
* `auth_time`
This helps to reduce even more the number of claims in a lightweight access token, but also gives the chance to configure claims that were always added automatically.
This provides additional help to reduce the number of claims in a lightweight access token, but also gives the chance to configure claims that were always added automatically.
= Removed `session_state` claim
The `session_state` claim, which contains the same value as the `sid` claim, is now removed from all tokens as it is not required according to the OpenID Connect Front-Channel Logout and OpenID Connect Back-Channel Logout specifications. The `session_state` claim remains present in the Access Token Response in accordance with OpenID Connect Session Management specification.
Note that the `setSessionState()` method is also removed from the `IDToken` class in favor of the `setSessionId()` method, and the `getSessionState()` method is now deprecated.
A new `Session State (session_state)` mapper is also included and can be assigned to client scopes (for instance `basic` client scope) to revert to the old behavior.
If an old version of the JS adapter is used, the `Session State (session_state)` mapper should also be used via client scopes as described above.

View file

@ -301,7 +301,7 @@ describe("Clients test", () => {
clientDetailsPage.goToClientScopesEvaluateGeneratedUserInfoTab();
cy.get("div#generatedIdToken").contains('"preferred_username": "admin"');
cy.get("div#generatedIdToken").contains('"session_state"');
cy.get("div#generatedIdToken").contains('"sid"');
});
});

View file

@ -1009,7 +1009,7 @@ function Keycloak (config) {
if (token) {
kc.token = token;
kc.tokenParsed = jwtDecode(token);
kc.sessionId = kc.tokenParsed.session_state;
kc.sessionId = kc.tokenParsed.sid;
kc.authenticated = true;
kc.subject = kc.tokenParsed.sub;
kc.realmAccess = kc.tokenParsed.realm_access;

View file

@ -32,7 +32,6 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiFunction;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import jakarta.ws.rs.HttpMethod;
@ -366,7 +365,7 @@ public class AuthorizationTokenService {
if (accessToken.getSessionState() == null) {
// Skip generating refresh token for accessToken without sessionState claim. This is "stateless" accessToken not pointing to any real persistent userSession
rpt.setSessionState(null);
rpt.setSessionId(null);
} else {
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()) {
responseBuilder.generateRefreshToken();

View file

@ -164,7 +164,7 @@ public class AccessTokenIntrospectionProvider implements TokenIntrospectionProvi
newToken.issuer(token.getIssuer());
newToken.setNonce(token.getNonce());
newToken.setScope(token.getScope());
newToken.setSessionState(token.getSessionState());
newToken.setSessionId(token.getSessionId());
// In the case of a refresh token, aud is a basic claim.
newToken.audience(token.getAudience());

View file

@ -991,7 +991,7 @@ public class TokenManager {
token.setAcr(acr);
}
token.setSessionState(session.getId());
token.setSessionId(session.getId());
ClientScopeModel offlineAccessScope = KeycloakModelUtils.getClientScopeByName(realm, OAuth2Constants.OFFLINE_ACCESS);
boolean offlineTokenRequested = offlineAccessScope == null ? false
: clientSessionCtx.getClientScopeIds().contains(offlineAccessScope.getId());
@ -1190,7 +1190,7 @@ public class TokenManager {
idToken.issuedFor(accessToken.getIssuedFor());
idToken.issuer(accessToken.getIssuer());
idToken.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class));
idToken.setSessionState(accessToken.getSessionState());
idToken.setSessionId(accessToken.getSessionId());
idToken.expiration(accessToken.getExpiration());
// Protocol mapper is supposed to set this in case "step_up_authentication" feature enabled

View file

@ -29,7 +29,6 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -142,7 +141,7 @@ public class ClientCredentialsGrantType extends OAuth2GrantTypeBase {
if (useRefreshToken) {
responseBuilder = responseBuilder.generateRefreshToken();
} else {
responseBuilder.getAccessToken().setSessionState(null);
responseBuilder.getAccessToken().setSessionId(null);
}
checkAndBindMtlsHoKToken(responseBuilder, useRefreshToken);

View file

@ -0,0 +1,93 @@
/*
* 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.protocol.oidc.mappers;
import org.jboss.logging.Logger;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author <a href="mailto:ggrazian@redhat.com">Giuseppe Graziano</a>
*/
public class SessionStateMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper, TokenIntrospectionTokenMapper {
public static final String PROVIDER_ID = "oidc-session-state-mapper";
private static final Logger logger = Logger.getLogger(AcrProtocolMapper.class);
private static final List<ProviderConfigProperty> configProperties = new ArrayList<>();
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Session State (session_state)";
}
@Override
public String getDisplayCategory() {
return TOKEN_MAPPER_CATEGORY;
}
@Override
public String getHelpText() {
return "Add Session State (session_state) claim";
}
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession,
ClientSessionContext clientSessionCtx) {
if (userSession != null) {
token.getOtherClaims().put(IDToken.SESSION_STATE, userSession.getId());
}
}
public static ProtocolMapperModel create(String name, boolean accessToken, boolean idToken, boolean userInfo, boolean introspectionEndpoint) {
ProtocolMapperModel mapper = new ProtocolMapperModel();
mapper.setName(name);
mapper.setProtocolMapper(PROVIDER_ID);
mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Map<String, String> config = new HashMap<>();
if (accessToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
if (userInfo) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true");
if (introspectionEndpoint) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "true");
mapper.setConfig(config);
return mapper;
}
}

View file

@ -755,7 +755,7 @@ public class AuthenticationManager {
token.type(TokenUtil.TOKEN_TYPE_KEYCLOAK_ID);
if (session != null) {
token.setSessionState(session.getId());
token.setSessionId(session.getId());
}
if (session != null && session.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0) {

View file

@ -48,3 +48,4 @@ org.keycloak.protocol.oidc.mappers.ClaimsParameterTokenMapper
org.keycloak.protocol.saml.mappers.UserAttributeNameIdMapper
org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper
org.keycloak.protocol.oidc.mappers.NonceBackwardsCompatibleMapper
org.keycloak.protocol.oidc.mappers.SessionStateMapper

View file

@ -210,7 +210,7 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest {
JsonNode jsonNode = objectMapper.readTree(tokenResponse);
assertTrue(jsonNode.get("active").asBoolean());
assertEquals(sessionId, jsonNode.get("session_state").asText());
assertEquals(sessionId, jsonNode.get("sid").asText());
assertEquals("test-app", jsonNode.get("client_id").asText());
assertTrue(jsonNode.has("exp"));
assertTrue(jsonNode.has("iat"));
@ -225,7 +225,7 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest {
assertTrue(rep.isActive());
assertEquals("test-app", rep.getClientId());
assertEquals(jsonNode.get("session_state").asText(), rep.getSessionState());
assertEquals(jsonNode.get("sid").asText(), rep.getSessionState());
assertEquals(jsonNode.get("exp").asInt(), rep.getExpiration());
assertEquals(jsonNode.get("iat").asInt(), rep.getIssuedAt());
assertEquals(jsonNode.get("nbf"), rep.getNbf());

View file

@ -37,7 +37,9 @@ import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.RoleNameMapper;
import org.keycloak.protocol.oidc.mappers.SHA256PairwiseSubMapper;
import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper;
import org.keycloak.protocol.oidc.mappers.SessionStateMapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@ -227,12 +229,12 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest(TEST_CLIENT_SECRET);
String accessToken = response.getAccessToken();
logger.debug("accessToken:" + accessToken);
assertAccessToken(oauth.verifyToken(accessToken), false, false, true);
assertAccessToken(oauth.verifyToken(accessToken), false, false, false);
oauth.clientId(RESOURCE_SERVER_CLIENT_ID);
String tokenResponse = oauth.introspectAccessTokenWithClientCredential(RESOURCE_SERVER_CLIENT_ID, RESOURCE_SERVER_CLIENT_PASSWORD, accessToken);
logger.debug("tokenResponse:" + tokenResponse);
assertTokenIntrospectionResponse(JsonSerialization.readValue(tokenResponse, AccessToken.class), false);
assertTokenIntrospectionResponse(JsonSerialization.readValue(tokenResponse, AccessToken.class), false, true, false);
} finally {
deleteProtocolMappers(protocolMappers);
}
@ -257,7 +259,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
oauth.clientId(RESOURCE_SERVER_CLIENT_ID);
String tokenResponse = oauth.introspectAccessTokenWithClientCredential(RESOURCE_SERVER_CLIENT_ID, RESOURCE_SERVER_CLIENT_PASSWORD, exchangedTokenString);
logger.debug("tokenResponse:" + tokenResponse);
assertTokenIntrospectionResponse(JsonSerialization.readValue(tokenResponse, AccessToken.class), true, true, true);
assertTokenIntrospectionResponse(JsonSerialization.readValue(tokenResponse, AccessToken.class), true, true, false);
} finally {
deleteProtocolMappers(protocolMappers);
}
@ -406,6 +408,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
Assert.assertNotNull(token.getOtherClaims().get("user-session-note"));
Assert.assertNotNull(token.getOtherClaims().get("test-claim"));
Assert.assertNotNull(token.getOtherClaims().get("group-name"));
Assert.assertNotNull(token.getOtherClaims().get(IDToken.SESSION_STATE));
}
Assert.assertNotNull(token.getAudience());
Assert.assertNotNull(token.getAcr());
@ -424,6 +427,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
Assert.assertNull(token.getOtherClaims().get("user-session-note"));
Assert.assertNull(token.getOtherClaims().get("test-claim"));
Assert.assertNull(token.getOtherClaims().get("group-name"));
Assert.assertNull(token.getOtherClaims().get(IDToken.SESSION_STATE));
}
Assert.assertNull(token.getAcr());
Assert.assertNull(token.getAllowedOrigins());
@ -439,22 +443,26 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
Assert.assertNotNull(token.getIat());
Assert.assertNotNull(token.getId());
Assert.assertNotNull(token.getType());
Assert.assertNotNull(token.getIssuedFor());
Assert.assertNotNull(token.getScope());
Assert.assertNotNull(token.getIssuer());
Assert.assertNotNull(token.getSubject());
if (isAuthCodeFlow) {
Assert.assertNotNull(token.getSessionId());
} else {
Assert.assertNull(token.getSessionId());
}
Assert.assertNotNull(token.getIssuedFor());
Assert.assertNotNull(token.getScope());
Assert.assertNotNull(token.getIssuer());
Assert.assertNotNull(token.getSubject());
}
private void assertBasicClaims(AccessToken token, boolean missing) {
private void assertBasicClaims(AccessToken token, boolean isAuthCodeFlow, boolean missing) {
if (missing) {
Assert.assertNull(token.getAuth_time());
} else {
Assert.assertNotNull(token.getAuth_time());
if (isAuthCodeFlow) {
Assert.assertNotNull(token.getAuth_time());
} else {
Assert.assertNull(token.getAuth_time());
}
}
}
@ -468,18 +476,15 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
Assert.assertNull(token.getNonce());
assertMapperClaims(token, isAddToAccessToken, isAuthCodeFlow);
assertInitClaims(token, isAuthCodeFlow);
assertBasicClaims(token, missingBasicClaims);
assertBasicClaims(token, isAuthCodeFlow, missingBasicClaims);
}
private void assertTokenIntrospectionResponse(AccessToken token, boolean isAuthCodeFlow) {
assertTokenIntrospectionResponse(token, isAuthCodeFlow, true, false);
}
private void assertTokenIntrospectionResponse(AccessToken token, boolean isAuthCodeFlow, boolean isAddToIntrospect, boolean exchangeToken) {
private void assertTokenIntrospectionResponse(AccessToken token, boolean isAuthCodeFlow, boolean isAddToIntrospect, boolean missingBasicClaims) {
Assert.assertNull(token.getNonce());
assertMapperClaims(token, isAddToIntrospect, isAuthCodeFlow);
assertInitClaims(token, isAuthCodeFlow);
assertIntrospectClaims(token);
assertBasicClaims(token, isAuthCodeFlow, missingBasicClaims);
}
protected RealmResource testRealm() {
@ -592,6 +597,8 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
}});
protocolMapperList.add(pairwiseSubMapper);
}
ProtocolMapperRepresentation sessionStateMapper = createClaimMapper("session-state-mapper", SessionStateMapper.PROVIDER_ID, config);
protocolMapperList.add(sessionStateMapper);
}
private static ProtocolMapperRepresentation createClaimMapper(String name, String providerId, Map<String, String> config) {
@ -604,7 +611,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
}
private void deleteProtocolMappers(ProtocolMappersResource protocolMappers) {
List<String> mapperNames = new ArrayList<>(Arrays.asList("reference", "audience", "role-name", "group-member", "hardcoded-claim", "hardcoded-role", "user-session-note", "pairwise-sub-mapper"));
List<String> mapperNames = new ArrayList<>(Arrays.asList("reference", "audience", "role-name", "group-member", "hardcoded-claim", "hardcoded-role", "user-session-note", "pairwise-sub-mapper", "session-state-mapper"));
List<ProtocolMapperRepresentation> mappers = new ArrayList<>();
for (String mapperName : mapperNames) {
mappers.add(ProtocolMapperUtil.getMapperByNameAndProtocol(protocolMappers, OIDCLoginProtocol.LOGIN_PROTOCOL, mapperName));