Make sure registration tokens are verified before processing registration (#46155)

Closes #46145

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2026-02-10 10:02:03 -03:00 committed by GitHub
parent 64dee82f9f
commit 8fc9a98026
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 98 additions and 23 deletions

View file

@ -84,15 +84,24 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
return invalidOrganizationResponse(tokenContext, token);
}
session.getContext().setOrganization(organization);
InvitationManager invitationManager = orgProvider.getInvitationManager();
OrganizationInvitationModel invitation = invitationManager.getById(token.getId());
AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
if (invitation == null || invitation.isExpired()) {
return invalidTokenResponse(tokenContext, token);
String orgId = authSession == null ? null : authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
if (orgId == null || !orgId.equals(token.getOrgId())) {
return invalidTokenResponse(tokenContext, token);
}
}
if (authSession != null) {
authSession.setAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organization.getId());
}
session.getContext().setOrganization(organization);
return super.preHandleToken(token, tokenContext);
}
@ -173,6 +182,7 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
.detail(Details.ORG_ID, token.getOrgId())
.error(Errors.INVALID_TOKEN);
return session.getProvider(LoginFormsProvider.class)
.setStatus(Status.BAD_REQUEST)
.setAuthenticationSession(authSession)
.setAttribute("messageHeader", Messages.EXPIRED_ACTION)
.setInfo(Messages.STALE_INVITE_ORG_LINK)
@ -189,6 +199,7 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
.detail(Details.ORG_ID, token.getOrgId())
.error(Errors.ORG_NOT_FOUND);
return session.getProvider(LoginFormsProvider.class)
.setStatus(Status.BAD_REQUEST)
.setAuthenticationSession(authSession)
.setAttribute("messageHeader", Messages.EXPIRED_ACTION)
.setInfo(Messages.ORG_NOT_FOUND, token.getOrgId())
@ -205,6 +216,7 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
.detail(Details.ORG_ID, token.getOrgId())
.error(Errors.USER_ORG_MEMBER_ALREADY);
return session.getProvider(LoginFormsProvider.class)
.setStatus(Status.BAD_REQUEST)
.setAuthenticationSession(authSession)
.setAttribute("messageHeader", Messages.EXPIRED_ACTION)
.setInfo(Messages.ORG_MEMBER_ALREADY, user.getUsername(), organization.getName())

View file

