Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3174,6 +3174,8 @@ parameterizedScopeType.custom=Custom
parameterizedScopeType.delegation=Delegation
repeatableScope=Repeatable
repeatableScopeHelp=When enabled, this scope can appear multiple times in a single request with different parameter values. When disabled, only one parameter value is allowed per request.
allowUserDataAccess=Allow access to user data
allowUserDataAccessHelp=When enabled, protocol mappers on this scope can resolve and expose attributes of the target user without requiring the authenticated user to have the 'view-users' permission. This is useful for user-to-user scenarios (e.g. share-with:alice) where normal users need to reference each other. When disabled (default), only users with 'view-users' permission (or fine-grained admin permission) will have user property claims included in the token.
webAuthnPolicyExtraOriginsHelp=The list of extra origins for non-web applications.
webAuthnPolicyPasskeysEnabledHelp=Enable passkeys (conditional UI) authentication in the username forms.
webAuthnPolicyMediationHelp=Controls how the browser presents the passkey selection dialog when the login page loads.
Expand Down
14 changes: 14 additions & 0 deletions js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
"attributes.parameterized.scope.repeatable",
);

const allowUserDataAccessFieldName =
convertAttributeNameToForm<ClientScopeDefaultOptionalType>(
"attributes.parameterized.scope.allow.user.data",
);

