Add parameter column to consent tables for dynamic scopes

Closes #9686

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
Ricardo Martin 2026-05-27 18:14:20 +02:00 committed by GitHub
parent 8d94475879
commit 63e3bb9a6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 531 additions and 164 deletions

View file

@ -848,7 +848,12 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC
for (String clientScopeId : cachedConsent.getClientScopeIds()) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, client, clientScopeId);
if (clientScope != null) {
consentModel.addGrantedClientScope(clientScope);
if (ClientScopeModel.isDynamicScope(clientScope)) {
cachedConsent.getParameters(clientScopeId).stream()
.forEach(p -> consentModel.addGrantedClientScope(clientScope, p));
} else {
consentModel.addGrantedClientScope(clientScope);
}
}
}

View file

@ -18,8 +18,10 @@
package org.keycloak.models.cache.infinispan.entities;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.UserConsentModel;
@ -30,6 +32,7 @@ public class CachedUserConsent {
private final String clientDbId;
private final Set<String> clientScopeIds = new HashSet<>();
private final MultivaluedHashMap<String,String> parameters = new MultivaluedHashMap<>();
private final Long createdDate;
private final Long lastUpdatedDate;
private boolean notExistent;
@ -38,6 +41,9 @@ public class CachedUserConsent {
this.clientDbId = consentModel.getClient().getId();
for (ClientScopeModel clientScope : consentModel.getGrantedClientScopes()) {
this.clientScopeIds.add(clientScope.getId());
if (ClientScopeModel.isDynamicScope(clientScope)) {
this.parameters.addAll(clientScope.getId(), consentModel.getParameters(clientScope));
}
}
this.createdDate = consentModel.getCreatedDate();
this.lastUpdatedDate = consentModel.getLastUpdatedDate();
@ -58,6 +64,10 @@ public class CachedUserConsent {
return clientScopeIds;
}
public List<String> getParameters(String scopeId) {
return parameters.getList(scopeId);
}
public Long getCreatedDate() {
return createdDate;
}

View file

@ -334,7 +334,10 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
for (UserConsentClientScopeEntity grantedClientScope : grantedClientScopeEntities) {
ClientScopeModel grantedClientScopeModel = KeycloakModelUtils.findClientScopeById(realm, client, grantedClientScope.getScopeId());
if (grantedClientScopeModel != null) {
model.addGrantedClientScope(grantedClientScopeModel);
model.addGrantedClientScope(grantedClientScopeModel,
ClientScopeModel.isDynamicScope(grantedClientScopeModel)
? grantedClientScope.getParameter().orElse(null)
: null);
}
}
}
@ -348,17 +351,11 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
Collection<UserConsentClientScopeEntity> scopesToRemove = new HashSet<>(grantedClientScopeEntities);
for (ClientScopeModel clientScope : consentModel.getGrantedClientScopes()) {
UserConsentClientScopeEntity grantedClientScopeEntity = new UserConsentClientScopeEntity();
grantedClientScopeEntity.setUserConsent(consentEntity);
grantedClientScopeEntity.setScopeId(clientScope.getId());
// Check if it's already there
if (!grantedClientScopeEntities.contains(grantedClientScopeEntity)) {
em.persist(grantedClientScopeEntity);
em.flush();
grantedClientScopeEntities.add(grantedClientScopeEntity);
if (ClientScopeModel.isDynamicScope(clientScope)) {
consentModel.getParameters(clientScope).forEach(p -> createUserConsentClientScopeEntity(
consentEntity, clientScope, p, grantedClientScopeEntities, scopesToRemove));
} else {
scopesToRemove.remove(grantedClientScopeEntity);
createUserConsentClientScopeEntity(consentEntity, clientScope, null, grantedClientScopeEntities, scopesToRemove);
}
}
// Those client scopes were no longer on consentModel and will be removed
@ -372,6 +369,25 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore, JpaUs
em.flush();
}
private void createUserConsentClientScopeEntity(
UserConsentEntity consentEntity, ClientScopeModel clientScope, String parameter,
Collection<UserConsentClientScopeEntity> grantedClientScopeEntities,
Collection<UserConsentClientScopeEntity> scopesToRemove) {
UserConsentClientScopeEntity grantedClientScopeEntity = new UserConsentClientScopeEntity();
grantedClientScopeEntity.setUserConsent(consentEntity);
grantedClientScopeEntity.setScopeId(clientScope.getId());
grantedClientScopeEntity.setParameter(parameter);
// Check if it's already there
if (!grantedClientScopeEntities.contains(grantedClientScopeEntity)) {
em.persist(grantedClientScopeEntity);
em.flush();
grantedClientScopeEntities.add(grantedClientScopeEntity);
} else {
scopesToRemove.remove(grantedClientScopeEntity);
}
}
@Override
public UserVerifiableCredentialModel addVerifiableCredential(String userId, UserVerifiableCredentialModel verifCredentialModel) {
if (verifCredentialModel.getCredentialScopeName() == null) {

View file

@ -18,6 +18,7 @@
package org.keycloak.models.jpa.entities;
import java.io.Serializable;
import java.util.Optional;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@ -47,6 +48,8 @@ import jakarta.persistence.Table;
@IdClass(UserConsentClientScopeEntity.Key.class)
public class UserConsentClientScopeEntity {
public static String NOT_AVAILABLE_PARAM = "#N A#";
@Id
@ManyToOne(fetch= FetchType.LAZY)
@JoinColumn(name = "USER_CONSENT_ID")
@ -56,6 +59,10 @@ public class UserConsentClientScopeEntity {
@Column(name="SCOPE_ID")
protected String scopeId;
@Id
@Column(name="PARAMETER")
protected String parameter;
public UserConsentEntity getUserConsent() {
return userConsent;
}
@ -72,6 +79,18 @@ public class UserConsentClientScopeEntity {
this.scopeId = scopeId;
}
public Optional<String> getParameter() {
return Optional.ofNullable(NOT_AVAILABLE_PARAM.equals(parameter)
? null
: parameter);
}
public void setParameter(String parameter) {
this.parameter = parameter == null
? NOT_AVAILABLE_PARAM
: parameter;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -79,14 +98,14 @@ public class UserConsentClientScopeEntity {
if (!(o instanceof UserConsentClientScopeEntity)) return false;
UserConsentClientScopeEntity that = (UserConsentClientScopeEntity)o;
UserConsentClientScopeEntity.Key myKey = new UserConsentClientScopeEntity.Key(this.userConsent, this.scopeId);
UserConsentClientScopeEntity.Key hisKey = new UserConsentClientScopeEntity.Key(that.userConsent, that.scopeId);
UserConsentClientScopeEntity.Key myKey = new UserConsentClientScopeEntity.Key(this.userConsent, this.scopeId, this.parameter);
UserConsentClientScopeEntity.Key hisKey = new UserConsentClientScopeEntity.Key(that.userConsent, that.scopeId, that.parameter);
return myKey.equals(hisKey);
}
@Override
public int hashCode() {
UserConsentClientScopeEntity.Key myKey = new UserConsentClientScopeEntity.Key(this.userConsent, this.scopeId);
UserConsentClientScopeEntity.Key myKey = new UserConsentClientScopeEntity.Key(this.userConsent, this.scopeId, this.parameter);
return myKey.hashCode();
}
@ -96,12 +115,15 @@ public class UserConsentClientScopeEntity {
protected String scopeId;
protected String parameter;
public Key() {
}
public Key(UserConsentEntity userConsent, String scopeId) {
public Key(UserConsentEntity userConsent, String scopeId, String parameter) {
this.userConsent = userConsent;
this.scopeId = scopeId;
this.parameter = parameter;
}
public UserConsentEntity getUserConsent() {
@ -112,6 +134,10 @@ public class UserConsentClientScopeEntity {
return scopeId;
}
public String getParameter() {
return parameter;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -121,6 +147,7 @@ public class UserConsentClientScopeEntity {
if (userConsent != null ? !userConsent.getId().equals(key.userConsent != null ? key.userConsent.getId() : null) : key.userConsent != null) return false;
if (scopeId != null ? !scopeId.equals(key.scopeId) : key.scopeId != null) return false;
if (parameter != null ? !parameter.equals(key.parameter) : key.parameter != null) return false;
return true;
}
@ -129,6 +156,7 @@ public class UserConsentClientScopeEntity {
public int hashCode() {
int result = userConsent != null ? userConsent.getId().hashCode() : 0;
result = 31 * result + (scopeId != null ? scopeId.hashCode() : 0);
result = 31 * result + (parameter != null ? parameter.hashCode() : 0);
return result;
}
}

View file

@ -385,7 +385,10 @@ public class JpaUserFederatedStorageProvider implements
grantedClientScopeModel = realm.getClientById(grantedClientScope.getScopeId());
}
if (grantedClientScopeModel != null) {
model.addGrantedClientScope(grantedClientScopeModel);
model.addGrantedClientScope(grantedClientScopeModel,
ClientScopeModel.isDynamicScope(grantedClientScopeModel)
? grantedClientScope.getParameter().orElse(null)
: null);
}
}
}
@ -399,17 +402,11 @@ public class JpaUserFederatedStorageProvider implements
Collection<FederatedUserConsentClientScopeEntity> scopesToRemove = new HashSet<>(grantedClientScopeEntities);
for (ClientScopeModel clientScope : consentModel.getGrantedClientScopes()) {
FederatedUserConsentClientScopeEntity grantedClientScopeEntity = new FederatedUserConsentClientScopeEntity();
grantedClientScopeEntity.setUserConsent(consentEntity);
grantedClientScopeEntity.setScopeId(clientScope.getId());
// Check if it's already there
if (!grantedClientScopeEntities.contains(grantedClientScopeEntity)) {
em.persist(grantedClientScopeEntity);
em.flush();
grantedClientScopeEntities.add(grantedClientScopeEntity);
if (ClientScopeModel.isDynamicScope(clientScope)) {
consentModel.getParameters(clientScope).forEach(p -> createFederatedUserConsentClientScopeEntity(
consentEntity, clientScope, p, grantedClientScopeEntities, scopesToRemove));
} else {
scopesToRemove.remove(grantedClientScopeEntity);
createFederatedUserConsentClientScopeEntity(consentEntity, clientScope, null, grantedClientScopeEntities, scopesToRemove);
}
}
// Those mappers were no longer on consentModel and will be removed
@ -423,6 +420,24 @@ public class JpaUserFederatedStorageProvider implements
em.flush();
}
private void createFederatedUserConsentClientScopeEntity(
FederatedUserConsentEntity consentEntity, ClientScopeModel clientScope, String parameter,
Collection<FederatedUserConsentClientScopeEntity> grantedClientScopeEntities,
Collection<FederatedUserConsentClientScopeEntity> scopesToRemove) {
FederatedUserConsentClientScopeEntity grantedClientScopeEntity = new FederatedUserConsentClientScopeEntity();
grantedClientScopeEntity.setUserConsent(consentEntity);
grantedClientScopeEntity.setScopeId(clientScope.getId());
grantedClientScopeEntity.setParameter(parameter);
// Check if it's already there
if (!grantedClientScopeEntities.contains(grantedClientScopeEntity)) {
em.persist(grantedClientScopeEntity);
em.flush();
grantedClientScopeEntities.add(grantedClientScopeEntity);
} else {
scopesToRemove.remove(grantedClientScopeEntity);
}
}
@Override
public void setNotBeforeForUser(RealmModel realm, String userId, int notBefore) {

View file

@ -18,6 +18,7 @@
package org.keycloak.storage.jpa.entity;
import java.io.Serializable;
import java.util.Optional;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@ -30,6 +31,8 @@ import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
import org.keycloak.models.jpa.entities.UserConsentClientScopeEntity;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ -56,6 +59,10 @@ public class FederatedUserConsentClientScopeEntity {
@Column(name="SCOPE_ID")
protected String scopeId;
@Id
@Column(name="PARAMETER")
protected String parameter;
public FederatedUserConsentEntity getUserConsent() {
return userConsent;
}
@ -72,6 +79,18 @@ public class FederatedUserConsentClientScopeEntity {
this.scopeId = scopeId;
}
public Optional<String> getParameter() {
return Optional.ofNullable(UserConsentClientScopeEntity.NOT_AVAILABLE_PARAM.equals(parameter)
? null
: parameter);
}
public void setParameter(String parameter) {
this.parameter = parameter == null
? UserConsentClientScopeEntity.NOT_AVAILABLE_PARAM
: parameter;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -79,14 +98,14 @@ public class FederatedUserConsentClientScopeEntity {
if (!(o instanceof FederatedUserConsentClientScopeEntity)) return false;
FederatedUserConsentClientScopeEntity that = ( FederatedUserConsentClientScopeEntity)o;
FederatedUserConsentClientScopeEntity.Key myKey = new FederatedUserConsentClientScopeEntity.Key(this.userConsent, this.scopeId);
FederatedUserConsentClientScopeEntity.Key hisKey = new FederatedUserConsentClientScopeEntity.Key(that.userConsent, that.scopeId);
FederatedUserConsentClientScopeEntity.Key myKey = new FederatedUserConsentClientScopeEntity.Key(this.userConsent, this.scopeId, this.parameter);
FederatedUserConsentClientScopeEntity.Key hisKey = new FederatedUserConsentClientScopeEntity.Key(that.userConsent, that.scopeId, that.parameter);
return myKey.equals(hisKey);
}
@Override
public int hashCode() {
FederatedUserConsentClientScopeEntity.Key myKey = new FederatedUserConsentClientScopeEntity.Key(this.userConsent, this.scopeId);
FederatedUserConsentClientScopeEntity.Key myKey = new FederatedUserConsentClientScopeEntity.Key(this.userConsent, this.scopeId, this.parameter);
return myKey.hashCode();
}
@ -96,12 +115,15 @@ public class FederatedUserConsentClientScopeEntity {
protected String scopeId;
protected String parameter;
public Key() {
}
public Key(FederatedUserConsentEntity userConsent, String scopeId) {
public Key(FederatedUserConsentEntity userConsent, String scopeId, String parameter) {
this.userConsent = userConsent;
this.scopeId = scopeId;
this.parameter = parameter;
}
public FederatedUserConsentEntity getUserConsent() {
@ -112,6 +134,10 @@ public class FederatedUserConsentClientScopeEntity {
return scopeId;
}
public String getParameter() {
return parameter;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -121,6 +147,7 @@ public class FederatedUserConsentClientScopeEntity {
if (userConsent != null ? !userConsent.getId().equals(key.userConsent != null ? key.userConsent.getId() : null) : key.userConsent != null) return false;
if (scopeId != null ? !scopeId.equals(key.scopeId) : key.scopeId != null) return false;
if (parameter != null ? !parameter.equals(key.parameter) : key.parameter != null) return false;
return true;
}
@ -129,6 +156,7 @@ public class FederatedUserConsentClientScopeEntity {
public int hashCode() {
int result = userConsent != null ? userConsent.getId().hashCode() : 0;
result = 31 * result + (scopeId != null ? scopeId.hashCode() : 0);
result = 31 * result + (parameter != null ? parameter.hashCode() : 0);
return result;
}
}

View file

@ -200,7 +200,24 @@
<column name="OWNER_ID"/>
<column name="CONTAINER_ID"/>
</createIndex>
</changeSet>
<changeSet author="keycloak" id="26.7.0-9686-dynamic-scopes-consent">
<addColumn tableName="USER_CONSENT_CLIENT_SCOPE">
<column name="PARAMETER" type="VARCHAR(255)" defaultValue="#N A#">
<constraints nullable="false"/>
</column>
</addColumn>
<dropPrimaryKey tableName="USER_CONSENT_CLIENT_SCOPE" constraintName="CONSTRAINT_GRNTCSNT_CLSC_PM"/>
<addPrimaryKey columnNames="USER_CONSENT_ID, SCOPE_ID, PARAMETER" constraintName="CONSTRAINT_GRNTCSNT_CLSC_PM" tableName="USER_CONSENT_CLIENT_SCOPE"/>
<addColumn tableName="FED_USER_CONSENT_CL_SCOPE">
<column name="PARAMETER" type="VARCHAR(255)" defaultValue="#N A#">
<constraints nullable="false"/>
</column>
</addColumn>
<dropPrimaryKey tableName="FED_USER_CONSENT_CL_SCOPE" constraintName="CONSTRAINT_FGRNTCSNT_CLSC_PM"/>
<addPrimaryKey columnNames="USER_CONSENT_ID, SCOPE_ID, PARAMETER" constraintName="CONSTRAINT_FGRNTCSNT_CLSC_PM" tableName="FED_USER_CONSENT_CL_SCOPE"/>
</changeSet>
<changeSet author="keycloak" id="26.7.0-46204-issued-ver-credential-table">

View file

@ -1009,7 +1009,7 @@ public class DefaultExportImportManager implements ExportImportManager {
createRoleMappings(userRep, user, newRealm);
if (userRep.getClientConsents() != null) {
for (UserConsentRepresentation consentRep : userRep.getClientConsents()) {
UserConsentModel consentModel = RepresentationToModel.toModel(newRealm, consentRep);
UserConsentModel consentModel = RepresentationToModel.toModel(newRealm, consentRep, session);
session.users().addConsent(newRealm, user.getId(), consentModel);
}
}
@ -1675,7 +1675,7 @@ public class DefaultExportImportManager implements ExportImportManager {
}
if (userRep.getClientConsents() != null) {
for (UserConsentRepresentation consentRep : userRep.getClientConsents()) {
UserConsentModel consentModel = RepresentationToModel.toModel(newRealm, consentRep);
UserConsentModel consentModel = RepresentationToModel.toModel(newRealm, consentRep, session);
federatedStorage.addConsent(newRealm, userRep.getId(), consentModel);
}
}

View file

@ -17,9 +17,12 @@
package org.keycloak.models.light;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.Time;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
@ -37,6 +40,7 @@ class LightweightConsentEntity {
private String clientId;
private Long createdDate;
private Set<String> grantedClientScopesIds;
private final MultivaluedHashMap<String, String> parameters = new MultivaluedHashMap<>();
private Long lastUpdatedDate;
public static LightweightConsentEntity fromModel(UserConsentModel model) {
@ -49,8 +53,8 @@ class LightweightConsentEntity {
model.getGrantedClientScopes()
.stream()
.map(ClientScopeModel::getId)
.forEach(consentEntity::addGrantedClientScopesId);
.forEach(m -> consentEntity.addGrantedClientScopesId(m.getId(),
ClientScopeModel.isDynamicScope(m) ? model.getParameters(m) : null));
return consentEntity;
}
@ -72,9 +76,16 @@ class LightweightConsentEntity {
if (grantedClientScopesIds != null && !grantedClientScopesIds.isEmpty()) {
grantedClientScopesIds.stream()
.map(scopeId -> KeycloakModelUtils.findClientScopeById(realm, client, scopeId))
.filter(Objects::nonNull)
.forEach(model::addGrantedClientScope);
.map(scopeId -> KeycloakModelUtils.findClientScopeById(realm, client, scopeId))
.filter(Objects::nonNull)
.forEach(m -> {
List<String> parameters = entity.parameters.getList(m.getId());
if (parameters.isEmpty()) {
model.addGrantedClientScope(m);
} else {
parameters.stream().forEach(p -> model.addGrantedClientScope(m, p));
}
});
}
return model;
@ -127,28 +138,7 @@ class LightweightConsentEntity {
return grantedClientScopesIds;
}
public void removeGrantedClientScopesId(String clientScopeId) {
if (grantedClientScopesIds == null) {
return;
}
if (grantedClientScopesIds.remove(clientScopeId)) {
this.lastUpdatedDate = Time.currentTimeMillis();
}
}
public void setGrantedClientScopesIds(Set<String> clientScopeIds) {
clientScopeIds = clientScopeIds == null ? null : new HashSet<>(clientScopeIds);
if (clientScopeIds != null) {
clientScopeIds.removeIf(Objects::isNull);
if (clientScopeIds.isEmpty()) {
clientScopeIds = null;
}
}
grantedClientScopesIds = clientScopeIds;
this.lastUpdatedDate = Time.currentTimeMillis();
}
public void addGrantedClientScopesId(String clientScopeId) {
public void addGrantedClientScopesId(String clientScopeId, List<String> parameters) {
if (clientScopeId == null) {
return;
}
@ -156,9 +146,16 @@ class LightweightConsentEntity {
grantedClientScopesIds = new HashSet<>();
}
grantedClientScopesIds.add(clientScopeId);
if (CollectionUtil.isNotEmpty(parameters)) {
this.parameters.addAll(clientScopeId, parameters);
}
this.lastUpdatedDate = Time.currentTimeMillis();
}
public void addGrantedClientScopesId(String clientScopeId) {
addGrantedClientScopesId(clientScopeId, null);
}
public Long getLastUpdatedDate() {
return lastUpdatedDate;
}

View file

@ -23,7 +23,6 @@ import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.common.Profile;
@ -284,11 +283,13 @@ public class LightweightUserAdapter extends AbstractInMemoryUserAdapter {
: consent.getClient().getId();
LightweightConsentEntity userConsentEntity = getConsentEntityByClient(clientId);
userConsentEntity.setGrantedClientScopesIds(
consent.getGrantedClientScopes().stream()
.map(ClientScopeModel::getId)
.collect(Collectors.toSet())
);
for (ClientScopeModel clientScope : consent.getGrantedClientScopes()) {
if (ClientScopeModel.isDynamicScope(clientScope)) {
userConsentEntity.addGrantedClientScopesId(clientScope.getId(), consent.getParameters(clientScope));
} else {
userConsentEntity.addGrantedClientScopesId(clientScope.getId());
}
}
update();
}

View file

@ -1026,6 +1026,9 @@ public class ModelToRepresentation {
for (ClientScopeModel clientScope : model.getGrantedClientScopes()) {
if (clientScope instanceof ClientModel) {
grantedClientScopes.add(((ClientModel) clientScope).getClientId());
} else if (ClientScopeModel.isDynamicScope(clientScope)) {
model.getParameters(clientScope).stream().forEach(p ->
grantedClientScopes.add(clientScope.getDynamicScopeRegexp().replace("*", p)));
} else {
grantedClientScopes.add(clientScope.getName());
}

View file

@ -102,7 +102,10 @@ import org.keycloak.models.credential.dto.OTPSecretData;
import org.keycloak.models.credential.dto.PasswordCredentialData;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.policy.PasswordPolicyNotMetException;
import org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProvider;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.rar.AuthorizationDetails;
import org.keycloak.rar.AuthorizationRequestContext;
import org.keycloak.representations.idm.AuthenticationExecutionRepresentation;
import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
@ -962,7 +965,7 @@ public class RepresentationToModel {
return model;
}
public static UserConsentModel toModel(RealmModel newRealm, UserConsentRepresentation consentRep) {
public static UserConsentModel toModel(RealmModel newRealm, UserConsentRepresentation consentRep, KeycloakSession session) {
ClientModel client = newRealm.getClientByClientId(consentRep.getClientId());
if (client == null) {
throw new RuntimeException("Unable to find client consent mappings for client: " + consentRep.getClientId());
@ -975,10 +978,28 @@ public class RepresentationToModel {
if (consentRep.getGrantedClientScopes() != null) {
for (String scopeName : consentRep.getGrantedClientScopes()) {
ClientScopeModel clientScope = KeycloakModelUtils.getClientScopeByName(newRealm, scopeName);
if (clientScope == null) {
if (clientScope != null) {
consentModel.addGrantedClientScope(clientScope);
} else if (Profile.isFeatureEnabled(Feature.DYNAMIC_SCOPES)) {
// check for dynamic scopes
AuthorizationRequestParserProvider clientScopeParser = session.getProvider(
AuthorizationRequestParserProvider.class, "client-scope");
if (clientScopeParser == null) {
throw new RuntimeException("No provider found for authorization requests parser client-scope");
}
AuthorizationRequestContext ctx = clientScopeParser.parseScopes(client, scopeName);
AuthorizationDetails authDetails = ctx.getAuthorizationDetailEntries().stream()
.filter(a -> a.getAuthorizationDetails().getDynamicScopeParamFromCustomData() != null)
.findAny().orElse(null);
if (authDetails == null) {
throw new RuntimeException("Unable to find client scope referenced in consent mappings of user. Client scope name: " + scopeName);
}
consentModel.addGrantedClientScope(authDetails.getClientScope(), authDetails.getAuthorizationDetails().getDynamicScopeParamFromCustomData());
} else {
throw new RuntimeException("Unable to find client scope referenced in consent mappings of user. Client scope name: " + scopeName);
}
consentModel.addGrantedClientScope(clientScope);
}
}

View file

@ -111,5 +111,6 @@ org.keycloak.models.workflow.WorkflowStateSpi
org.keycloak.models.workflow.WorkflowStepSpi
org.keycloak.models.workflow.WorkflowSpi
org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorSpi
org.keycloak.protocol.oidc.rar.AuthorizationRequestParserSpi
org.keycloak.cache.AlternativeLookupSPI
org.keycloak.cache.LocalCacheSPI

View file

@ -19,6 +19,7 @@ package org.keycloak.models;
import java.util.Map;
import org.keycloak.common.Profile;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.provider.ProviderEvent;
@ -33,6 +34,16 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
*/
String VALUE_SEPARATOR = ":";
/**
* Returns true when dynamic scopes are enabled and the scope is defined as dynamic.
*
* @param scope The scope to check
* @return true when the dynamic scopes feature is enabled and the scope is dynamic, false otherwise
*/
static boolean isDynamicScope(ClientScopeModel scope) {
return Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES) && scope.isDynamicScope();
}
interface ClientScopeRemovedEvent extends ProviderEvent {
ClientScopeModel getClientScope();
@ -89,7 +100,7 @@ public interface ClientScopeModel extends ProtocolMapperContainerModel, ScopeCon
String consentScreenText = getAttribute(CONSENT_SCREEN_TEXT);
if (ObjectUtil.isBlank(consentScreenText)) {
consentScreenText = getName();
if (isDynamicScope()) {
if (isDynamicScope(this)) {
consentScreenText += ": {0}";
}
}

View file

@ -17,16 +17,21 @@
package org.keycloak.models;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.keycloak.common.util.MultivaluedHashMap;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class UserConsentModel {
private final ClientModel client;
private Set<ClientScopeModel> clientScopes = new HashSet<>();
private final Set<ClientScopeModel> clientScopes = new HashSet<>();
private final MultivaluedHashMap<String, String> parameters = new MultivaluedHashMap<>();
private Long createdDate;
private Long lastUpdatedDate;
@ -39,17 +44,43 @@ public class UserConsentModel {
}
public void addGrantedClientScope(ClientScopeModel clientScope) {
addGrantedClientScope(clientScope, null);
}
public void addGrantedClientScope(ClientScopeModel clientScope, String parameter) {
clientScopes.add(clientScope);
if (ClientScopeModel.isDynamicScope(clientScope)) {
if (parameter == null) {
throw new IllegalArgumentException("Paramater value is compulsory for Dynamic Scope " + clientScope.getName());
}
parameters.add(clientScope.getId(), parameter);
}
}
public Set<ClientScopeModel> getGrantedClientScopes() {
return clientScopes;
}
public List<String> getParameters(ClientScopeModel clientScope) {
if (ClientScopeModel.isDynamicScope(clientScope)) {
return parameters.getList(clientScope.getId());
}
return Collections.emptyList();
}
public boolean isClientScopeGranted(ClientScopeModel clientScope) {
// TODO: May need to be changed with adding support for client scopes inheritance
return isClientScopeGranted(clientScope, null);
}
public boolean isClientScopeGranted(ClientScopeModel clientScope, String parameter) {
for (ClientScopeModel apprClientScope : clientScopes) {
if (apprClientScope.getId().equals(clientScope.getId())) return true;
if (apprClientScope.getId().equals(clientScope.getId())) {
if (ClientScopeModel.isDynamicScope(clientScope)) {
return parameter != null && parameters.getList(apprClientScope.getId()).contains(parameter);
} else {
return parameter == null && parameters.getList(apprClientScope.getId()).isEmpty();
}
}
}
return false;
}

View file

@ -258,7 +258,7 @@ public class TokenManager {
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, oldTokenScope, session);
// Check user didn't revoke granted consent
if (!verifyConsentStillAvailable(session, user, client, clientSessionCtx.getClientScopesStream())) {
if (!verifyConsentStillAvailable(session, user, client, oldTokenScope)) {
throw new OAuthErrorException(OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user");
}
@ -861,24 +861,36 @@ public class TokenManager {
}
// Check if user still has granted consents to all requested client scopes
public static boolean verifyConsentStillAvailable(KeycloakSession session, UserModel user, ClientModel client,
Stream<ClientScopeModel> requestedClientScopes) {
public static boolean verifyConsentStillAvailable(KeycloakSession session, UserModel user, ClientModel client, String scopeParam) {
if (!client.isConsentRequired()) {
return true;
}
UserConsentModel grantedConsent = UserConsentManager.getConsentByClient(session, client.getRealm(), user, client.getId());
return requestedClientScopes
.filter(ClientScopeModel::isDisplayOnConsentScreen)
.noneMatch(requestedScope -> {
if (grantedConsent == null || !grantedConsent.getGrantedClientScopes().contains(requestedScope)) {
logger.debugf("Client '%s' no longer has requested consent from user '%s' for client scope '%s'",
client.getClientId(), user.getUsername(), requestedScope.getName());
return true;
}
if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) {
AuthorizationRequestContext ctx = AuthorizationContextUtil.getAuthorizationRequestContextFromScopesWithClient(
session, client, scopeParam);
for (AuthorizationDetails authDetails : ctx.getAuthorizationDetailEntries()) {
ClientScopeModel requestedScope = authDetails.getClientScope();
String parameter = authDetails.getDynamicScopeParam();
if (requestedScope.isDisplayOnConsentScreen() && (grantedConsent == null || !grantedConsent.isClientScopeGranted(requestedScope, parameter))) {
return false;
});
}
}
return true;
} else {
return getRequestedClientScopes(session, scopeParam, client, user)
.filter(ClientScopeModel::isDisplayOnConsentScreen)
.noneMatch(requestedScope -> {
if (grantedConsent == null || !grantedConsent.isClientScopeGranted(requestedScope)) {
logger.debugf("Client '%s' no longer has requested consent from user '%s' for client scope '%s'",
client.getClientId(), user.getUsername(), requestedScope.getName());
return true;
}
return false;
});
}
}
public AccessToken transformAccessToken(KeycloakSession session, AccessToken token,

View file

@ -18,8 +18,6 @@
package org.keycloak.protocol.oidc.grants;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;
import jakarta.ws.rs.core.Response;
@ -30,7 +28,6 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
@ -205,8 +202,7 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
// Compute client scopes again from scope parameter. Check if user still has them granted
// (but in code-to-token request, it could just theoretically happen that they are not available)
Supplier<Stream<ClientScopeModel>> clientScopesSupplier = () -> TokenManager.getRequestedClientScopes(session, scopeParam, client, user);
if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopesSupplier.get())) {
if (!TokenManager.verifyConsentStillAvailable(session, user, client, scopeParam)) {
String errorMessage = "Client no longer has requested consent from user";
event.detail(Details.REASON, errorMessage);
event.error(Errors.NOT_ALLOWED);

View file

@ -36,7 +36,6 @@ import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.TokenManager;
@ -46,6 +45,7 @@ import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context.BackchannelTo
import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context.BackchannelTokenResponseContext;
import org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.rar.AuthorizationDetails;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
@ -185,9 +185,7 @@ public class CibaGrantType extends OAuth2GrantTypeBase {
// (but in code-to-token request, it could just theoretically happen that they are not available)
String scopeParam = request.getScope();
if (!TokenManager
.verifyConsentStillAvailable(session,
user, client, TokenManager.getRequestedClientScopes(session, scopeParam, client, user))) {
if (!TokenManager.verifyConsentStillAvailable(session, user, client, scopeParam)) {
String errorMessage = "Client no longer has requested consent from user";
event.detail(Details.REASON, errorMessage);
event.error(Errors.NOT_ALLOWED);
@ -270,10 +268,11 @@ public class CibaGrantType extends OAuth2GrantTypeBase {
boolean updateConsentRequired = false;
for (String clientScopeId : authSession.getClientScopes()) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, client, clientScopeId);
if (clientScope != null && !grantedConsent.isClientScopeGranted(clientScope) && clientScope.isDisplayOnConsentScreen()) {
grantedConsent.addGrantedClientScope(clientScope);
for (AuthorizationDetails authDetails : AuthenticationManager.getClientScopeModelStream(session, client).toList()) {
ClientScopeModel clientScope = authDetails.getClientScope();
String parameter = authDetails.getDynamicScopeParam();
if (clientScope != null && !grantedConsent.isClientScopeGranted(clientScope, parameter) && clientScope.isDisplayOnConsentScreen()) {
grantedConsent.addGrantedClientScope(clientScope, parameter);
updateConsentRequired = true;
}
}

View file

@ -345,7 +345,7 @@ public class DeviceGrantType extends OAuth2GrantTypeBase {
// Compute client scopes again from scope parameter. Check if user still has them granted
// (but in device_code-to-token request, it could just theoretically happen that they are not available)
String scopeParam = deviceCodeModel.getScope();
if (!TokenManager.verifyConsentStillAvailable(session, user, client, TokenManager.getRequestedClientScopes(session, scopeParam, client, user))) {
if (!TokenManager.verifyConsentStillAvailable(session, user, client, scopeParam)) {
String errorMessage = "Client no longer has requested consent from user";
event.detail(Details.REASON, errorMessage);
event.error(Errors.NOT_ALLOWED);

View file

@ -212,8 +212,8 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
}
}
protected void validateConsents(UserModel targetUser, ClientSessionContext clientSessionCtx) {
if (!TokenManager.verifyConsentStillAvailable(session, targetUser, client, clientSessionCtx.getClientScopesStream())) {
protected void validateConsents(UserModel targetUser, String scope) {
if (!TokenManager.verifyConsentStillAvailable(session, targetUser, client, scope)) {
event.detail(Details.REASON, "Missing consents for Token Exchange in client " + client.getClientId());
event.error(Errors.CONSENT_DENIED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE,
@ -287,7 +287,7 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
clientSessionCtx.setAttribute(Constants.REQUESTED_AUDIENCE_CLIENTS, targetAudienceClients.toArray(ClientModel[]::new));
}
validateConsents(targetUser, clientSessionCtx);
validateConsents(targetUser, scope);
clientSessionCtx.setAttribute(Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
TokenContextEncoderProvider encoder = session.getProvider(TokenContextEncoderProvider.class);

View file

@ -107,6 +107,7 @@ import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.rar.AuthorizationDetails;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ServicesLogger;
@ -1247,7 +1248,9 @@ public class AuthenticationManager {
}
// we need to add dynamic scopes with params to the scopes to consent every time for now
if (grantedConsent == null || !grantedConsent.isClientScopeGranted(clientScope) || isDynamicScopeWithParam(authDetails)) {
AuthorizationDetailsJSONRepresentation rep = authDetails.getAuthorizationDetails();
String parameter = rep != null ? rep.getDynamicScopeParamFromCustomData() : null;
if (grantedConsent == null || !grantedConsent.isClientScopeGranted(clientScope, parameter)) {
clientScopesToDisplay.add(authDetails);
}
}
@ -1271,7 +1274,7 @@ public class AuthenticationManager {
}
private static Stream<AuthorizationDetails> getClientScopeModelStream(KeycloakSession session, ClientModel client) {
public static Stream<AuthorizationDetails> getClientScopeModelStream(KeycloakSession session, ClientModel client) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
//if Dynamic Scopes are enabled, get the scopes from the AuthorizationRequestContext, passing the session and scopes as parameters
// then concat a Stream with the ClientModel, as it's discarded in the getAuthorizationRequestContext method

View file

@ -85,7 +85,6 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
@ -97,6 +96,7 @@ import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.rar.AuthorizationDetails;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorPageException;
@ -1018,6 +1018,16 @@ public class LoginActionsService {
return Response.status(302).location(redirect).build();
}
private boolean checkGranted(AuthorizationDetails details, UserConsentModel grantedConsent) {
ClientScopeModel clientScope = details.getClientScope();
String parameter = details.getDynamicScopeParam();
if (!grantedConsent.isClientScopeGranted(clientScope, parameter) && clientScope.isDisplayOnConsentScreen()) {
grantedConsent.addGrantedClientScope(clientScope, parameter);
return true;
}
return false;
}
/**
* OAuth grant page. You should not invoked this directly!
*
@ -1062,26 +1072,19 @@ public class LoginActionsService {
return DeviceGrantType.denyOAuth2DeviceAuthorization(authSession, Error.CONSENT_DENIED, session);
}
UserConsentModel grantedConsent = UserConsentManager.getConsentByClient(session, realm, user, client.getId());
if (grantedConsent == null) {
UserConsentModel existingConsent = UserConsentManager.getConsentByClient(session, realm, user, client.getId());
UserConsentModel grantedConsent;
if (existingConsent == null) {
grantedConsent = new UserConsentModel(client);
UserConsentManager.addConsent(session, realm, user, grantedConsent);
} else {
grantedConsent = existingConsent;
}
// Update may not be required if all clientScopes were already granted (May happen for example with prompt=consent)
boolean updateConsentRequired = false;
for (String clientScopeId : authSession.getClientScopes()) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, client, clientScopeId);
if (clientScope != null) {
if (!grantedConsent.isClientScopeGranted(clientScope) && clientScope.isDisplayOnConsentScreen()) {
grantedConsent.addGrantedClientScope(clientScope);
updateConsentRequired = true;
}
} else {
logger.warnf("Client scope or client with ID '%s' not found", clientScopeId);
}
}
Boolean updateConsentRequired = AuthenticationManager.getClientScopeModelStream(session, client)
.map(d -> checkGranted(d, grantedConsent))
.reduce(Boolean::logicalOr).orElse(Boolean.FALSE);
if (updateConsentRequired) {
UserConsentManager.updateConsent(session, realm, user, grantedConsent);

View file

@ -24,7 +24,6 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.function.Function;
@ -50,7 +49,6 @@ import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.common.util.StringPropertyReplacer;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
@ -78,6 +76,7 @@ import org.keycloak.services.resources.account.resources.ResourcesService;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.theme.Theme;
import org.keycloak.theme.beans.AdvancedMessageFormatterMethod;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
@ -87,6 +86,7 @@ import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.ValidationException.Error;
import freemarker.template.TemplateModelException;
import org.jboss.resteasy.reactive.NoCache;
/**
@ -266,7 +266,7 @@ public class AccountRestService {
representation.setEffectiveUrl(ResolveRelative.resolveRelativeUri(session, model.getRootUrl(), model.getBaseUrl()));
UserConsentModel consentModel = consents.get(model.getClientId());
if(consentModel != null) {
representation.setConsent(modelToBriefRepresentation(consentModel));
representation.setConsent(modelToRepresentation(consentModel, true));
representation.setLogoUri(model.getAttribute(ClientModel.LOGO_URI));
representation.setPolicyUri(model.getAttribute(ClientModel.POLICY_URI));
representation.setTosUri(model.getAttribute(ClientModel.TOS_URI));
@ -274,25 +274,27 @@ public class AccountRestService {
return representation;
}
private ConsentRepresentation modelToRepresentation(final UserConsentModel model) {
final List<ConsentScopeRepresentation> grantedScopes = model.getGrantedClientScopes().stream()
.map(clientScopeModel -> new ConsentScopeRepresentation(
clientScopeModel.getId(),
private ConsentScopeRepresentation createContentScopeRepresentation(ClientScopeModel clientScopeModel, String parameter, boolean briefRepresentation) {
return briefRepresentation
? new ConsentScopeRepresentation(clientScopeModel.getId(),
getClientScopeName(clientScopeModel),
getClientScopeDisplayText(clientScopeModel, parameter))
: new ConsentScopeRepresentation(clientScopeModel.getId(),
getClientScopeName(clientScopeModel),
clientScopeModel.getDescription(),
clientScopeModel.getProtocol(),
getClientScopeDisplayText(clientScopeModel))
).toList();
return new ConsentRepresentation(grantedScopes, model.getCreatedDate(), model.getLastUpdatedDate());
getClientScopeDisplayText(clientScopeModel, parameter));
}
private ConsentRepresentation modelToBriefRepresentation(final UserConsentModel model) {
final List<ConsentScopeRepresentation> grantedScopes = model.getGrantedClientScopes().stream()
.map(clientScopeModel -> new ConsentScopeRepresentation(
clientScopeModel.getId(),
getClientScopeName(clientScopeModel),
getClientScopeDisplayText(clientScopeModel))
).toList();
private ConsentRepresentation modelToRepresentation(UserConsentModel model, boolean briefRepresentation) {
List<ConsentScopeRepresentation> grantedScopes = new ArrayList<>();
model.getGrantedClientScopes().stream().forEach(m -> {
if (ClientScopeModel.isDynamicScope(m)) {
model.getParameters(m).forEach(p -> grantedScopes.add(createContentScopeRepresentation(m, p, briefRepresentation)));
} else {
grantedScopes.add(createContentScopeRepresentation(m, null, briefRepresentation));
}
});
return new ConsentRepresentation(grantedScopes, model.getCreatedDate(), model.getLastUpdatedDate());
}
@ -303,12 +305,21 @@ public class AccountRestService {
return clientScopeModel.getConsentScreenText();
}
private String getClientScopeDisplayText(final ClientScopeModel clientScopeModel) {
final var consentScreenText = clientScopeModel.getConsentScreenText();
return StringPropertyReplacer.replaceProperties(
consentScreenText,
Objects.requireNonNull(getProperties())::getProperty
);
private String getClientScopeDisplayText(final ClientScopeModel clientScopeModel, final String parameter) {
if (clientScopeModel.getConsentScreenText() == null) {
return null;
}
AdvancedMessageFormatterMethod method = new AdvancedMessageFormatterMethod(locale, getProperties());
List<String> inputs = new ArrayList<>();
inputs.add(clientScopeModel.getConsentScreenText());
if (parameter != null) {
inputs.add(parameter);
}
try {
return (String) method.exec(inputs);
} catch (TemplateModelException e) {
return clientScopeModel.getConsentScreenText();
}
}
private Properties getProperties() {
@ -343,11 +354,7 @@ public class AccountRestService {
return Response.noContent().build();
}
if (briefRepresentation) {
return Response.ok(modelToBriefRepresentation(consent)).build();
}
return Response.ok(modelToRepresentation(consent)).build();
return Response.ok(modelToRepresentation(consent, briefRepresentation)).build();
}
/**
@ -442,7 +449,7 @@ public class AccountRestService {
String scopeString = grantedConsent.getGrantedClientScopes().stream().map(cs->cs.getName()).collect(Collectors.joining(" "));
event.detail(Details.SCOPE, scopeString).success();
grantedConsent = UserConsentManager.getConsentByClient(session, realm, user, client.getId());
return Response.ok(modelToBriefRepresentation(grantedConsent)).build();
return Response.ok(modelToRepresentation(grantedConsent, true)).build();
} catch (IllegalArgumentException e) {
throw ErrorResponse.error(e.getMessage(), Response.Status.BAD_REQUEST);
}
@ -472,9 +479,13 @@ public class AccountRestService {
String msg = String.format("Scope id %s does not exist for client %s.", scopeRepresentation, consent.getClient().getName());
event.error(msg);
throw new IllegalArgumentException(msg);
} else {
consent.addGrantedClientScope(scopeModel);
}
if (ClientScopeModel.isDynamicScope(scopeModel)) {
String msg = String.format("Cannot create Scope id %s for client %s because is dynamic.", scopeRepresentation, consent.getClient().getName());
event.error(msg);
throw new IllegalArgumentException(msg);
}
consent.addGrantedClientScope(scopeModel, null);
}
return consent;
}

View file

@ -28,7 +28,6 @@ org.keycloak.encoding.ResourceEncodingSpi
org.keycloak.protocol.oidc.encode.TokenContextEncoderSpi
org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelSpi
org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverSpi
org.keycloak.protocol.oidc.rar.AuthorizationRequestParserSpi
org.keycloak.services.resources.admin.ext.AdminRealmResourceSpi
org.keycloak.theme.freemarker.FreeMarkerSPI
org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderSpi

View file

@ -32,17 +32,21 @@ import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testframework.ui.annotations.InjectPage;
import org.keycloak.testframework.ui.page.OAuthGrantPage;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.suites.DatabaseTest;
import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
*
* @author rmartinc
*/
@DatabaseTest
@KeycloakIntegrationTest(config = DynamicScopesOAuthGrantTest.DynamicScopesServerConfig.class)
public class DynamicScopesOAuthGrantTest {
@ -79,8 +83,8 @@ public class DynamicScopesOAuthGrantTest {
thirdParty.admin().addOptionalClientScope(DYNAMIC_SCOPE_ID);
}
@BeforeEach
public void beforeEach() {
@AfterEach
public void afterEach() {
// logout user and revoke consents if present
AccountHelper.logout(realm.admin(), DEFAULT_USERNAME);
List<Map<String, Object>> userConsents = AccountHelper.getUserConsents(realm.admin(), DEFAULT_USERNAME);
@ -95,18 +99,19 @@ public class DynamicScopesOAuthGrantTest {
// login using the dynamic scope
oauth.client(THIRD_PARTY_APP, "password");
oauth.scope("foo-dynamic-scope:withparam");
oauth.scope("foo-dynamic-scope:param1");
oauth.openLoginForm();
oauth.fillLoginForm(DEFAULT_USERNAME, DEFAULT_PASSWORD);
grantPage.assertCurrent();
List<String> grants = grantPage.getDisplayedGrants();
Assertions.assertTrue(grants.contains("foo-dynamic-scope: withparam"));
Assertions.assertTrue(grants.contains("foo-dynamic-scope: param1"));
grantPage.accept();
EventRepresentation loginEvent = events.poll();
EventAssertion.assertSuccess(loginEvent).type(EventType.LOGIN)
.clientId(THIRD_PARTY_APP)
.details(Details.REDIRECT_URI, oauth.getRedirectUri())
.details(Details.USERNAME, DEFAULT_USERNAME)
.details(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED);
String code = oauth.parseLoginResponse().getCode();
@ -117,6 +122,12 @@ public class DynamicScopesOAuthGrantTest {
.sessionId(loginEvent.getSessionId())
.details(Details.CODE_ID, loginEvent.getDetails().get(Details.CODE_ID));
List<Map<String, Object>> userConsents = AccountHelper.getUserConsents(realm.admin(), DEFAULT_USERNAME);
Assertions.assertTrue(((List) userConsents.get(0).get("grantedClientScopes")).stream().anyMatch(p -> p.equals("foo-dynamic-scope:param1")));
res = oauth.doRefreshTokenRequest(res.getRefreshToken());
MatcherAssert.assertThat(List.of(res.getScope().split(" ")), Matchers.hasItems("foo-dynamic-scope:param1"));
oauth.logoutForm().idTokenHint(res.getIdToken()).open();
EventAssertion.assertSuccess(events.poll()).type(EventType.LOGOUT)
@ -124,20 +135,57 @@ public class DynamicScopesOAuthGrantTest {
.clientId(THIRD_PARTY_APP)
.withoutDetails(Details.REDIRECT_URI);
// login again to check whether the Dynamic scope and only the dynamic scope is requested again
oauth.scope("foo-dynamic-scope:withparam");
// login again with the same param and a new one, only param2 should be requested
oauth.scope("foo-dynamic-scope:param1 foo-dynamic-scope:param2");
oauth.openLoginForm();
oauth.fillLoginForm(DEFAULT_USERNAME, DEFAULT_PASSWORD);
grantPage.assertCurrent();
grants = grantPage.getDisplayedGrants();
Assertions.assertEquals(1, grants.size());
Assertions.assertTrue(grants.contains("foo-dynamic-scope: withparam"));
Assertions.assertTrue(grants.contains("foo-dynamic-scope: param2"));
grantPage.accept();
loginEvent = events.poll();
EventAssertion.expectLoginSuccess(loginEvent)
.clientId(THIRD_PARTY_APP)
.details(Details.REDIRECT_URI, oauth.getRedirectUri())
.details(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED);
code = oauth.parseLoginResponse().getCode();
res = oauth.doAccessTokenRequest(code);
EventAssertion.assertSuccess(events.poll()).type(EventType.CODE_TO_TOKEN)
.clientId(THIRD_PARTY_APP)
.sessionId(loginEvent.getSessionId())
.details(Details.CODE_ID, loginEvent.getDetails().get(Details.CODE_ID));
userConsents = AccountHelper.getUserConsents(realm.admin(), DEFAULT_USERNAME);
Assertions.assertTrue(((List) userConsents.get(0).get("grantedClientScopes")).stream().anyMatch(p -> p.equals("foo-dynamic-scope:param1")));
Assertions.assertTrue(((List) userConsents.get(0).get("grantedClientScopes")).stream().anyMatch(p -> p.equals("foo-dynamic-scope:param2")));
res = oauth.doRefreshTokenRequest(res.getRefreshToken());
MatcherAssert.assertThat(List.of(res.getScope().split(" ")), Matchers.hasItems("foo-dynamic-scope:param1", "foo-dynamic-scope:param2"));
res = oauth.scope("foo-dynamic-scope:param2").doRefreshTokenRequest(res.getRefreshToken());
MatcherAssert.assertThat(List.of(res.getScope().split(" ")), Matchers.not(Matchers.hasItems("foo-dynamic-scope:param1")));
MatcherAssert.assertThat(List.of(res.getScope().split(" ")), Matchers.hasItems("foo-dynamic-scope:param2"));
oauth.logoutForm().idTokenHint(res.getIdToken()).open();
EventAssertion.assertSuccess(events.poll()).type(EventType.LOGOUT)
.sessionId(loginEvent.getSessionId())
.clientId(THIRD_PARTY_APP)
.withoutDetails(Details.REDIRECT_URI);
// login again with the same two params
oauth.scope("foo-dynamic-scope:param1 foo-dynamic-scope:param2");
oauth.openLoginForm();
oauth.fillLoginForm(DEFAULT_USERNAME, DEFAULT_PASSWORD);
EventAssertion.expectLoginSuccess(events.poll())
.clientId(THIRD_PARTY_APP)
.details(Details.REDIRECT_URI, oauth.getRedirectUri())
.details(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED);
.details(Details.CONSENT, Details.CONSENT_VALUE_PERSISTED_CONSENT);
}
@Test

View file

@ -90,6 +90,7 @@ import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.AdminApiUtil;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.exportimport.ExportImportUtil;
import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.runonserver.RunHelpers;
import org.keycloak.theme.DefaultThemeSelectorProvider;
@ -155,7 +156,7 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
protected void testMigratedMigrationData(boolean supportsAuthzService) {
assertNames(migrationRealm.roles().list(), "offline_access", "uma_authorization", "default-roles-migration", "migration-test-realm-role");
List<String> expectedClientIds = new ArrayList<>(Arrays.asList("account", "account-console", "admin-cli", "broker", "migration-test-client", "migration-saml-client",
"realm-management", "security-admin-console", "http://localhost:8280/sales-post-enc/"));
"realm-management", "security-admin-console", "http://localhost:8280/sales-post-enc/", "migration-consent-client"));
if (supportsAuthzService) {
expectedClientIds.add("authz-servlet");
@ -165,8 +166,16 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
assertNames(migrationRealm.clients().findAll(), expectedClientIds.toArray(new String[expectedClientIds.size()]));
String id2 = migrationRealm.clients().findByClientId("migration-test-client").get(0).getId();
assertNames(migrationRealm.clients().get(id2).roles().list(), "migration-test-client-role");
assertNames(migrationRealm.users().search("", 0, 5), "migration-test-user", "offline-test-user");
assertNames(migrationRealm.users().search("", 0, 5), "migration-test-user", "offline-test-user", "consent-user");
assertNames(migrationRealm.groups().groups(), "migration-test-group");
// check consents have migrated OK after dynamic scopes
List<Map<String, Object>> userConsents = AccountHelper.getUserConsents(migrationRealm, "consent-user");
Assertions.assertNotNull(userConsents);
Assertions.assertEquals(1, userConsents.size());
Assertions.assertEquals("migration-consent-client", userConsents.get(0).get("clientId"));
assertThat((List<String>) userConsents.get(0).get("grantedClientScopes"),
Matchers.containsInAnyOrder("roles", "email", "profile"));
}
protected void testMigratedMasterData() {

View file

@ -1795,7 +1795,29 @@
"account" : [ "manage-account", "view-profile" ]
},
"groups" : [ ]
} ],
}, {
"id" : "08420acf-d86f-4eba-8a69-73e7da43c668",
"username" : "consent-user",
"emailVerified" : false,
"createdTimestamp" : 1779110441255,
"enabled" : true,
"totp" : false,
"credentials" : [ ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "offline_access" ],
"clientRoles" : {
"account" : [ "manage-account", "view-profile" ]
},
"clientConsents" : [ {
"clientId" : "migration-consent-client",
"grantedClientScopes" : [ "roles", "email", "profile" ],
"createdDate" : 1779175201074,
"lastUpdatedDate" : 1779175201101
} ],
"notBefore" : 0,
"groups" : [ ]
} ],
"clientScopeMappings" : {
"realm-management" : [ {
"client" : "admin-cli",
@ -2481,6 +2503,35 @@
"useTemplateConfig" : false,
"useTemplateScope" : false,
"useTemplateMappers" : false
}, {
"id" : "e908e55c-7cfe-46de-8c86-9ed6c65c11d9",
"clientId" : "migration-consent-client",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"redirectUris" : [ ],
"webOrigins" : [ ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : true,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : true,
"serviceAccountsEnabled" : false,
"publicClient" : true,
"frontchannelLogout" : false,
"protocol" : "openid-connect",
"attributes" : {
"post.logout.redirect.uris" : "+",
"backchannel.logout.session.required" : "true",
"backchannel.logout.revoke.offline.tokens" : "false"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "acr", "web-origins", "roles", "profile", "email" ],
"optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
} ],
"clientTemplates" : [ ],
"browserSecurityHeaders" : {

View file

@ -2651,6 +2651,28 @@
},
"notBefore" : 0,
"groups" : [ ]
}, {
"id" : "08420acf-d86f-4eba-8a69-73e7da43c668",
"username" : "consent-user",
"emailVerified" : false,
"createdTimestamp" : 1779110441255,
"enabled" : true,
"totp" : false,
"credentials" : [ ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "offline_access", "uma_authorization" ],
"clientRoles" : {
"account" : [ "manage-account", "view-profile" ]
},
"clientConsents" : [ {
"clientId" : "migration-consent-client",
"grantedClientScopes" : [ "roles", "email", "profile" ],
"createdDate" : 1779175201074,
"lastUpdatedDate" : 1779175201101
} ],
"notBefore" : 0,
"groups" : [ ]
} ],
"scopeMappings" : [ {
"clientScope" : "offline_access",
@ -2970,6 +2992,36 @@
"saml.signing.certificate": "MIIDBjCCAe6gAwIBAgIJANPu/mvxOREdMA0GCSqGSIb3DQEBCwUAMDAxLjAsBgNVBAMTJWh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9zYWxlcy1wb3N0LWVuYy8wIBcNMjQwNjIxMTkzMTE3WhgPMjEyNDA1MjgxOTMxMTdaMDAxLjAsBgNVBAMTJWh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9zYWxlcy1wb3N0LWVuYy8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE5iKDNNW5XxHAF0ITErZcHDYZI68z7u68n7o4dsiywkfOWf7jVnw7PJVnMeDEtLWtTO6f0tRTqJ4OV5HYdJ9+mhPJtn+2UuvrepyYa2IsC1eFPH98ZEtYapsE6ObvhKBQMcu5G/tQrxkCFY2ssDa99unwBH5STLyX78UvqKiYnkPCvIhkiPIHy8ab7DQowc+EE9XhlE3b63A65rp4G9R87rwgJX5VTM3h81WcDuWLPOg7YRYLZoorWz2p38/qL9gXY5NxIRK16EHGfw2W1dPrX3GyMOJbXVyrBNZ6m5IL9Wn7lBEJ/Dl7ZFMFB5W36QkJ+3aaNLT/Tu/Gz+7f24inAgMBAAGjITAfMB0GA1UdDgQWBBSk7RegFbEBruVbt/VFl2gZhZ2IpDANBgkqhkiG9w0BAQsFAAOCAQEAGyH1sXVU3HDMhCzP2k5fsJBGA+1iKLMsyyiGcaD/22anQ1uVU7iWPZH8mSJGWqkvo/4oFb7RjB2KzO/50wP0q/P/tymGsYoznt+MEJKKxYEqAYmIns7SKRIgv3xEfF8yQy2jOuULC9FTq/Pb3gd9Om40jmeJtYccDSICjEC+A2fcGe56ScuRRLt+3WFyIZUFH7Y9FYZQ3EYQ88UZg//5F1ddAzGtdMSeTanMxLKow7rUIm/+Sx6cd+Vkwo/SYdk4hsD8xZCYx8Ln4i3NKh+SzyvbYykyWVI2fwjplqvM5Md/M+SNvPtU9tkOCUxQqVfz/bwtTiqfjdSaUJlasgGByg==",
"saml.encryption.certificate": "MIIDBjCCAe6gAwIBAgIJANPu/mvxOREdMA0GCSqGSIb3DQEBCwUAMDAxLjAsBgNVBAMTJWh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9zYWxlcy1wb3N0LWVuYy8wIBcNMjQwNjIxMTkzMTE3WhgPMjEyNDA1MjgxOTMxMTdaMDAxLjAsBgNVBAMTJWh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9zYWxlcy1wb3N0LWVuYy8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE5iKDNNW5XxHAF0ITErZcHDYZI68z7u68n7o4dsiywkfOWf7jVnw7PJVnMeDEtLWtTO6f0tRTqJ4OV5HYdJ9+mhPJtn+2UuvrepyYa2IsC1eFPH98ZEtYapsE6ObvhKBQMcu5G/tQrxkCFY2ssDa99unwBH5STLyX78UvqKiYnkPCvIhkiPIHy8ab7DQowc+EE9XhlE3b63A65rp4G9R87rwgJX5VTM3h81WcDuWLPOg7YRYLZoorWz2p38/qL9gXY5NxIRK16EHGfw2W1dPrX3GyMOJbXVyrBNZ6m5IL9Wn7lBEJ/Dl7ZFMFB5W36QkJ+3aaNLT/Tu/Gz+7f24inAgMBAAGjITAfMB0GA1UdDgQWBBSk7RegFbEBruVbt/VFl2gZhZ2IpDANBgkqhkiG9w0BAQsFAAOCAQEAGyH1sXVU3HDMhCzP2k5fsJBGA+1iKLMsyyiGcaD/22anQ1uVU7iWPZH8mSJGWqkvo/4oFb7RjB2KzO/50wP0q/P/tymGsYoznt+MEJKKxYEqAYmIns7SKRIgv3xEfF8yQy2jOuULC9FTq/Pb3gd9Om40jmeJtYccDSICjEC+A2fcGe56ScuRRLt+3WFyIZUFH7Y9FYZQ3EYQ88UZg//5F1ddAzGtdMSeTanMxLKow7rUIm/+Sx6cd+Vkwo/SYdk4hsD8xZCYx8Ln4i3NKh+SzyvbYykyWVI2fwjplqvM5Md/M+SNvPtU9tkOCUxQqVfz/bwtTiqfjdSaUJlasgGByg=="
}
},
{
"id" : "e908e55c-7cfe-46de-8c86-9ed6c65c11d9",
"clientId" : "migration-consent-client",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"redirectUris" : [ ],
"webOrigins" : [ ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : true,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : true,
"serviceAccountsEnabled" : false,
"publicClient" : true,
"frontchannelLogout" : false,
"protocol" : "openid-connect",
"attributes" : {
"post.logout.redirect.uris" : "+",
"backchannel.logout.session.required" : "true",
"backchannel.logout.revoke.offline.tokens" : "false"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "acr", "web-origins", "roles", "profile", "email" ],
"optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
} ],
"clientScopes" : [ {
"id" : "adef1610-70ec-4282-88ef-bcb26b1f5edf",