From d2a5f846e5c227d7b66843b98941ef298355f1fb Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Fri, 28 Aug 2026 16:20:40 +0200 Subject: [PATCH] Keep the organization invitation through broker login Closes #52181 Signed-off-by: Alexis Rico --- ...IdpAddOrganizationMemberAuthenticator.java | 69 +++++++++++++ .../organization/utils/Organizations.java | 15 ++- .../oidc/endpoints/AuthorizationEndpoint.java | 17 ++++ .../resources/IdentityBrokerService.java | 10 ++ .../admin/OrganizationInvitationLinkTest.java | 97 +++++++++++++++++++ 5 files changed, 205 insertions(+), 3 deletions(-) diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpAddOrganizationMemberAuthenticator.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpAddOrganizationMemberAuthenticator.java index 0c266e8b7151..56dd0b88fe59 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpAddOrganizationMemberAuthenticator.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/broker/IdpAddOrganizationMemberAuthenticator.java @@ -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; @@ -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) { @@ -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; @@ -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()) { diff --git a/services/src/main/java/org/keycloak/organization/utils/Organizations.java b/services/src/main/java/org/keycloak/organization/utils/Organizations.java index 5236e603a723..f94aba01d038 100644 --- a/services/src/main/java/org/keycloak/organization/utils/Organizations.java +++ b/services/src/main/java/org/keycloak/organization/utils/Organizations.java @@ -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; @@ -189,15 +195,18 @@ public static void checkEnabled(OrganizationProvider provider, AdminPermissionEv public static InviteOrgActionToken parseInvitationToken(KeycloakSession session, HttpRequest request) throws VerificationException { MultivaluedMap 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 verifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class) + TokenVerifier verifier = TokenVerifier.create(tokenString, InviteOrgActionToken.class) .withChecks(TokenVerifier.IS_ACTIVE, new TokenVerifier.RealmUrlCheck(Urls.realmIssuer(context.getUri().getBaseUri(), realm.getName()))); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index 2c0a177619a4..aa09764608f0 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -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; @@ -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); @@ -251,6 +253,7 @@ private Response process(final MultivaluedMap 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 @@ -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) { diff --git a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java index 5c6bccca396d..8f14d31e10b0 100755 --- a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java +++ b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java @@ -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; @@ -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) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java index 1aa088845bde..00e25a2ee3c7 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java @@ -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; @@ -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; @@ -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; @@ -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 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); }