diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java
index a3b6ab6fb7..a5271e1cb3 100644
--- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java
+++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java
@@ -75,7 +75,12 @@ public interface OrganizationMembersResource {
OrganizationMemberResource member(@PathParam("id") String id);
@POST
- @Path("invite")
+ @Path("invite-user")
@Consumes(MediaType.APPLICATION_JSON)
- Response inviteMember(UserRepresentation rep);
+ Response inviteUser(String email);
+
+ @POST
+ @Path("invite-existing-user")
+ @Consumes(MediaType.APPLICATION_JSON)
+ Response inviteExistingUser(String id);
}
diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java
new file mode 100644
index 0000000000..6b6e5d321f
--- /dev/null
+++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java
@@ -0,0 +1,137 @@
+package org.keycloak.organization.admin.resource;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
+import org.keycloak.common.util.Time;
+import org.keycloak.email.EmailException;
+import org.keycloak.email.EmailTemplateProvider;
+import org.keycloak.events.admin.OperationType;
+import org.keycloak.models.Constants;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.OrganizationModel;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.organization.OrganizationProvider;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
+import org.keycloak.protocol.oidc.utils.OIDCResponseType;
+import org.keycloak.services.ErrorResponse;
+import org.keycloak.services.ServicesLogger;
+import org.keycloak.services.Urls;
+import org.keycloak.services.resources.LoginActionsService;
+import org.keycloak.services.resources.admin.AdminEventBuilder;
+import org.keycloak.storage.adapter.InMemoryUserAdapter;
+import org.keycloak.utils.StringUtil;
+
+/**
+ * @author Pedro Igor
+ */
+public class OrganizationInvitationResource {
+
+ private final KeycloakSession session;
+ private final RealmModel realm;
+ private final OrganizationProvider provider;
+ private final OrganizationModel organization;
+ private final AdminEventBuilder adminEvent;
+ private final int tokenExpiration;
+
+ public OrganizationInvitationResource(KeycloakSession session, OrganizationModel organization, AdminEventBuilder adminEvent) {
+ this.session = session;
+ this.realm = session.getContext().getRealm();
+ this.provider = session.getProvider(OrganizationProvider.class);
+ this.organization = organization;
+ this.adminEvent = adminEvent;
+ this.tokenExpiration = getTokenExpiration();
+ }
+
+ public Response inviteUser(String email) {
+ if (StringUtil.isBlank(email)) {
+ throw ErrorResponse.error("To invite a member you need to provide an email", Status.BAD_REQUEST);
+ }
+
+ UserModel user = session.users().getUserByEmail(realm, email);
+
+ if (user != null) {
+ OrganizationModel org = provider.getByMember(user);
+
+ if (org.equals(organization)) {
+ throw ErrorResponse.error("User already a member of the organization", Status.CONFLICT);
+ }
+
+ throw ErrorResponse.error("User already exists with this e-mail", Status.BAD_REQUEST);
+ }
+
+ user = new InMemoryUserAdapter(session, realm, null);
+ user.setEmail(email);
+
+ return sendInvitation(user);
+ }
+
+ public Response inviteExistingUser(String id) {
+ if (StringUtil.isBlank(id)) {
+ throw new BadRequestException("To invite a member you need to provide the user id");
+ }
+
+ UserModel user = session.users().getUserById(realm, id);
+
+ if (user == null) {
+ throw ErrorResponse.error("User does not exist", Status.BAD_REQUEST);
+ }
+
+ return sendInvitation(user);
+ }
+
+ private Response sendInvitation(UserModel user) {
+ String link = user.getId() == null ? createRegistrationLink(user) : createInvitationLink(user);
+
+ try {
+ session.getProvider(EmailTemplateProvider.class)
+ .setRealm(realm)
+ .setUser(user)
+ .sendOrgInviteEmail(link, TimeUnit.SECONDS.toMinutes(tokenExpiration));
+ } catch (EmailException e) {
+ ServicesLogger.LOGGER.failedToSendEmail(e);
+ throw ErrorResponse.error("Failed to send invite email", Status.INTERNAL_SERVER_ERROR);
+ }
+
+ adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success();
+
+ return Response.noContent().build();
+ }
+
+ private int getTokenExpiration() {
+ return Time.currentTime() + realm.getActionTokenGeneratedByAdminLifespan();
+ }
+
+ private String createInvitationLink(UserModel user) {
+ return LoginActionsService.actionTokenProcessor(session.getContext().getUri())
+ .queryParam("key", createToken(user))
+ .build(realm.getName()).toString();
+ }
+
+ private String createRegistrationLink(UserModel user) {
+ return OIDCLoginProtocolService.registrationsUrl(session.getContext().getUri().getBaseUriBuilder())
+ .queryParam(OAuth2Constants.RESPONSE_TYPE, OIDCResponseType.CODE)
+ .queryParam(Constants.CLIENT_ID, Constants.ACCOUNT_MANAGEMENT_CLIENT_ID)
+ .queryParam(Constants.ORG_TOKEN, createToken(user))
+ .buildFromMap(Map.of("realm", realm.getName(), "protocol", OIDCLoginProtocol.LOGIN_PROTOCOL)).toString();
+ }
+
+ private String createToken(UserModel user) {
+ InviteOrgActionToken token = new InviteOrgActionToken(user.getId(), tokenExpiration, user.getEmail(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
+
+ token.setOrgId(organization.getId());
+
+ String redirectUri = Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString();
+ token.setRedirectUri(redirectUri);
+
+ return token.serialize(session, realm, session.getContext().getUri());
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java
index 71a13ae470..02cfe3aecb 100644
--- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java
+++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java
@@ -35,9 +35,7 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
-import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.ext.Provider;
-import java.util.Objects;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
@@ -54,9 +52,9 @@ import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
import org.keycloak.events.admin.OperationType;
import org.keycloak.models.*;
-import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.organization.OrganizationProvider;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.idm.OrganizationRepresentation;
@@ -65,12 +63,9 @@ import org.keycloak.services.ErrorResponse;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
import org.keycloak.services.resources.LoginActionsService;
-import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.resources.admin.AdminEventBuilder;
import org.keycloak.services.resources.admin.UserResource;
-import org.keycloak.services.resources.admin.UsersResource;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
-import org.keycloak.storage.adapter.InMemoryUserAdapter;
import org.keycloak.utils.StringUtil;
@Provider
@@ -122,60 +117,16 @@ public class OrganizationMemberResource {
throw ErrorResponse.error("User is already a member of the organization.", Status.CONFLICT);
}
+ @Path("invite-user")
+ public Response inviteUser(String email) {
+ return new OrganizationInvitationResource(session, organization, adminEvent).inviteUser(email);
+ }
+
@POST
- @Path("invite")
+ @Path("invite-existing-user")
@Consumes(MediaType.APPLICATION_JSON)
- public Response inviteMember(UserRepresentation rep) {
- if (rep == null || StringUtil.isBlank(rep.getEmail())) {
- throw new BadRequestException("To invite a member you need to provide an email and/or username");
- }
-
- UserModel user = session.users().getUserByEmail(realm, rep.getEmail());
-
- InviteOrgActionToken token = null;
- String link = null;
- int tokenExpiration = Time.currentTime() + realm.getActionTokenGeneratedByAdminLifespan();
- String redirectUri = Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString();
-
- if (user != null) {
- token = new InviteOrgActionToken(user.getId(), tokenExpiration, user.getEmail(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
- token.setOrgId(organization.getId());
- token.setRedirectUri(redirectUri);
- link = LoginActionsService.actionTokenProcessor(session.getContext().getUri())
- .queryParam("key", token.serialize(session, realm, session.getContext().getUri()))
- .build(realm.getName()).toString();
- } else {
- // TODO this link really only works with implicit token grants enabled for the given client
- // this path lets us invite a user that doesn't exist yet, letting them register into the organization
- token = new InviteOrgActionToken(null, tokenExpiration, rep.getEmail(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
- token.setOrgId(organization.getId());
- token.setRedirectUri(redirectUri);
- Map params = Map.of("realm", realm.getName(), "protocol", "openid-connect");
- link = OIDCLoginProtocolService.registrationsUrl(session.getContext().getUri().getBaseUriBuilder())
- .queryParam(OAuth2Constants.RESPONSE_TYPE, OIDCResponseType.CODE)
- .queryParam(Constants.CLIENT_ID, Constants.ACCOUNT_MANAGEMENT_CLIENT_ID)
- .queryParam(Constants.ORG_TOKEN, token.serialize(session, realm, session.getContext().getUri()))
- .buildFromMap(params).toString();
- }
-
-
- if (user == null ) {
- user = new InMemoryUserAdapter(session, realm, null);
- user.setEmail(rep.getEmail());
- }
-
- try {
- session
- .getProvider(EmailTemplateProvider.class)
- .setRealm(realm)
- .setUser(user)
- .sendOrgInviteEmail(link, TimeUnit.SECONDS.toMinutes(token.getExp()));
- } catch (EmailException e) {
- ServicesLogger.LOGGER.failedToSendEmail(e);
- throw ErrorResponse.error("Failed to send invite email", Status.INTERNAL_SERVER_ERROR);
- }
- adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success();
- return Response.noContent().build();
+ public Response inviteExistingUser(String id) {
+ return new OrganizationInvitationResource(session, organization, adminEvent).inviteExistingUser(id);
}
@GET
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 9c104eab0d..2936c1a4f5 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
@@ -19,38 +19,28 @@ package org.keycloak.testsuite.organization.admin;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-import static org.keycloak.models.Constants.ACCOUNT_MANAGEMENT_CLIENT_ID;
import java.io.IOException;
+import java.util.List;
import java.util.Map;
-import java.util.UUID;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Predicate;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
-import org.jboss.arquillian.drone.webdriver.htmlunit.DroneHtmlUnitDriver;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.OrganizationResource;
-import org.keycloak.authentication.requiredactions.TermsAndConditions;
import org.keycloak.common.Profile.Feature;
-import org.keycloak.common.util.Time;
import org.keycloak.common.util.UriUtils;
-import org.keycloak.models.AuthenticationExecutionModel;
-import org.keycloak.models.Constants;
-import org.keycloak.models.UserModel;
+import org.keycloak.cookie.CookieProvider;
+import org.keycloak.cookie.CookieScope;
+import org.keycloak.cookie.CookieType;
import org.keycloak.representations.idm.RealmRepresentation;
-import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
-import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.InfoPage;
import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.util.GreenMailRule;
@@ -93,23 +83,24 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
- organization.members().inviteMember(user).close();
+ organization.members().inviteExistingUser(user.getId()).close();
MimeMessage message = greenMail.getLastReceivedMessage();
Assert.assertNotNull(message);
String link = MailUtils.getPasswordResetEmailLink(message);
-
driver.navigate().to(link.trim());
+ // not yet a member
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
+ // confirm the intent of membership
infoPage.clickToContinue();
assertThat(infoPage.getInfo(), containsString("Your account has been updated."));
- Assert.assertTrue(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
+ // now a member
+ Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation());
}
@Test
public void testInviteNewUserRegistration() throws IOException {
- driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
UserRepresentation user = UserBuilder.create()
.username("invitedUser")
.email("inviteduser@email")
@@ -117,29 +108,25 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
.build();
// User isn't created when we send the invite
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
- organization.members().inviteMember(user).close();
+ organization.members().inviteUser(user.getEmail()).close();
MimeMessage message = greenMail.getLastReceivedMessage();
Assert.assertNotNull(message);
String link = MailUtils.getPasswordResetEmailLink(message);
String orgToken = UriUtils.parseQueryParameters(link, false).values().stream().map(strings -> strings.get(0)).findFirst().orElse(null);
Assert.assertNotNull(orgToken);
-
- driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
driver.navigate().to(link.trim());
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
-
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", user.getEmail(),
user.getUsername(), "password", "password", null, false, null);
- }
+ List users = testRealm().users().searchByEmail(user.getEmail(), true);
+ Assert.assertFalse(users.isEmpty());
+ // user is a member
+ Assert.assertNotNull(organization.members().member(users.get(0).getId()).toRepresentation());
- private UserRepresentation assertUserRegistered(String userId, String username) {
- events.expectLogin().detail("username", username.toLowerCase()).user(userId).assertEvent();
-
- UserRepresentation user = testRealm().users().get(userId).toRepresentation();
- org.junit.Assert.assertNotNull(user);
- org.junit.Assert.assertNotNull(user.getCreatedTimestamp());
- return user;
+ // authenticated to the account console
+ Assert.assertTrue(driver.getPageSource().contains("Account Management"));
+ Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName()));
}
}
\ No newline at end of file