useEffect(() => {
if (parameterizedScope === "true" && isCustomType) {
const current = (form.getValues(regexpFieldName) as string) || "";
Expand Down Expand Up @@ -417,6 +422,15 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
labelIcon={t("repeatableScopeHelp")}
stringify
/>
{parameterType === "username" && (
<DefaultSwitchControl
name={allowUserDataAccessFieldName}
defaultValue="false"
label={t("allowUserDataAccess")}
labelIcon={t("allowUserDataAccessHelp")}
stringify
/>
)}
</>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ interface ClientScopeCreatedEvent extends ProviderEvent {
String PARAMETERIZED_SCOPE_REGEXP = "parameterized.scope.regexp";
String PARAMETERIZED_SCOPE_TYPE = "parameterized.scope.type";
String IS_REPEATABLE_SCOPE = "parameterized.scope.repeatable";
String ALLOW_USER_DATA_ACCESS = "parameterized.scope.allow.user.data";
String IS_ALWAYS_CONSENT = "always.display.consent";

/** @deprecated Use {@link #IS_PARAMETERIZED_SCOPE} instead. */
Expand Down Expand Up @@ -153,6 +154,24 @@ default boolean isParameterizedScope() {
return Boolean.parseBoolean(getAttribute(IS_PARAMETERIZED_SCOPE));
}

/**
* Returns the parameterized scope type identifier, or {@code null} if this scope is not parameterized.
*/
default String getParameterizedScopeType() {
if (!isParameterizedScope()) {
return null;
}
return getAttribute(PARAMETERIZED_SCOPE_TYPE);
}

/**
* Returns whether protocol mappers on this scope are allowed to expose target user attributes
* without requiring the authenticated user to have view-users permission.
*/
default boolean isAllowUserDataAccess() {
return isParameterizedScope() && Boolean.parseBoolean(getAttribute(ALLOW_USER_DATA_ACCESS));
}

default boolean isAlwaysConsent() {
return isParameterizedScope() && isDisplayOnConsentScreen()
&& Boolean.parseBoolean(getAttribute(IS_ALWAYS_CONSENT))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,19 @@ protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSes
ProtocolMapperModel model = new ProtocolMapperModel(mappingModel);
model.getConfig().put(ProtocolMapperUtils.MULTIVALUED, Boolean.toString(TokenManager.isRepeatableScope(keycloakSession, clientScope)));
if (!parameterValues.isEmpty()) {
setClaim(token, model, userSession, keycloakSession, parameterValues);
setClaim(token, model, userSession, keycloakSession, clientScope, parameterValues);
}
}

/**
* Maps resolved parameter values to a token claim. The mapper's {@code multivalued} config
* controls whether multiple values are mapped as a JSON array or only the first value is used.
*/
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession,
KeycloakSession keycloakSession, ClientScopeModel clientScope, List<String> parameterValues) {
setClaim(token, mappingModel, userSession, keycloakSession, parameterValues);
}

protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession,
KeycloakSession keycloakSession, List<String> parameterValues) {
OIDCAttributeMapperHelper.mapClaim(token, mappingModel, parameterValues);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@
import java.util.List;

import org.keycloak.common.util.CollectionUtil;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.ProtocolMapperUtils;
import org.keycloak.protocol.oidc.scope.ParameterizedScopeTypeProvider;
import org.keycloak.protocol.oidc.scope.UsernameScopeType;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;
import org.keycloak.utils.StringUtil;

import org.jboss.logging.Logger;

public class ParameterizedScopeUserPropertyMapper extends ParameterizedScopeMapper {

private static final Logger logger = Logger.getLogger(ParameterizedScopeUserPropertyMapper.class);

public static final String PROVIDER_ID = "oidc-parameterized-scope-user-property-mapper";

private static final List<ProviderConfigProperty> configProperties = new ArrayList<>();
Expand Down Expand Up @@ -54,26 +62,36 @@ public List<ProviderConfigProperty> getConfigProperties() {

@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession,
KeycloakSession keycloakSession, List<String> parameterValues) {
KeycloakSession keycloakSession, ClientScopeModel clientScope, List<String> parameterValues) {
String attributeName = mappingModel.getConfig().get(ProtocolMapperUtils.USER_ATTRIBUTE);
if (StringUtil.isBlank(attributeName)) {
return;
}

ParameterizedScopeTypeProvider scopeType = keycloakSession.getProvider(ParameterizedScopeTypeProvider.class, clientScope.getParameterizedScopeType());
RealmModel realm = userSession.getRealm();

List<Object> resolvedValues = new ArrayList<>();
for (String parameterValue : parameterValues) {
UserModel user = keycloakSession.users().getUserByUsername(userSession.getRealm(), parameterValue);
if (user == null) {
UserModel targetUser = keycloakSession.users().getUserByUsername(realm, parameterValue);
if (targetUser == null) {
continue;
}

boolean accessDenied = scopeType instanceof UsernameScopeType usernameType && !usernameType.canAccessTargetUser(clientScope, userSession.getUser(), targetUser);
if (accessDenied) {
logger.debugf("Skipping user property claim for target user '%s' — authenticated user '%s' is not authorized to access the target user's data",
parameterValue, userSession.getUser().getUsername());
continue;
}

Collection<String> attributeValue = KeycloakModelUtils.resolveAttribute(user, attributeName, false);
Collection<String> attributeValue = KeycloakModelUtils.resolveAttribute(targetUser, attributeName, false);
if (CollectionUtil.isNotEmpty(attributeValue)) {
resolvedValues.addAll(attributeValue);
continue;
}

String propertyValue = ProtocolMapperUtils.getUserModelValue(user, attributeName);
String propertyValue = ProtocolMapperUtils.getUserModelValue(targetUser, attributeName);
if (propertyValue != null) {
resolvedValues.add(propertyValue);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public boolean isRepeatable() {
return false;
}

@Override
public boolean canAccessTargetUser(ClientScopeModel scope, UserModel currentUser, UserModel targetUser) {
// Impersonation relationship is already validated in validateParameterWithUser during scope resolution
return true;
}

@Override
public ParameterizedScopeTypeProvider create(KeycloakSession session) {
return new DelegationScopeType(session);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.resources.admin.fgap.AdminPermissions;
import org.keycloak.utils.StringUtil;

/**
Expand Down Expand Up @@ -35,6 +36,16 @@ public ParameterizedScopeTypeProvider create(KeycloakSession session) {
return new UsernameScopeType(session);
}

/**
* Checks whether the authenticated user is allowed to access the target user's data for this scope.
*/
public boolean canAccessTargetUser(ClientScopeModel scope, UserModel currentUser, UserModel targetUser) {
if (scope.isAllowUserDataAccess()) {
return true;
}
return AdminPermissions.evaluator(session, scope.getRealm(), scope.getRealm(), currentUser).users().canView(targetUser);
}

@Override
public void validateParameter(@Nonnull ClientScopeModel scope, @Nonnull String parameter) throws InvalidScopeParameterException {
if (StringUtil.isBlank(parameter)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public ParameterizedScopeBuilder consentScreenText(String text) {
return this;
}

public ParameterizedScopeBuilder allowUserDataAccess(boolean allow) {
builder.attribute(ClientScopeModel.ALLOW_USER_DATA_ACCESS, Boolean.toString(allow));
return this;
}

public ParameterizedScopeBuilder regexp(String regexp) {
builder.attribute(ClientScopeModel.PARAMETERIZED_SCOPE_REGEXP, regexp);
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import java.util.stream.Collectors;

import org.keycloak.common.Profile;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.Constants;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
Expand Down Expand Up @@ -62,6 +64,7 @@ public class ParameterizedScopeMapperTest {
private static final String DEFAULT_USERNAME = "test-user@localhost";
private static final String DEFAULT_PASSWORD = "password";
private static final String TARGET_USERNAME = "target-user";
private static final String UNPRIVILEGED_USERNAME = "unprivileged-user";
private static final String RAW_PARAM_CLAIM = "param_value";
private static final String USER_ID_CLAIM = "resolved_user_id";
private static final String USER_EMAIL_CLAIM = "resolved_user_email";
Expand All @@ -82,6 +85,9 @@ public class ParameterizedScopeMapperTest {
@InjectUser(config = SecondTargetUserConfig.class, ref = "secondTarget")
ManagedUser secondTargetUser;

@InjectUser(config = UnprivilegedUserConfig.class, ref = "unprivileged")
ManagedUser unprivilegedUser;

@InjectEvents
Events events;

Expand All @@ -107,16 +113,18 @@ public void setup() {

@AfterEach
public void afterEach() {
try {
AccountHelper.logout(realm.admin(), DEFAULT_USERNAME);
} catch (Exception ignored) {
}
try {
List<Map<String, Object>> userConsents = AccountHelper.getUserConsents(realm.admin(), DEFAULT_USERNAME);
if (userConsents.stream().anyMatch(m -> CLIENT_ID.equals(m.get("clientId")))) {
AccountHelper.revokeConsents(realm.admin(), DEFAULT_USERNAME, CLIENT_ID);
for (String username : List.of(DEFAULT_USERNAME, UNPRIVILEGED_USERNAME)) {
try {
AccountHelper.logout(realm.admin(), username);
} catch (Exception ignored) {
}
try {
List<Map<String, Object>> userConsents = AccountHelper.getUserConsents(realm.admin(), username);
if (userConsents.stream().anyMatch(m -> CLIENT_ID.equals(m.get("clientId")))) {
AccountHelper.revokeConsents(realm.admin(), username, CLIENT_ID);
}
} catch (Exception ignored) {
}
} catch (Exception ignored) {
}
}

Expand Down Expand Up @@ -152,6 +160,37 @@ public void parameterizedScopeMapperNonExistentUser() {
assertNoUserClaims(token);
}

@Test
public void userPropertyMapperWithViewUsersPermission() {
addUsernameScopeWithUserPropertyMapper("authz-scope", false);

AccessTokenResponse res = loginAndGetResponse("authz-scope:" + TARGET_USERNAME);
AccessToken token = oauth.verifyToken(res.getAccessToken());

assertUserIdClaim(token, targetUser.getId());
}

@Test
public void userPropertyMapperSuppressedWithoutViewUsersPermission() {
addUsernameScopeWithUserPropertyMapper("authz-scope-denied", false);

AccessTokenResponse res = loginAndGetResponse("authz-scope-denied:" + TARGET_USERNAME, UNPRIVILEGED_USERNAME);
AccessToken token = oauth.verifyToken(res.getAccessToken());

Assertions.assertNull(token.getOtherClaims().get(USER_ID_CLAIM),
"User id claim should not be present without view-users permission");
}

@Test
public void userPropertyMapperAllowedWithAllowUserDataAccess() {
addUsernameScopeWithUserPropertyMapper("allow-data-scope", true);

AccessTokenResponse res = loginAndGetResponse("allow-data-scope:" + TARGET_USERNAME, UNPRIVILEGED_USERNAME);
AccessToken token = oauth.verifyToken(res.getAccessToken());

assertUserIdClaim(token, targetUser.getId());
}

@Test
public void claimsPersistOnRefresh() {
AccessTokenResponse res = loginAndGetResponse(SCOPE_NAME + ":" + TARGET_USERNAME);
Expand Down Expand Up @@ -265,10 +304,14 @@ private static void assertListClaim(AccessToken token, String claimName, String.
}

private AccessTokenResponse loginAndGetResponse(String scope) {
return loginAndGetResponse(scope, DEFAULT_USERNAME);
}

private AccessTokenResponse loginAndGetResponse(String scope, String username) {
oauth.client(CLIENT_ID, "password");
oauth.scope(scope);
oauth.openLoginForm();
oauth.fillLoginForm(DEFAULT_USERNAME, DEFAULT_PASSWORD);
oauth.fillLoginForm(username, DEFAULT_PASSWORD);
grantPage.assertCurrent();
grantPage.accept();
events.poll();
Expand All @@ -293,6 +336,29 @@ private String createParameterizedScope(String name, boolean repeatable) {
return ApiUtil.getCreatedId(realm.admin().clientScopes().create(scope));
}

private String createUsernameTypeScope(String name, boolean allowUserDataAccess) {
ClientScopeRepresentation scope = create(name)
.parameterizedScopeType("username")
.isRepeatableScope(false)
.allowUserDataAccess(allowUserDataAccess)
.displayOnConsentScreen(true)
.alwaysConsent(false)
.build();
return ApiUtil.getCreatedId(realm.admin().clientScopes().create(scope));
}

private String addUsernameScopeWithUserPropertyMapper(String name, boolean allowUserDataAccess) {
String scopeId = createUsernameTypeScope(name, allowUserDataAccess);
addMapper(scopeId, ParameterizedScopeUserPropertyMapper.create(
"username-user-id-mapper", "id", USER_ID_CLAIM, "String", true, false, true));
client.admin().addOptionalClientScope(scopeId);
realm.cleanup().add(r -> {
r.clients().get(client.getId()).removeOptionalClientScope(scopeId);
r.clientScopes().get(scopeId).remove();
});
return scopeId;
}

private String createNonParameterizedScopeWithMappers() {
ClientScopeRepresentation scope = ClientScopeBuilder.create()
.name("non-param-scope")
Expand Down Expand Up @@ -364,7 +430,8 @@ public RealmBuilder configure(RealmBuilder realm) {
.name("Test", "User")
.emailVerified(true)
.password(DEFAULT_PASSWORD)
.enabled(true));
.enabled(true)
.clientRoles(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.VIEW_USERS));
}
}

Expand Down Expand Up @@ -400,4 +467,14 @@ public UserBuilder configure(UserBuilder user) {
.name("Second", "Target");
}
}

static class UnprivilegedUserConfig implements UserConfig {
@Override
public UserBuilder configure(UserBuilder user) {
return user.username(UNPRIVILEGED_USERNAME)
.password(DEFAULT_PASSWORD)
.email("unprivileged@localhost")
.name("Unprivileged", "User");
}
}
}
Loading