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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,22 @@

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.common.VerificationException;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationInvitationModel;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.sessions.AuthenticationSessionModel;

import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent;

Expand All @@ -45,6 +51,29 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro
KeycloakSession session = context.getSession();
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
UserModel user = context.getUser();
InviteOrgActionToken invitation = getPendingInvitation(session, user);

if (invitation != null) {
// The invitation decides which organization is joined, whichever broker was used to get
// here, including one belonging to another organization. Unmanaged, as when an existing
// user accepts an invitation.
OrganizationModel invited = provider.getById(invitation.getOrgId());

provider.addMember(invited, user);
provider.getInvitationManager().remove(invitation.getId());
context.getAuthenticationSession().setRedirectUri(invitation.getRedirectUri());

context.getEvent().clone()
.event(EventType.INVITE_ORG)
.user(user)
.detail(Details.USERNAME, user.getUsername())
.detail(Details.ORG_ID, invited.getId())
.success();

context.success();
return;
}

OrganizationModel organization = Organizations.resolveOrganization(session);

if (organization == null) {
Expand All @@ -64,6 +93,42 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro
context.success();
}

/**
* Returns the invitation that started this authentication, or {@code null} if there is none or
* it does not authorize this user. The token is read from the authentication session rather than
* looked up by email, so a link superseded by a resend cannot be used to consume its replacement.
*/
private static InviteOrgActionToken getPendingInvitation(KeycloakSession session, UserModel user) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();

if (authSession == null || user == null || user.getEmail() == null) {
return null;
}

InviteOrgActionToken token;

try {
token = Organizations.parseInvitationToken(session, authSession.getAuthNote(Organizations.INVITATION_TOKEN_NOTE));
} catch (VerificationException e) {
return null;
}

if (token == null || !user.getEmail().equalsIgnoreCase(token.getEmail())) {
return null;
}

OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
OrganizationModel organization = provider.getById(token.getOrgId());

if (organization == null || !organization.isEnabled()) {
return null;
}

OrganizationInvitationModel invitation = provider.getInvitationManager().getById(token.getId());

return invitation == null || invitation.isExpired() ? null : token;
}

@Override
public boolean requiresUser() {
return true;
Expand All @@ -77,6 +142,10 @@ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserMode
return false;
}

if (getPendingInvitation(session, user) != null) {
return true;
}

OrganizationModel organization = Organizations.resolveOrganization(session);

