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
|
||||
[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.
|
||||
|===
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
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}.
|
||||
*
|
||||
|
|
|
@ -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))
|
||||
.map(provider::getById)
|
||||
.orElseGet(() -> {
|
||||
String rawScopes = authSession.getClientNote(OAuth2Constants.SCOPE);
|
||||
OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes);
|
||||
|
||||
if (OrganizationScope.SINGLE.equals(scope)) {
|
||||
return scope.resolveOrganizations(user, rawScopes, session).findAny().orElse(null);
|
||||
}
|
||||
List<OrganizationModel> organizations = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE))
|
||||
.map(provider::getById)
|
||||
.map(List::of)
|
||||
.orElseGet(() -> scope == null ? List.of() : scope.resolveOrganizations(user, rawScopes, session).toList());
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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)
|
||||
.filter(o -> o.isEnabled() && provider.isManagedMember(o, user))
|
||||
.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 (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()) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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.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;
|
||||
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
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