@ -59,7 +59,7 @@ public class RegistrationPage implements FormAuthenticator, FormAuthenticatorFac
public Response render(FormContext context, LoginFormsProvider form) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
try {
InviteOrgActionToken token = Organizations.parseInvitationToken(context.getHttpRequest());
InviteOrgActionToken token = Organizations.parseInvitationToken(context.getSession(), context.getHttpRequest());
if (token != null) {
KeycloakSession session = context.getSession();

View file

@ -300,7 +300,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
InviteOrgActionToken token;
try {
token = Organizations.parseInvitationToken(context.getHttpRequest());
token = Organizations.parseInvitationToken(context.getSession(), context.getHttpRequest());
} catch (VerificationException e) {
error.accept(List.of(new FormMessage("Unexpected error parsing the invitation token")));
return false;

View file

@ -32,6 +32,8 @@ import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.Constants;
import org.keycloak.models.FederatedIdentityModel;
@ -47,6 +49,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.Urls;
import org.keycloak.sessions.AuthenticationSessionModel;
import static java.util.Optional.of;
@ -153,7 +156,7 @@ public class Organizations {
}
}
public static InviteOrgActionToken parseInvitationToken(HttpRequest request) throws VerificationException {
public static InviteOrgActionToken parseInvitationToken(KeycloakSession session, HttpRequest request) throws VerificationException {
MultivaluedMap<String, String> queryParameters = request.getUri().getQueryParameters();
String tokenFromQuery = queryParameters.getFirst(Constants.TOKEN);
@ -161,7 +164,16 @@ public class Organizations {
return null;
}
return TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class).getToken();
KeycloakContext context = session.getContext();
RealmModel realm = session.getContext().getRealm();
TokenVerifier<InviteOrgActionToken> verifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class)
.withChecks(TokenVerifier.IS_ACTIVE,
new TokenVerifier.RealmUrlCheck(Urls.realmIssuer(context.getUri().getBaseUri(), realm.getName())));
SignatureVerifierContext verifierContext = session.getProvider(SignatureProvider.class, verifier.getHeader().getAlgorithm().name()).verifier(verifier.getHeader().getKeyId());
verifier.verifierContext(verifierContext);
return verifier.verify().getToken();
}
public static String getEmailDomain(String email) {

View file

@ -657,6 +657,11 @@ public class LoginActionsService {
tokenContext = new ActionTokenContext<>(session, realm, sessionContext.getUri(), clientConnection, request, event, handler, execution, clientData, this::processFlow, this::brokerLoginFlow);
if (preHandleToken != null) {
KeycloakContext context = session.getContext();
authSession = context.getAuthenticationSession();
if (authSession != null) {
tokenContext.setAuthenticationSession(authSession, false);
}
return preHandleToken.apply(handler, token, tokenContext);
}
@ -778,11 +783,7 @@ public class LoginActionsService {
@QueryParam(Constants.CLIENT_DATA) String clientData,
@QueryParam(Constants.TAB_ID) String tabId,
@QueryParam(Constants.TOKEN) String tokenString) {
if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION) && tokenString != null) {
//this call should extract orgId from token and set the organization to the session context
preHandleActionToken(tokenString);
}
return registerRequest(authSessionId, code, execution, clientId, tabId,clientData);
return registerRequest(authSessionId, code, execution, clientId, tabId,clientData, tokenString);
}
@ -801,21 +802,12 @@ public class LoginActionsService {
@QueryParam(Constants.CLIENT_DATA) String clientData,
@QueryParam(Constants.TAB_ID) String tabId,
@QueryParam(Constants.TOKEN) String tokenString) {
if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION) && tokenString != null) {
//this call should extract orgId from token and set the organization to the session context
preHandleActionToken(tokenString);
}
return registerRequest(authSessionId, code, execution, clientId, tabId, clientData);
return registerRequest(authSessionId, code, execution, clientId, tabId, clientData, tokenString);
}
private Response registerRequest(String authSessionId, String code, String execution, String clientId, String tabId, String clientData) {
private Response registerRequest(String authSessionId, String code, String execution, String clientId, String tabId, String clientData, String tokenString) {
event.event(EventType.REGISTER);
if (!Organizations.isRegistrationAllowed(session, realm)) {
event.error(Errors.REGISTRATION_DISABLED);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.REGISTRATION_NOT_ALLOWED);
}
SessionCodeChecks checks = checksForCode(authSessionId, code, execution, clientId, tabId, clientData, REGISTRATION_PATH);
if (!checks.verifyActiveAndValidAction(AuthenticationSessionModel.Action.AUTHENTICATE.name(), ClientSessionCode.ActionType.LOGIN)) {
@ -824,10 +816,25 @@ public class LoginActionsService {
AuthenticationSessionModel authSession = checks.getAuthenticationSession();
session.getContext().setAuthenticationSession(authSession);
processLocaleParam(authSession);
AuthenticationManager.expireIdentityCookie(session);
if (Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION) && tokenString != null) {
//this call should extract orgId from token and set the organization to the session context
Response response = preHandleActionToken(tokenString);
if (response != null) {
return response;
}
}
if (!Organizations.isRegistrationAllowed(session, realm)) {
event.error(Errors.REGISTRATION_DISABLED);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.REGISTRATION_NOT_ALLOWED);
}
return processRegistration(checks.isActionRequest(), execution, authSession, null);
}

View file

@ -18,6 +18,8 @@
package org.keycloak.testsuite.organization.admin;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.HashMap;
@ -30,10 +32,13 @@ import java.util.function.Predicate;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.common.util.UriUtils;
import org.keycloak.cookie.CookieType;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
@ -57,7 +62,10 @@ import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.MailUtils.EmailBody;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
@ -74,6 +82,7 @@ import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
@ -259,6 +268,41 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName()));
}
@Test
public void testRegistrationUsingRegistrationEndpoint() throws Exception {
String email = "inviteduser@email";
String firstName = "Homer";
String lastName = "Simpson";
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
organization.members().inviteUser(email, firstName, lastName).close();
URI link = URI.create(getInvitationLinkFromEmail());
NameValuePair tokenParam = URLEncodedUtils.parse(link, StandardCharsets.UTF_8).stream()
.filter((np) -> "token".equals(np.getName()))
.findAny().orElse(null);
assertThat(tokenParam, notNullValue());
JWSInput token = new JWSInput(tokenParam.getValue());
Map<String, String> tokenClaims = token.readJsonContent(Map.class);
tokenClaims.put("email", "myemail@some.org");
String modifiedToken = new JWSBuilder().content(JsonSerialization.writeValueAsString(tokenClaims).getBytes(StandardCharsets.UTF_8)).none();
modifiedToken = token.getEncodedHeader() + modifiedToken.substring(modifiedToken.indexOf('.'));
modifiedToken = modifiedToken + token.getEncodedSignature();
RealmRepresentation realm = testRealm().toRepresentation();
realm.setRegistrationAllowed(true);
testRealm().update(realm);
oauth.clientId("broker-app");
loginPage.open(realm.getRealm());
loginPage.clickRegister();
registerPage.assertCurrent();
String registerUrl = UriBuilder.fromUri(driver.getCurrentUrl())
.queryParam("token", modifiedToken)
.build().toString();
driver.navigate().to(registerUrl);
errorPage.assertCurrent();
}
@Test
public void testInviteNewUserRegistrationCustomRegistrationFlow() throws IOException, MessagingException {
String registrationFlowAlias = "custom-registration-flow";