if (organization == null || !organization.isEnabled()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@

public class Organizations {

/**
* Authentication session note holding the invitation token that started the flow, so the
* invitation survives the redirect to an identity provider and the flow reset that follows.
*/
public static final String INVITATION_TOKEN_NOTE = "ORG_INVITATION_TOKEN";

private static final String WILDCARD_PREFIX = "*.";
private static final int MIN_DOMAIN_PARTS = 2;
private static final int MAX_DOMAIN_PARTS = 10;
Expand Down Expand Up @@ -189,15 +195,18 @@ public static void checkEnabled(OrganizationProvider provider, AdminPermissionEv

public static InviteOrgActionToken parseInvitationToken(KeycloakSession session, HttpRequest request) throws VerificationException {
MultivaluedMap<String, String> queryParameters = request.getUri().getQueryParameters();
String tokenFromQuery = queryParameters.getFirst(Constants.TOKEN);

if (tokenFromQuery == null) {
return parseInvitationToken(session, queryParameters.getFirst(Constants.TOKEN));
}

public static InviteOrgActionToken parseInvitationToken(KeycloakSession session, String tokenString) throws VerificationException {
if (tokenString == null) {
return null;
}

KeycloakContext context = session.getContext();
RealmModel realm = session.getContext().getRealm();
TokenVerifier<InviteOrgActionToken> verifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class)
TokenVerifier<InviteOrgActionToken> verifier = TokenVerifier.create(tokenString, InviteOrgActionToken.class)
.withChecks(TokenVerifier.IS_ACTIVE,
new TokenVerifier.RealmUrlCheck(Urls.realmIssuer(context.getUri().getBaseUri(), realm.getName())));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
Expand Down Expand Up @@ -103,6 +104,7 @@ private enum Action {

private AuthorizationEndpointRequest request;
private String redirectUri;
private String invitationToken;

public AuthorizationEndpoint(KeycloakSession session, EventBuilder event) {
super(session, event);
Expand Down Expand Up @@ -251,6 +253,7 @@ private Response process(final MultivaluedMap<String, String> params) {
public AuthorizationEndpoint register(String tokenString) {
event.event(EventType.REGISTER);
action = Action.REGISTER;
invitationToken = tokenString;

if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION) && tokenString != null) {
//this call should extract orgId from token and set the organization to the session context
Expand Down Expand Up @@ -402,6 +405,20 @@ private void updateAuthenticationSession() {
authenticationSession.setClientNote(OAuth2Constants.AUTHORIZATION_DETAILS, authorizationDetails);
}
}

// register() resolves the invitation before this session exists, so the request context is
// the only place holding it. Keep both the organization and the token for the rest of the
// authentication: the invitee may leave for an identity provider, and the link they follow
// to get there does not carry the token.
OrganizationModel organization = session.getContext().getOrganization();

if (organization != null) {
authenticationSession.setAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organization.getId());
}

if (invitationToken != null) {
authenticationSession.setAuthNote(Organizations.INVITATION_TOKEN_NOTE, invitationToken);
}
}

private Response buildAuthorizationCodeAuthorizationResponse(String requestUri) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
Expand Down Expand Up @@ -804,11 +805,20 @@ public Response authenticated(BrokeredIdentityContext context) {
boolean forwardedPassiveLogin = "true".equals(authenticationSession.getAuthNote(AuthenticationProcessor.FORWARDED_PASSIVE_LOGIN));

String userRequestedLocale = authenticationSession.getAuthNote(LocaleSelectorProvider.USER_REQUEST_LOCALE);
String organizationId = authenticationSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
String invitationToken = authenticationSession.getAuthNote(Organizations.INVITATION_TOKEN_NOTE);
// Redirect to firstBrokerLogin after successful login and ensure that previous authentication state removed
AuthenticationProcessor.resetFlow(authenticationSession, LoginActionsService.FIRST_BROKER_LOGIN_PATH);
if (userRequestedLocale != null) {
authenticationSession.setAuthNote(LocaleSelectorProvider.USER_REQUEST_LOCALE, userRequestedLocale);
}
// Restore the invitation being accepted, resolved before the broker round trip.
if (organizationId != null) {
authenticationSession.setAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organizationId);
}
if (invitationToken != null) {
authenticationSession.setAuthNote(Organizations.INVITATION_TOKEN_NOTE, invitationToken);
}

// Set the FORWARDED_PASSIVE_LOGIN note (if needed) after resetting the session so it is not lost.
if (forwardedPassiveLogin) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder;

import org.keycloak.admin.client.resource.OrganizationResource;
Expand All @@ -46,6 +47,7 @@
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.representations.idm.AuthenticationExecutionRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.MembershipType;
import org.keycloak.representations.idm.OrganizationInvitationRepresentation;
Expand Down Expand Up @@ -80,6 +82,7 @@

import static org.keycloak.representations.idm.OrganizationInvitationRepresentation.Status.EXPIRED;
import static org.keycloak.representations.idm.OrganizationInvitationRepresentation.Status.PENDING;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
Expand Down Expand Up @@ -654,6 +657,100 @@ public void testLinkExpired() throws IOException, MessagingException {
}
}

@Test
public void testInviteNewUserWhoSignsUpWithRealmIdentityProvider() throws IOException, MessagingException {
OrganizationResource organization = createOrganizationWithoutBroker();
String email = bc.getUserEmail();
organization.members().inviteUser(email, "Homer", "Simpson").close();

UserRepresentation user = acceptInvitationWithBroker(createRealmBroker(), email);

// Unmanaged, matching how an existing user joins through InviteOrgActionTokenHandler.
MemberRepresentation member = organization.members().member(user.getId()).toRepresentation();
assertThat(member.getMembershipType(), equalTo(MembershipType.UNMANAGED));
assertThat(organization.invitations().list(), empty());
assertInviteOrgEvent(user.getId(), organization.toRepresentation().getId());
}

private void assertInviteOrgEvent(String userId, String orgId) {
for (EventRepresentation event = events.poll(); event != null; event = events.poll()) {
if (EventType.INVITE_ORG.name().equals(event.getType())) {
assertThat(event.getUserId(), equalTo(userId));
assertThat(event.getDetails().get(Details.ORG_ID), equalTo(orgId));
return;
}
}
throw new AssertionError("no INVITE_ORG event was fired");
}

@Test
public void testInviteNewUserWhoSignsUpWithRealmIdentityProviderUsingAnotherEmail() throws IOException, MessagingException {
OrganizationResource organization = createOrganizationWithoutBroker();
// The invitation is for somebody else, so no broker may consume it.
organization.members().inviteUser("someoneelse@email", "Homer", "Simpson").close();

acceptInvitationWithBroker(createRealmBroker(), bc.getUserEmail());

assertThat(organization.members().list(-1, -1), empty());
assertThat(organization.invitations().list(), Matchers.hasSize(1));
assertThat(organization.invitations().list().get(0).getStatus(), equalTo(PENDING));
}

@Test
public void testInviteNewUserWhoSignsUpWithOrganizationIdentityProvider() throws IOException, MessagingException {
IdentityProviderRepresentation orgBroker = brokerConfigFunction.apply(organizationName).setUpIdentityProvider();
orgBroker.setHideOnLogin(false);
OrganizationResource organization = managedRealm.admin().organizations()
.get(createOrganization(managedRealm.admin(), getCleanup(), organizationName, orgBroker).getId());
String email = bc.getUserEmail();
organization.members().inviteUser(email, "Homer", "Simpson").close();

UserRepresentation user = acceptInvitationWithBroker(orgBroker, email);

// Consumed rather than left pending, even though this broker is one the org would accept anyway.
assertThat(organization.invitations().list(), empty());
MemberRepresentation member = organization.members().member(user.getId()).toRepresentation();
assertThat(member.getMembershipType(), equalTo(MembershipType.UNMANAGED));
}

// A broker on the realm, not an organization: how a shared social login has to be modelled.
private IdentityProviderRepresentation createRealmBroker() {
IdentityProviderRepresentation realmBroker = brokerConfigFunction.apply("realmbroker").setUpIdentityProvider();
realmBroker.setAlias("realmbroker");
realmBroker.setHideOnLogin(false);
managedRealm.admin().identityProviders().create(realmBroker).close();
getCleanup().addCleanup(managedRealm.admin().identityProviders().get(realmBroker.getAlias())::remove);
return realmBroker;
}

// No linked broker and no domains, so nothing else can resolve the organization.
private OrganizationResource createOrganizationWithoutBroker() {
String orgId;
try (Response response = managedRealm.admin().organizations().create(createRepresentation(organizationName))) {
assertThat(response.getStatus(), equalTo(Status.CREATED.getStatusCode()));
orgId = ApiUtil.getCreatedId(response);
}
getCleanup().addCleanup(() -> managedRealm.admin().organizations().get(orgId).delete().close());
return managedRealm.admin().organizations().get(orgId);
}

private UserRepresentation acceptInvitationWithBroker(IdentityProviderRepresentation broker, String brokeredEmail) throws IOException, MessagingException {
// The invitee has no account yet, so the invitation is a registration link.
driver.navigate().to(getInvitationLinkFromEmail());
registerPage.assertCurrent();
// No password wanted, so back to sign-in, which lists the brokers, and pick one there.
registerPage.clickBackToLogin();
loginPage.clickSocial(broker.getAlias());
loginPage.login(bc.getUserLogin(), bc.getUserPassword());
waitForPage(driver, "update account information", false);
updateAccountInformationPage.assertCurrent();
updateAccountInformationPage.updateAccountInformation(bc.getUserLogin(), brokeredEmail, "Homer", "Simpson");

List<UserRepresentation> users = managedRealm.admin().users().searchByEmail(brokeredEmail, true);
users.forEach(u -> getCleanup().addCleanup(() -> managedRealm.admin().users().get(u.getId()).remove()));
return users.isEmpty() ? null : users.get(0);
}

private void registerUser(OrganizationResource organization, String email) throws MessagingException, IOException {
registerUser(organization, email, email);
}
Expand Down
Loading