Support for selecting an organization when requesting the organization scope

Closes #31438

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-08-20 12:25:48 -03:00 committed by Alexander Schwartz
parent 585d179fe0
commit c1f6d5ca64
13 changed files with 567 additions and 72 deletions

View file

@ -2,11 +2,9 @@
= Mapping organization claims
[role="_abstract"]
When authenticating in the context of an organization, the access token is automatically updated with specific claims
about the organization where the user is a member.
To map organization-specific claims into tokens, a client needs to request the *organization* scope when sending
authorization requests to the server.
authorization requests to the server. When authenticating in the context of an organization, clients can request the `organization` scope to map to tokens information
about the organizations the user is a member.
As a result, the token will contain a claim as follows:
@ -19,5 +17,17 @@ As a result, the token will contain a claim as follows:
The organization claim can be used by clients (for example, from ID Tokens) and resource servers (for example, from access tokens)
to authorize access to protected resources based on the organization where the user is a member.
The organization scope is a built-in optional client scope at the realm. Therefore, this scope is added to any client created
The `organization` scope is a built-in optional client scope at the realm. Therefore, this scope is added to any client created
in the realm, by default.
The `organization` scope is requested using different formats:
[cols="2*", options="header"]
|===
|Format
|Description
| `organization` | Maps to a single organization if the user is a member of a single organization.
Otherwise, if a member of multiple organizations, the user will be prompted to select an organization when authenticating to the realm.
| `organization:<alias>` | Maps to a single organization with the given alias.
| `organization:*` | Maps to all organizations the user is a member of.
|===

View file

