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:
parent
585d179fe0
commit
c1f6d5ca64
13 changed files with 567 additions and 72 deletions
|
@ -2,11 +2,9 @@
|
||||||
|
|
||||||
= Mapping organization claims
|
= Mapping organization claims
|
||||||
[role="_abstract"]
|
[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
|
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:
|
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)
|
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.
|
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.
|
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.
|
||||||
|
|===
|
||||||
|
|
|
@ -17,8 +17,11 @@
|
||||||
package org.keycloak.email.freemarker.beans;
|
package org.keycloak.email.freemarker.beans;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.forms.login.freemarker.model.OrganizationBean;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.OrganizationModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
|
import org.keycloak.organization.OrganizationProvider;
|
||||||
import org.keycloak.representations.userprofile.config.UPAttribute;
|
import org.keycloak.representations.userprofile.config.UPAttribute;
|
||||||
import org.keycloak.representations.userprofile.config.UPConfig;
|
import org.keycloak.representations.userprofile.config.UPConfig;
|
||||||
import org.keycloak.userprofile.UserProfileProvider;
|
import org.keycloak.userprofile.UserProfileProvider;
|
||||||
|
@ -26,6 +29,7 @@ import org.keycloak.userprofile.UserProfileProvider;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
* @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 static final Logger logger = Logger.getLogger(ProfileBean.class);
|
||||||
|
|
||||||
private UserModel user;
|
private UserModel user;
|
||||||
|
private final KeycloakSession session;
|
||||||
private final Map<String, String> attributes = new HashMap<>();
|
private final Map<String, String> attributes = new HashMap<>();
|
||||||
|
private List<OrganizationBean> organizations;
|
||||||
|
|
||||||
public ProfileBean(UserModel user, KeycloakSession session) {
|
public ProfileBean(UserModel user, KeycloakSession session) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
|
this.session = session;
|
||||||
|
|
||||||
if (user.getAttributes() != null) {
|
if (user.getAttributes() != null) {
|
||||||
//TODO: there is no need to set only a single value for attributes but changing this might break existing
|
//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() {
|
public Map<String, String> getAttributes() {
|
||||||
return attributes;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.getEmailDomain;
|
||||||
import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent;
|
import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent;
|
||||||
import static org.keycloak.organization.utils.Organizations.resolveHomeBroker;
|
import static org.keycloak.organization.utils.Organizations.resolveHomeBroker;
|
||||||
import static org.keycloak.organization.utils.Organizations.resolveOrganization;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.MultivaluedMap;
|
import jakarta.ws.rs.core.MultivaluedMap;
|
||||||
|
import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||||
import org.keycloak.authentication.AuthenticationFlowError;
|
import org.keycloak.authentication.AuthenticationFlowError;
|
||||||
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator;
|
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.LoginFormsProvider;
|
||||||
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
|
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
|
||||||
import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
|
import org.keycloak.forms.login.freemarker.model.IdentityProviderBean;
|
||||||
import org.keycloak.http.HttpRequest;
|
import org.keycloak.http.HttpRequest;
|
||||||
import org.keycloak.models.IdentityProviderModel;
|
import org.keycloak.models.IdentityProviderModel;
|
||||||
|
import org.keycloak.models.KeycloakContext;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.OrganizationModel;
|
import org.keycloak.models.OrganizationModel;
|
||||||
import org.keycloak.models.OrganizationModel.IdentityProviderRedirectMode;
|
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.OrganizationAwareAuthenticationContextBean;
|
||||||
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean;
|
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean;
|
||||||
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareRealmBean;
|
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;
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
|
|
||||||
public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||||
|
@ -64,7 +71,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OrganizationModel organization = resolveOrganization(session);
|
OrganizationModel organization = Organizations.resolveOrganization(session);
|
||||||
|
|
||||||
if (organization == null) {
|
if (organization == null) {
|
||||||
initialChallenge(context);
|
initialChallenge(context);
|
||||||
|
@ -84,9 +91,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||||
RealmModel realm = context.getRealm();
|
RealmModel realm = context.getRealm();
|
||||||
UserModel user = resolveUser(context, username);
|
UserModel user = resolveUser(context, username);
|
||||||
String domain = getEmailDomain(username);
|
String domain = getEmailDomain(username);
|
||||||
OrganizationModel organization = resolveOrganization(session, user, domain);
|
OrganizationModel organization = resolveOrganization(user, domain);
|
||||||
|
|
||||||
if (organization == null) {
|
if (organization == null) {
|
||||||
|
if (shouldUserSelectOrganization(context, user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// request does not map to any organization, go to the next step/sub-flow
|
// request does not map to any organization, go to the next step/sub-flow
|
||||||
context.attempted();
|
context.attempted();
|
||||||
return;
|
return;
|
||||||
|
@ -100,7 +110,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
unkownUserChallenge(context, organization, realm);
|
unknownUserChallenge(context, organization, realm);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,6 +128,61 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||||
return realm.isOrganizationsEnabled();
|
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) {
|
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
|
// 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()) {
|
if (user != null && user.credentialManager().getStoredCredentialsStream().findAny().isPresent()) {
|
||||||
|
@ -158,6 +223,10 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserModel resolveUser(AuthenticationFlowContext context, String username) {
|
private UserModel resolveUser(AuthenticationFlowContext context, String username) {
|
||||||
|
if (context.getUser() != null) {
|
||||||
|
return context.getUser();
|
||||||
|
}
|
||||||
|
|
||||||
if (username == null) {
|
if (username == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -166,14 +235,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||||
RealmModel realm = session.getContext().getRealm();
|
RealmModel realm = session.getContext().getRealm();
|
||||||
UserModel user = Optional.ofNullable(users.getUserByEmail(realm, username)).orElseGet(() -> users.getUserByUsername(realm, username));
|
UserModel user = Optional.ofNullable(users.getUserByEmail(realm, username)).orElseGet(() -> users.getUserByUsername(realm, username));
|
||||||
|
|
||||||
if (user != null) {
|
context.setUser(user);
|
||||||
context.setUser(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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
|
// 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
|
// public organization brokers for selection
|
||||||
LoginFormsProvider form = context.form()
|
LoginFormsProvider form = context.form()
|
||||||
|
|
|
@ -21,6 +21,7 @@ import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import org.keycloak.Config;
|
import org.keycloak.Config;
|
||||||
|
@ -31,6 +32,7 @@ import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.OrganizationModel;
|
import org.keycloak.models.OrganizationModel;
|
||||||
import org.keycloak.models.ProtocolMapperModel;
|
import org.keycloak.models.ProtocolMapperModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
|
import org.keycloak.organization.OrganizationProvider;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
|
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
|
||||||
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
|
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
|
||||||
|
@ -82,10 +84,19 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp
|
||||||
return;
|
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<>();
|
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()) {
|
if (claim.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -23,16 +23,19 @@ import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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.function.Predicate;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import org.keycloak.common.util.TriFunction;
|
import org.keycloak.common.util.TriFunction;
|
||||||
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.ClientScopeDecorator;
|
import org.keycloak.models.ClientScopeDecorator;
|
||||||
import org.keycloak.models.ClientScopeModel;
|
import org.keycloak.models.ClientScopeModel;
|
||||||
|
import org.keycloak.models.ClientSessionContext;
|
||||||
import org.keycloak.models.KeycloakContext;
|
import org.keycloak.models.KeycloakContext;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.OrganizationModel;
|
import org.keycloak.models.OrganizationModel;
|
||||||
|
@ -43,33 +46,37 @@ import org.keycloak.protocol.oidc.TokenManager;
|
||||||
import org.keycloak.utils.StringUtil;
|
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 {
|
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,
|
ALL("*"::equals,
|
||||||
(organizations) -> true,
|
|
||||||
(user, scopes, session) -> {
|
(user, scopes, session) -> {
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return Stream.empty();
|
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,
|
SINGLE(StringUtil::isNotBlank,
|
||||||
(organizations) -> organizations.findAny().isPresent(),
|
|
||||||
(user, scopes, session) -> {
|
(user, scopes, session) -> {
|
||||||
OrganizationModel organization = parseScopeParameter(scopes)
|
OrganizationModel organization = parseScopeParameter(scopes)
|
||||||
.map(OrganizationScope::parseScopeValue)
|
.map(OrganizationScope::parseScopeValue)
|
||||||
.map(alias -> getProvider(session).getByAlias(alias))
|
.map(alias -> getProvider(session).getByAlias(alias))
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.filter(OrganizationModel::isEnabled)
|
|
||||||
.findAny()
|
.findAny()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
|
@ -82,32 +89,94 @@ public enum OrganizationScope {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Stream.empty();
|
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,
|
ANY(""::equals,
|
||||||
(organizations) -> true,
|
|
||||||
(user, scopes, session) -> {
|
(user, scopes, session) -> {
|
||||||
|
if (user == null) {
|
||||||
|
return Stream.empty();
|
||||||
|
}
|
||||||
|
|
||||||
List<OrganizationModel> organizations = getProvider(session).getByMember(user).toList();
|
List<OrganizationModel> organizations = getProvider(session).getByMember(user).toList();
|
||||||
|
|
||||||
if (organizations.size() == 1) {
|
if (organizations.size() == 1) {
|
||||||
return organizations.stream();
|
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 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.valueMatcher = valueMatcher;
|
||||||
|
this.valueResolver = valueResolver;
|
||||||
this.valueValidator = valueValidator;
|
this.valueValidator = valueValidator;
|
||||||
this.orgResolver = orgResolver;
|
this.nameResolver = nameResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -122,7 +191,7 @@ public enum OrganizationScope {
|
||||||
if (scope == null) {
|
if (scope == null) {
|
||||||
return Stream.empty();
|
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}
|
* @return the {@link ClientScopeModel}
|
||||||
*/
|
*/
|
||||||
public ClientScopeModel toClientScope(String name, UserModel user, KeycloakSession session) {
|
public ClientScopeModel toClientScope(String name, UserModel user, KeycloakSession session) {
|
||||||
|
OrganizationScope scope = valueOfScope(name);
|
||||||
|
|
||||||
|
if (scope == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
KeycloakContext context = session.getContext();
|
KeycloakContext context = session.getContext();
|
||||||
ClientModel client = context.getClient();
|
ClientModel client = context.getClient();
|
||||||
ClientScopeModel orgScope = getOrganizationClientScope(client, session);
|
ClientScopeModel orgScope = getOrganizationClientScope(client, session);
|
||||||
|
@ -142,12 +217,6 @@ public enum OrganizationScope {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
OrganizationScope scope = OrganizationScope.valueOfScope(name);
|
|
||||||
|
|
||||||
if (scope == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<OrganizationModel> organizations = scope.resolveOrganizations(user, name, session);
|
Stream<OrganizationModel> organizations = scope.resolveOrganizations(user, name, session);
|
||||||
|
|
||||||
if (valueValidator.test(organizations)) {
|
if (valueValidator.test(organizations)) {
|
||||||
|
@ -157,6 +226,31 @@ public enum OrganizationScope {
|
||||||
return null;
|
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}.
|
* Returns a {@link OrganizationScope} instance based on the given {@code rawScope}.
|
||||||
*
|
*
|
||||||
|
|
|
@ -249,34 +249,34 @@ public class Organizations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static OrganizationModel resolveOrganization(KeycloakSession session, UserModel user, String domain) {
|
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);
|
OrganizationProvider provider = getProvider(session);
|
||||||
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
|
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
|
||||||
|
|
||||||
if (authSession != null) {
|
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)
|
.map(provider::getById)
|
||||||
.orElseGet(() -> {
|
.map(List::of)
|
||||||
String rawScopes = authSession.getClientNote(OAuth2Constants.SCOPE);
|
.orElseGet(() -> scope == null ? List.of() : scope.resolveOrganizations(user, rawScopes, session).toList());
|
||||||
OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes);
|
|
||||||
|
|
||||||
if (OrganizationScope.SINGLE.equals(scope)) {
|
if (organizations.size() == 1) {
|
||||||
return scope.resolveOrganizations(user, rawScopes, session).findAny().orElse(null);
|
// single organization mapped from authentication session
|
||||||
}
|
return organizations.get(0);
|
||||||
|
} else if (scope != null) {
|
||||||
return null;
|
// organization scope requested but no single organization mapped from the scope
|
||||||
});
|
return null;
|
||||||
|
|
||||||
if (organization != null) {
|
|
||||||
return organization;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<OrganizationModel> organization = Optional.ofNullable((OrganizationModel) session.getAttribute(OrganizationModel.class.getName()));
|
|
||||||
|
|
||||||
if (organization.isPresent()) {
|
|
||||||
return organization.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
organization = ofNullable(user).stream().flatMap(provider::getByMember)
|
organization = ofNullable(user).stream().flatMap(provider::getByMember)
|
||||||
.filter(o -> o.isEnabled() && provider.isManagedMember(o, user))
|
.filter(o -> o.isEnabled() && provider.isManagedMember(o, user))
|
||||||
.findAny();
|
.findAny();
|
||||||
|
|
|
@ -396,7 +396,9 @@ public class TokenManager {
|
||||||
//if scope parameter is not null, remove every scope that is not part of scope parameter
|
//if scope parameter is not null, remove every scope that is not part of scope parameter
|
||||||
if (scopeParameter != null && ! scopeParameter.isEmpty()) {
|
if (scopeParameter != null && ! scopeParameter.isEmpty()) {
|
||||||
Set<String> scopeParamScopes = Arrays.stream(scopeParameter.split(" ")).collect(Collectors.toSet());
|
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(" "));
|
.collect(Collectors.joining(" "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -438,6 +440,21 @@ public class TokenManager {
|
||||||
return responseBuilder;
|
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,
|
private void validateTokenReuseForRefresh(KeycloakSession session, RealmModel realm, RefreshToken refreshToken,
|
||||||
TokenValidation validation) throws OAuthErrorException {
|
TokenValidation validation) throws OAuthErrorException {
|
||||||
if (realm.isRevokeRefreshToken()) {
|
if (realm.isRevokeRefreshToken()) {
|
||||||
|
|
|
@ -75,6 +75,7 @@ public class DefaultClientSessionContext implements ClientSessionContext {
|
||||||
this.requestedScopes = requestedScopes;
|
this.requestedScopes = requestedScopes;
|
||||||
this.clientSession = clientSession;
|
this.clientSession = clientSession;
|
||||||
this.session = session;
|
this.session = session;
|
||||||
|
this.session.setAttribute(ClientSessionContext.class.getName(), this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -54,6 +54,7 @@ import org.keycloak.testsuite.organization.broker.BrokerConfigurationWrapper;
|
||||||
import org.keycloak.testsuite.pages.AppPage;
|
import org.keycloak.testsuite.pages.AppPage;
|
||||||
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
|
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
|
||||||
import org.keycloak.testsuite.pages.LoginPage;
|
import org.keycloak.testsuite.pages.LoginPage;
|
||||||
|
import org.keycloak.testsuite.pages.SelectOrganizationPage;
|
||||||
import org.keycloak.testsuite.pages.UpdateAccountInformationPage;
|
import org.keycloak.testsuite.pages.UpdateAccountInformationPage;
|
||||||
import org.keycloak.testsuite.util.TestCleanup;
|
import org.keycloak.testsuite.util.TestCleanup;
|
||||||
|
|
||||||
|
@ -71,6 +72,9 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest {
|
||||||
@Page
|
@Page
|
||||||
protected LoginPage loginPage;
|
protected LoginPage loginPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
protected SelectOrganizationPage selectOrganizationPage;
|
||||||
|
|
||||||
@Page
|
@Page
|
||||||
protected IdpConfirmLinkPage idpConfirmLinkPage;
|
protected IdpConfirmLinkPage idpConfirmLinkPage;
|
||||||
|
|
||||||
|
|
|
@ -25,10 +25,11 @@ import static org.hamcrest.Matchers.not;
|
||||||
import static org.hamcrest.Matchers.notNullValue;
|
import static org.hamcrest.Matchers.notNullValue;
|
||||||
import static org.hamcrest.Matchers.nullValue;
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
import static org.junit.Assert.assertEquals;
|
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.MalformedURLException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
@ -113,7 +114,6 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@Test
|
@Test
|
||||||
public void testOrganizationScopeMapsSpecificOrganization() {
|
public void testOrganizationScopeMapsSpecificOrganization() {
|
||||||
driver.manage().timeouts().pageLoadTimeout(Duration.ofDays(1));
|
|
||||||
OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
|
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());
|
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()));
|
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
|
||||||
|
@ -205,26 +205,204 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest
|
||||||
loginPage.login(memberPassword);
|
loginPage.login(memberPassword);
|
||||||
|
|
||||||
assertScopeAndClaims(orgScope, orgA);
|
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()));
|
OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString()));
|
||||||
testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close();
|
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.open(bc.consumerRealmName());
|
||||||
loginPage.loginUsername(member.getEmail());
|
loginPage.loginUsername(member.getEmail());
|
||||||
loginPage.login(memberPassword);
|
loginPage.login(memberPassword);
|
||||||
code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||||
response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
|
OAuthClient.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
|
assertThat(response.getScope(), containsString(orgScope));
|
||||||
// once we support the user to select an organization, the selected organization will be mapped
|
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));
|
assertThat(response.getScope(), containsString(orgScope));
|
||||||
accessToken = oauth.verifyToken(response.getAccessToken());
|
accessToken = oauth.verifyToken(response.getAccessToken());
|
||||||
assertThat(accessToken.getScope(), containsString(orgScope));
|
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)));
|
assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.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.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.member.register.title=Create an account to join the ${kc.org.name} organization
|
||||||
|
organization.select=Select an organization to proceed:
|
23
themes/src/main/resources/theme/base/login/select-organization.ftl
Executable file
23
themes/src/main/resources/theme/base/login/select-organization.ftl
Executable 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>
|
Loading…
Reference in a new issue