Fix organization invitation redirect to respect account client base URL

When an organization's redirect URL is left empty, Keycloak currently defaults
to the account console URL, ignoring the account client's configured Home URL
(base URL). This fix checks the account client's base URL before falling back
to the default account console URL.

Changes:
- Added resolveAccountClientBaseUrl() helper method in OrganizationInvitationResource
- Added setBaseUrl() method to ClientAttributeUpdater test utility
- Added integration tests for the new behavior

Closes #45052

Signed-off-by: Rathan Naik <30756840+Rathan-Naik@users.noreply.github.com>
This commit is contained in:
Rathan Naik 2026-01-03 22:28:29 +05:30 committed by Pedro Igor
parent a6bf194487
commit 2af7c843af
3 changed files with 69 additions and 1 deletions

View File

@ -39,6 +39,7 @@ import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationInvitationModel;
@ -58,6 +59,7 @@ import org.keycloak.services.Urls;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.services.resources.admin.AdminEventBuilder;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.services.validation.Validation;
import org.keycloak.storage.adapter.InMemoryUserAdapter;
import org.keycloak.utils.StringUtil;
@ -203,7 +205,7 @@ public class OrganizationInvitationResource {
token.id(invitation.getId());
if (organization.getRedirectUrl() == null || organization.getRedirectUrl().isBlank()) {
token.setRedirectUri(Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString());
token.setRedirectUri(resolveAccountClientBaseUrl());
} else {
token.setRedirectUri(organization.getRedirectUrl());
}
@ -211,6 +213,19 @@ public class OrganizationInvitationResource {
return token.serialize(session, realm, session.getContext().getUri());
}
private String resolveAccountClientBaseUrl() {
ClientModel accountClient = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
if (accountClient != null) {
String baseUrl = accountClient.getBaseUrl();
if (baseUrl != null && !baseUrl.isBlank()) {
return ResolveRelative.resolveRelativeUri(session, accountClient.getRootUrl(), baseUrl);
}
}
return Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString();
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get invitations for the organization")

View File

@ -155,6 +155,11 @@ public class ClientAttributeUpdater extends ServerResourceUpdater<ClientAttribut
return this;
}
public ClientAttributeUpdater setBaseUrl(String baseUrl) {
rep.setBaseUrl(baseUrl);
return this;
}
public ClientAttributeUpdater addDefaultClientScope(String clientScope) {
rep.getDefaultClientScopes().add(clientScope);
return this;

View File

@ -35,6 +35,7 @@ import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.common.util.UriUtils;
import org.keycloak.cookie.CookieType;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.representations.idm.AuthenticationExecutionRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
@ -48,6 +49,7 @@ import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
import org.keycloak.testsuite.pages.InfoPage;
import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import org.keycloak.testsuite.updaters.OrganizationAttributeUpdater;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.GreenMailRule;
@ -187,6 +189,52 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
}
}
@Test
public void testInviteWithAccountClientCustomBaseUrl() throws IOException, MessagingException {
UserRepresentation user = createUser("invited", "invited@myemail.com");
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
try (
ClientAttributeUpdater cau = ClientAttributeUpdater.forClient(adminClient, TEST_REALM_NAME, Constants.ACCOUNT_MANAGEMENT_CLIENT_ID)
.setBaseUrl(OAuthClient.APP_AUTH_ROOT)
.update();
Response response = organization.members().inviteExistingUser(user.getId());
) {
assertThat(response.getStatus(), equalTo(Response.Status.NO_CONTENT.getStatusCode()));
acceptInvitation(organization, user, "AUTH_RESPONSE");
}
}
@Test
public void testInviteNewUserRegistrationWithAccountClientCustomBaseUrl() throws IOException, MessagingException {
String email = "inviteduser@email";
String firstName = "Homer";
String lastName = "Simpson";
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
try (
ClientAttributeUpdater cau = ClientAttributeUpdater.forClient(adminClient, TEST_REALM_NAME, Constants.ACCOUNT_MANAGEMENT_CLIENT_ID)
.setBaseUrl(OAuthClient.APP_AUTH_ROOT)
.update();
) {
organization.members().inviteUser(email, firstName, lastName).close();
registerUser(organization, email);
List<UserRepresentation> users = testRealm().users().searchByEmail(email, true);
assertThat(users, not(empty()));
MemberRepresentation member = organization.members().member(users.get(0).getId()).toRepresentation();
Assert.assertNotNull(member);
assertThat(member.getMembershipType(), equalTo(MembershipType.MANAGED));
getCleanup().addCleanup(() -> testRealm().users().get(users.get(0).getId()).remove());
assertThat(driver.getTitle(), containsString("AUTH_RESPONSE"));
}
}
@Test
public void testInviteNewUserRegistration() throws IOException, MessagingException {
String email = "inviteduser@email";