@ -17,8 +17,11 @@
package org.keycloak.email.freemarker.beans;
import org.jboss.logging.Logger;
import org.keycloak.forms.login.freemarker.model.OrganizationBean;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.userprofile.UserProfileProvider;
@ -26,6 +29,7 @@ import org.keycloak.userprofile.UserProfileProvider;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -36,10 +40,13 @@ public class ProfileBean {
private static final Logger logger = Logger.getLogger(ProfileBean.class);
private UserModel user;
private final KeycloakSession session;
private final Map<String, String> attributes = new HashMap<>();
private List<OrganizationBean> organizations;
public ProfileBean(UserModel user, KeycloakSession session) {
this.user = user;
this.session = session;
if (user.getAttributes() != null) {
//TODO: there is no need to set only a single value for attributes but changing this might break existing
@ -80,4 +87,13 @@ public class ProfileBean {
public Map<String, String> getAttributes() {
return attributes;
}
public List<OrganizationBean> getOrganizations() {
if (organizations == null) {
organizations = session.getProvider(OrganizationProvider.class).getByMember(user)
.map(o -> new OrganizationBean(o, user))
.toList();
}
return organizations;
}
}

View file

@ -20,20 +20,25 @@ package org.keycloak.organization.authentication.authenticators.browser;
import static org.keycloak.organization.utils.Organizations.getEmailDomain;
import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent;
import static org.keycloak.organization.utils.Organizations.resolveHomeBroker;
import static org.keycloak.organization.utils.Organizations.resolveOrganization;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;
import jakarta.ws.rs.core.MultivaluedMap;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator;
import org.keycloak.email.freemarker.beans.ProfileBean;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.OrganizationModel.IdentityProviderRedirectMode;
@ -45,6 +50,8 @@ import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareAuthenticationContextBean;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean;
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareRealmBean;
import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.sessions.AuthenticationSessionModel;
public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
@ -64,7 +71,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
return;
}
OrganizationModel organization = resolveOrganization(session);
OrganizationModel organization = Organizations.resolveOrganization(session);
if (organization == null) {
initialChallenge(context);
@ -84,9 +91,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
RealmModel realm = context.getRealm();
UserModel user = resolveUser(context, username);
String domain = getEmailDomain(username);
OrganizationModel organization = resolveOrganization(session, user, domain);
OrganizationModel organization = resolveOrganization(user, domain);
if (organization == null) {
if (shouldUserSelectOrganization(context, user)) {
return;
}
// request does not map to any organization, go to the next step/sub-flow
context.attempted();
return;
@ -100,7 +110,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
}
if (user == null) {
unkownUserChallenge(context, organization, realm);
unknownUserChallenge(context, organization, realm);
return;
}
@ -118,6 +128,61 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
return realm.isOrganizationsEnabled();
}
private OrganizationModel resolveOrganization(UserModel user, String domain) {
KeycloakContext context = session.getContext();
HttpRequest request = context.getHttpRequest();
MultivaluedMap<String, String> parameters = request.getDecodedFormParameters();
List<String> alias = parameters.getOrDefault(OrganizationModel.ORGANIZATION_ATTRIBUTE, List.of());
if (alias.isEmpty()) {
return Organizations.resolveOrganization(session, user, domain);
}
OrganizationProvider provider = getOrganizationProvider();
OrganizationModel organization = provider.getByAlias(alias.get(0));
if (organization == null) {
return null;
}
AuthenticationSessionModel authSession = context.getAuthenticationSession();
// make sure the organization selected by the user is available from the client session when running mappers and issuing tokens
authSession.setClientNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organization.getId());
return organization;
}
private boolean shouldUserSelectOrganization(AuthenticationFlowContext context, UserModel user) {
OrganizationProvider provider = getOrganizationProvider();
AuthenticationSessionModel authSession = context.getAuthenticationSession();
String rawScope = authSession.getClientNote(OAuth2Constants.SCOPE);
OrganizationScope scope = OrganizationScope.valueOfScope(rawScope);
if (!OrganizationScope.ANY.equals(scope)) {
return false;
}
Stream<OrganizationModel> organizations = provider.getByMember(user);
if (organizations.count() > 1) {
LoginFormsProvider form = context.form();
form.setAttribute("user", new ProfileBean(user, session));
form.setAttributeMapper(new Function<Map<String, Object>, Map<String, Object>>() {
@Override
public Map<String, Object> apply(Map<String, Object> attributes) {
attributes.computeIfPresent("auth",
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)
);
return attributes;
}
});
context.challenge(form.createForm("select-organization.ftl"));
return true;
}
return false;
}
private boolean tryRedirectBroker(AuthenticationFlowContext context, OrganizationModel organization, UserModel user, String username, String domain) {
// the user has credentials set; do not redirect to allow the user to pick how to authenticate
if (user != null && user.credentialManager().getStoredCredentialsStream().findAny().isPresent()) {
@ -158,6 +223,10 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
}
private UserModel resolveUser(AuthenticationFlowContext context, String username) {
if (context.getUser() != null) {
return context.getUser();
}
if (username == null) {
return null;
}
@ -166,14 +235,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
RealmModel realm = session.getContext().getRealm();
UserModel user = Optional.ofNullable(users.getUserByEmail(realm, username)).orElseGet(() -> users.getUserByUsername(realm, username));
if (user != null) {
context.setUser(user);
}
context.setUser(user);
return user;
}
private void unkownUserChallenge(AuthenticationFlowContext context, OrganizationModel organization, RealmModel realm) {
private void unknownUserChallenge(AuthenticationFlowContext context, OrganizationModel organization, RealmModel realm) {
// the user does not exist and is authenticating in the scope of the organization, show the identity-first login page and the
// public organization brokers for selection
LoginFormsProvider form = context.form()

View file

@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import org.keycloak.Config;
@ -31,6 +32,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
@ -82,10 +84,19 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
return;
}
Stream<OrganizationModel> organizations = scope.resolveOrganizations(userSession.getUser(), rawScopes, session);
String orgId = clientSessionCtx.getClientSession().getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
Stream<OrganizationModel> organizations;
if (orgId == null) {
organizations = scope.resolveOrganizations(userSession.getUser(), rawScopes, session);
} else {
organizations = Stream.of(session.getProvider(OrganizationProvider.class).getById(orgId));
}
Map<String, Map<String, Object>> claim = new HashMap<>();
organizations.forEach(o -> claim.put(o.getAlias(), Map.of()));
organizations.filter(Objects::nonNull).forEach(o -> claim.put(o.getAlias(), Map.of()));
if (claim.isEmpty()) {
return;

View file

@ -23,16 +23,19 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.keycloak.common.util.TriFunction;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeDecorator;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
@ -43,33 +46,37 @@ import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.utils.StringUtil;
/**
* An enum with utility methods to process the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope.
* <p>An enum with utility methods to process the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope.
*
* <p>The {@link OrganizationScope} behaves like a dynamic scopes so that access to organizations is granted depending
* on how the client requests the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope.
*/
public enum OrganizationScope {
/**
* Maps to any organization a user is a member
* Maps to any organization a user is a member. When this scope is requested by clients, all the organizations
* the user is a member are granted.
*/
ALL("*"::equals,
(organizations) -> true,
(user, scopes, session) -> {
if (user == null) {
return Stream.empty();
}
return getProvider(session).getByMember(user).filter(OrganizationModel::isEnabled);
}),
return getProvider(session).getByMember(user);
},
(organizations) -> true,
(current, previous) -> valueOfScope(current) == null ? previous : current),
/**
* Maps to a specific organization the user is a member.
* Maps to a specific organization the user is a member. When this scope is requested by clients, only the
* organization specified in the scope is granted.
*/
SINGLE(StringUtil::isNotBlank,
(organizations) -> organizations.findAny().isPresent(),
(user, scopes, session) -> {
OrganizationModel organization = parseScopeParameter(scopes)
.map(OrganizationScope::parseScopeValue)
.map(alias -> getProvider(session).getByAlias(alias))
.filter(Objects::nonNull)
.filter(OrganizationModel::isEnabled)
.findAny()
.orElse(null);
@ -82,32 +89,94 @@ public enum OrganizationScope {
}
return Stream.empty();
},
(organizations) -> organizations.findAny().isPresent(),
(current, previous) -> {
if (current.equals(previous)) {
return current;
}
if (OrganizationScope.ALL.equals(valueOfScope(current))) {
return previous;
}
return null;
}),
/**
* Maps to a single organization if the user is a member of a single organization.
* Maps to a single organization if the user is a member of a single organization. When this scope is requested by clients,
* the user will be asked to select and organization if a member of multiple organizations or, in case the user is a
* member of a single organization, grant access to that organization.
*/
ANY(""::equals,
(organizations) -> true,
(user, scopes, session) -> {
if (user == null) {
return Stream.empty();
}
List<OrganizationModel> organizations = getProvider(session).getByMember(user).toList();
if (organizations.size() == 1) {
return organizations.stream();
}
return Stream.empty();
ClientSessionContext context = (ClientSessionContext) session.getAttribute(ClientSessionContext.class.getName());
if (context == null) {
return Stream.empty();
}
AuthenticatedClientSessionModel clientSession = context.getClientSession();
String orgId = clientSession.getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
if (orgId == null) {
return Stream.empty();
}
return organizations.stream().filter(o -> o.getId().equals(orgId));
},
(organizations) -> true,
(current, previous) -> {
if (current.equals(previous)) {
return current;
}
if (OrganizationScope.ALL.equals(valueOfScope(current))) {
return previous;
}
return null;
});
private static final Pattern SCOPE_PATTERN = Pattern.compile(OIDCLoginProtocolFactory.ORGANIZATION + ":*".replace("*", "(.*)"));
private final Predicate<String> valueMatcher;
private final Predicate<Stream<OrganizationModel>> valueValidator;
private final TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> orgResolver;
OrganizationScope(Predicate<String> valueMatcher, Predicate<Stream<OrganizationModel>> valueValidator, TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> orgResolver) {
/**
* <p>Resolves the value of the scope from its raw format. For instance, {@code organization:<value>} will resolve to {@code <value>}.
*
* <p>If no value is provided, like in {@code organization}, an empty string is returned instead.
*/
private final Predicate<String> valueMatcher;
/**
* Resolves the organizations based on the values of the scope.
*/
private final TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> valueResolver;
/**
* Validate the value of the scope based on how they map to existing organizations.
*/
private final Predicate<Stream<OrganizationModel>> valueValidator;
/**
* Resolves the name of the scope when requesting a scope using a different format.
*/
private final BiFunction<String, String, String> nameResolver;
OrganizationScope(Predicate<String> valueMatcher, TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> valueResolver, Predicate<Stream<OrganizationModel>> valueValidator, BiFunction<String, String, String> nameResolver) {
this.valueMatcher = valueMatcher;
this.valueResolver = valueResolver;
this.valueValidator = valueValidator;
this.orgResolver = orgResolver;
this.nameResolver = nameResolver;
}
/**
@ -122,7 +191,7 @@ public enum OrganizationScope {
if (scope == null) {
return Stream.empty();
}
return orgResolver.apply(user, scope, session);
return valueResolver.apply(user, scope, session).filter(OrganizationModel::isEnabled);
}
/**
@ -134,6 +203,12 @@ public enum OrganizationScope {
* @return the {@link ClientScopeModel}
*/
public ClientScopeModel toClientScope(String name, UserModel user, KeycloakSession session) {
OrganizationScope scope = valueOfScope(name);
if (scope == null) {
return null;
}
KeycloakContext context = session.getContext();
ClientModel client = context.getClient();
ClientScopeModel orgScope = getOrganizationClientScope(client, session);
@ -142,12 +217,6 @@ public enum OrganizationScope {
return null;
}
OrganizationScope scope = OrganizationScope.valueOfScope(name);
if (scope == null) {
return null;
}
Stream<OrganizationModel> organizations = scope.resolveOrganizations(user, name, session);
if (valueValidator.test(organizations)) {
@ -157,6 +226,31 @@ public enum OrganizationScope {
return null;
}
/**
* <p>Resolves the name of this scope based on the given set of {@code scopes} and the {@code previous} name.
*
* <p>The scope name can be mapped to another scope depending on its semantics. Otherwise, it will map to
* the same name. This method is mainly useful to recognize if a scope previously granted is still valid
* and can be mapped to the new scope being requested. For instance, when refreshing tokens.
*
* @param scopes the scopes to resolve the name from
* @param previous the previous name of this scope
* @return the name of the scope
*/
public String resolveName(Set<String> scopes, String previous) {
for (String scope : scopes) {
String resolved = nameResolver.apply(scope, previous);
if (resolved == null) {
continue;
}
return resolved;
}
return null;
}
/**
* Returns a {@link OrganizationScope} instance based on the given {@code rawScope}.
*

View file

@ -249,34 +249,34 @@ public class Organizations {
}
public static OrganizationModel resolveOrganization(KeycloakSession session, UserModel user, String domain) {
Optional<OrganizationModel> organization = Optional.ofNullable((OrganizationModel) session.getAttribute(OrganizationModel.class.getName()));
if (organization.isPresent()) {
// resolved from current keycloak session
return organization.get();
}
OrganizationProvider provider = getProvider(session);
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
if (authSession != null) {
OrganizationModel organization = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE))
String rawScopes = authSession.getClientNote(OAuth2Constants.SCOPE);
OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes);
List<OrganizationModel> organizations = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE))
.map(provider::getById)
.orElseGet(() -> {
String rawScopes = authSession.getClientNote(OAuth2Constants.SCOPE);
OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes);
.map(List::of)
.orElseGet(() -> scope == null ? List.of() : scope.resolveOrganizations(user, rawScopes, session).toList());
if (OrganizationScope.SINGLE.equals(scope)) {
return scope.resolveOrganizations(user, rawScopes, session).findAny().orElse(null);
}
return null;
});
if (organization != null) {
return organization;
if (organizations.size() == 1) {
// single organization mapped from authentication session
return organizations.get(0);
} else if (scope != null) {
// organization scope requested but no single organization mapped from the scope
return null;
}
}
Optional<OrganizationModel> organization = Optional.ofNullable((OrganizationModel) session.getAttribute(OrganizationModel.class.getName()));
if (organization.isPresent()) {
return organization.get();
}
organization = ofNullable(user).stream().flatMap(provider::getByMember)
.filter(o -> o.isEnabled() && provider.isManagedMember(o, user))
.findAny();

View file

@ -396,7 +396,9 @@ public class TokenManager {
//if scope parameter is not null, remove every scope that is not part of scope parameter
if (scopeParameter != null && ! scopeParameter.isEmpty()) {
Set<String> scopeParamScopes = Arrays.stream(scopeParameter.split(" ")).collect(Collectors.toSet());
oldTokenScope = Arrays.stream(oldTokenScope.split(" ")).filter(sc -> scopeParamScopes.contains(sc))
oldTokenScope = Arrays.stream(oldTokenScope.split(" "))
.map(transformScopes(scopeParamScopes))
.filter(Objects::nonNull)
.collect(Collectors.joining(" "));
}
@ -438,6 +440,21 @@ public class TokenManager {
return responseBuilder;
}
private Function<String, String> transformScopes(Set<String> requestedScopes) {
return scope -> {
if (requestedScopes.contains(scope)) {
return scope;
}
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
OrganizationScope oldScope = OrganizationScope.valueOfScope(scope);
return oldScope == null ? null : oldScope.resolveName(requestedScopes, scope);
}
return null;
};
}
private void validateTokenReuseForRefresh(KeycloakSession session, RealmModel realm, RefreshToken refreshToken,
TokenValidation validation) throws OAuthErrorException {
if (realm.isRevokeRefreshToken()) {

View file

@ -75,6 +75,7 @@ public class DefaultClientSessionContext implements ClientSessionContext {
this.requestedScopes = requestedScopes;
this.clientSession = clientSession;
this.session = session;
this.session.setAttribute(ClientSessionContext.class.getName(), this);
}

View file

@ -0,0 +1,73 @@
/*
* 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.testsuite.pages;
import static org.keycloak.testsuite.util.UIUtils.clickLink;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.junit.Assert;
import org.keycloak.testsuite.util.DroneUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
public class SelectOrganizationPage extends LanguageComboboxAwarePage {
@ArquillianResource
protected OAuthClient oauth;
@FindBy(xpath = "//html")
protected WebElement htmlRoot;
@Override
public boolean isCurrent() {
try {
return !driver.findElements(By.id("kc-user-organizations")).isEmpty();
} catch (NoSuchElementException ignore) {}
return false;
}
@Override
public void open() {
throw new UnsupportedOperationException();
}
public void assertCurrent(String realm) {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected " + name + " but was " + DroneUtils.getCurrentDriver().getTitle() + " (" + DroneUtils.getCurrentDriver().getCurrentUrl() + ")",
isCurrent(realm));
}
public void selectOrganization(String alias) {
WebElement socialButton = findOrganizationButton(alias);
clickLink(socialButton);
}
public boolean isOrganizationButtonPresent(String alias) {
String id = "organization-" + alias;
return !DroneUtils.getCurrentDriver().findElements(By.id(id)).isEmpty();
}
private WebElement findOrganizationButton(String alias) {
String id = "organization-" + alias;
return DroneUtils.getCurrentDriver().findElement(By.id(id));
}
}

View file

@ -54,6 +54,7 @@ import org.keycloak.testsuite.organization.broker.BrokerConfigurationWrapper;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.SelectOrganizationPage;
import org.keycloak.testsuite.pages.UpdateAccountInformationPage;
import org.keycloak.testsuite.util.TestCleanup;
@ -71,6 +72,9 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
@Page
protected LoginPage loginPage;
@Page
protected SelectOrganizationPage selectOrganizationPage;
@Page
protected IdpConfirmLinkPage idpConfirmLinkPage;

View file

@ -25,10 +25,11 @@ import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@ -113,7 +114,6 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest
@SuppressWarnings("unchecked")
@Test
public void testOrganizationScopeMapsSpecificOrganization() {
driver.manage().timeouts().pageLoadTimeout(Duration.ofDays(1));
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName());
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
@ -205,26 +205,204 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest
loginPage.login(memberPassword);
assertScopeAndClaims(orgScope, orgA);
String code;
AccessTokenResponse response;
AccessToken accessToken;
UserRepresentation account = getUserRepresentation(member.getEmail());
realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout();
realmsResouce().realm(bc.providerRealmName()).logoutAll();
}
@Test
public void testOrganizationScopeAnyAskUserToSelectOrganization() {
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName());
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
oauth.clientId("broker-app");
oauth.scope("organization");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
assertTrue(selectOrganizationPage.isCurrent());
assertFalse(driver.getPageSource().contains("kc-select-try-another-way-form"));
assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias()));
assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias()));
selectOrganizationPage.selectOrganization(orgB.getAlias());
loginPage.login(memberPassword);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
// for now, return the organization scope in the response and access token even though no organization is mapped into the token
// once we support the user to select an organization, the selected organization will be mapped
assertThat(response.getScope(), containsString("organization"));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
Map<String, Object> organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
assertThat(organizations.containsKey(orgA.getAlias()), is(false));
assertThat(organizations.containsKey(orgB.getAlias()), is(true));
}
@Test
public void testRefreshTokenWithAllOrganizationsAskingForSpecificOrganization() {
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName());
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
// identity-first login will respect the organization provided in the scope even though the user email maps to a different organization
oauth.clientId("broker-app");
String orgScope = "organization:*";
oauth.scope(orgScope);
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
loginPage.login(memberPassword);
code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
// for now, return the organization scope in the response and access token even though no organization is mapped into the token
// once we support the user to select an organization, the selected organization will be mapped
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), containsString(orgScope));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), containsString(orgScope));
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
Map<String, Object> organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(organizations.size(), is(2));
orgScope = "organization:orga";
oauth.scope(orgScope);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), containsString(orgScope));
accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), containsString(orgScope));
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(organizations.size(), is(1));
assertThat(organizations.containsKey(orgA.getAlias()), is(true));
}
@Test
public void testRefreshTokenWithSingleOrganizationsAskingAllOrganizations() {
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName());
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
// identity-first login will respect the organization provided in the scope even though the user email maps to a different organization
oauth.clientId("broker-app");
String originalScope = "organization:orga";
String orgScope = originalScope;
oauth.scope(orgScope);
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
loginPage.login(memberPassword);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), containsString(orgScope));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), containsString(orgScope));
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
Map<String, Object> organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(organizations.size(), is(1));
assertThat(organizations.containsKey(orgA.getAlias()), is(true));
orgScope = "organization:*";
oauth.scope(orgScope);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), containsString(originalScope));
accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), containsString(originalScope));
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(organizations.size(), is(1));
assertThat(organizations.containsKey(orgA.getAlias()), is(true));
}
@Test
public void testRefreshTokenWithSingleOrganizationsAskingDifferentOrganization() {
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName());
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
// identity-first login will respect the organization provided in the scope even though the user email maps to a different organization
oauth.clientId("broker-app");
String originalScope = "organization:orga";
String orgScope = originalScope;
oauth.scope(orgScope);
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
loginPage.login(memberPassword);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), containsString(orgScope));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), containsString(orgScope));
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
Map<String, Object> organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(organizations.size(), is(1));
assertThat(organizations.containsKey(orgA.getAlias()), is(true));
orgScope = "organization:orgb";
oauth.scope(orgScope);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), not(containsString(originalScope)));
accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), not(containsString(orgScope)));
assertThat(accessToken.getScope(), not(containsString(originalScope)));
assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION)));
}
@Test
public void testRefreshTokenScopeAnyAskingAllOrganizations() {
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName());
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
oauth.clientId("broker-app");
String originalScope = "organization";
oauth.scope(originalScope);
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
assertTrue(selectOrganizationPage.isCurrent());
assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias()));
assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias()));
selectOrganizationPage.selectOrganization(orgB.getAlias());
loginPage.login(memberPassword);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
// for now, return the organization scope in the response and access token even though no organization is mapped into the token
// once we support the user to select an organization, the selected organization will be mapped
assertThat(response.getScope(), containsString("organization"));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
Map<String, Object> organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
assertThat(organizations.containsKey(orgB.getAlias()), is(true));
String orgScope = "organization:*";
oauth.scope(orgScope);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), containsString(originalScope));
accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), containsString(originalScope));
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(organizations.containsKey(orgB.getAlias()), is(true));
}
@Test
public void testRefreshTokenScopeAnyAskingSingleOrganization() {
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName());
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
oauth.clientId("broker-app");
String originalScope = "organization";
oauth.scope(originalScope);
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());
assertTrue(selectOrganizationPage.isCurrent());
assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias()));
assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias()));
selectOrganizationPage.selectOrganization(orgB.getAlias());
loginPage.login(memberPassword);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
// for now, return the organization scope in the response and access token even though no organization is mapped into the token
// once we support the user to select an organization, the selected organization will be mapped
assertThat(response.getScope(), containsString("organization"));
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
Map<String, Object> organizations = (Map<String, Object>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
assertThat(organizations.containsKey(orgB.getAlias()), is(true));
String orgScope = "organization:orgb";
oauth.scope(orgScope);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
assertThat(response.getScope(), not(containsString(orgScope)));
accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getScope(), not(containsString(orgScope)));
assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION)));
}

View file

@ -523,3 +523,4 @@ error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most
organization.confirm-membership.title=You are about to join organization ${kc.org.name}
organization.confirm-membership=By clicking on the link below, you will become a member of the {0} organization:
organization.member.register.title=Create an account to join the ${kc.org.name} organization
organization.select=Select an organization to proceed:

View file

@ -0,0 +1,23 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "form">
<form action="${url.loginAction}" class="form-vertical" method="post">
<div id="kc-user-organizations" class="${properties.kcFormGroupClass!}">
<h2>${msg("organization.select")}</h2>
<ul class="${properties.kcFormSocialAccountListClass!} <#if user.organizations?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list user.organizations as organization>
<li>
<a id="organization-${organization.alias}" class="${properties.kcFormSocialAccountListButtonClass!} <#if user.organizations?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>"
type="button" onclick="document.forms[0]['kc.org'].value = '${organization.alias}'; document.forms[0].submit()">
<span class="${properties.kcFormSocialAccountNameClass!}">${organization.name!}</span>
</a>
</li>
</#list>
</ul>
</div>
<input type="hidden" name="kc.org"/>
</form>
</#if>
</@layout.registrationLayout>