CVE-2026-1529
Description
A flaw was found in Keycloak. An attacker can exploit this vulnerability by modifying the organization ID and target email within a legitimate invitation token's JSON Web Token (JWT) payload. This lack of cryptographic signature verification allows the attacker to successfully self-register into an unauthorized organization, leading to unauthorized access.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.keycloak:keycloak-servicesMaven | >= 26.5.0, < 26.5.3 | 26.5.3 |
org.keycloak:keycloak-servicesMaven | < 26.2.13 | 26.2.13 |
org.keycloak:keycloak-servicesMaven | >= 26.3.0, < 26.4.9 | 26.4.9 |
Patches
38fc9a9802610Make sure registration tokens are verified before processing registration (#46155)
6 files changed · +98 −23
services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java+15 −3 modified@@ -84,15 +84,24 @@ public Response preHandleToken(InviteOrgActionToken token, ActionTokenContext<In 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 @@ private Response invalidTokenResponse(ActionTokenContext<InviteOrgActionToken> t .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 @@ private Response invalidOrganizationResponse(ActionTokenContext<InviteOrgActionT .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 @@ private Response alreadyMemberResponse(OrganizationModel organization, UserModel .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())
services/src/main/java/org/keycloak/authentication/forms/RegistrationPage.java+1 −1 modified@@ -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();
services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java+1 −1 modified@@ -300,7 +300,7 @@ private boolean validateOrganizationInvitation(ValidationContext context, Multiv 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;
services/src/main/java/org/keycloak/organization/utils/Organizations.java+14 −2 modified@@ -32,6 +32,8 @@ 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.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,15 +156,24 @@ public static void checkEnabled(OrganizationProvider provider) { } } - 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); if (tokenFromQuery == null) { 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) {
services/src/main/java/org/keycloak/services/resources/LoginActionsService.java+23 −16 modified@@ -657,6 +657,11 @@ protected <T extends JsonWebToken & SingleUseObjectKeyModel> Response handleActi 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 Response registerPage(@QueryParam(AUTH_SESSION_ID) String authSessionId, @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 Response processRegister(@QueryParam(AUTH_SESSION_ID) String authSessionI @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 @@ private Response registerRequest(String authSessionId, String code, String execu 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); }
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java+44 −0 modified@@ -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 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.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.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 void testInviteNewUserRegistration() throws IOException, MessagingExcepti 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";
82cd7941d1ddMake sure registration tokens are verified before processing registration
6 files changed · +144 −47
services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java+60 −27 modified@@ -104,40 +104,18 @@ public Response handleToken(InviteOrgActionToken token, ActionTokenContext<Invit OrganizationModel organization = orgProvider.getById(token.getOrgId()); if (organization == null) { - event.user(user).error(Errors.ORG_NOT_FOUND); - return session.getProvider(LoginFormsProvider.class) - .setAuthenticationSession(authSession) - .setInfo(Messages.ORG_NOT_FOUND, token.getOrgId()) - .createInfoPage(); + return invalidOrganizationResponse(tokenContext, token); } if (organization.isMember(user)) { - event.user(user).error(Errors.USER_ORG_MEMBER_ALREADY); - return session.getProvider(LoginFormsProvider.class) - .setAuthenticationSession(authSession) - .setInfo(Messages.ORG_MEMBER_ALREADY, user.getUsername()) - .setAttribute("pageRedirectUri", organization.getRedirectUrl()) - .createInfoPage(); + return alreadyMemberResponse(organization, user, tokenContext, token); } - final UriInfo uriInfo = tokenContext.getUriInfo(); - final RealmModel realm = tokenContext.getRealm(); + UriInfo uriInfo = tokenContext.getUriInfo(); + RealmModel realm = tokenContext.getRealm(); if (tokenContext.isAuthenticationSessionFresh()) { - // Update the authentication session in the token - String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId(); - token.setCompoundAuthenticationSessionId(authSessionEncodedId); - UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo), - authSession.getClient().getClientId(), authSession.getTabId(), AuthenticationProcessor.getClientData(session, authSession)); - String confirmUri = builder.build(realm.getName()).toString(); - - return session.getProvider(LoginFormsProvider.class) - .setAuthenticationSession(authSession) - .setSuccess(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP, organization.getName()) - .setAttribute("messageHeader", Messages.CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE) - .setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri) - .setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName()) - .createInfoPage(); + return confirmMembershipResponse(organization, user, tokenContext, token); } // if we made it this far then go ahead and add the user to the organization @@ -169,4 +147,59 @@ public Response handleToken(InviteOrgActionToken token, ActionTokenContext<Invit return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction); } + + private Response invalidOrganizationResponse(ActionTokenContext<InviteOrgActionToken> tokenContext, InviteOrgActionToken token) { + EventBuilder event = tokenContext.getEvent(); + KeycloakSession session = tokenContext.getSession(); + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + + event.detail(Details.TOKEN_ID, token.getId()) + .detail(Details.EMAIL, token.getEmail()) + .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()) + .createInfoPage(); + } + + private Response alreadyMemberResponse(OrganizationModel organization, UserModel user, ActionTokenContext<InviteOrgActionToken> tokenContext, InviteOrgActionToken token) { + EventBuilder event = tokenContext.getEvent(); + KeycloakSession session = tokenContext.getSession(); + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + + event.detail(Details.TOKEN_ID, token.getId()) + .detail(Details.EMAIL, token.getEmail()) + .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()) + .setAttribute("pageRedirectUri", organization.getRedirectUrl()) + .createInfoPage(); + } + + private Response confirmMembershipResponse(OrganizationModel organization, UserModel user, ActionTokenContext<InviteOrgActionToken> tokenContext, InviteOrgActionToken token) { + KeycloakSession session = tokenContext.getSession(); + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + UriInfo uriInfo = tokenContext.getUriInfo(); + String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId(); + token.setCompoundAuthenticationSessionId(authSessionEncodedId); + RealmModel realm = tokenContext.getRealm(); + UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo), + authSession.getClient().getClientId(), authSession.getTabId(), AuthenticationProcessor.getClientData(session, authSession)); + String confirmUri = builder.build(realm.getName()).toString(); + + return session.getProvider(LoginFormsProvider.class) + .setAuthenticationSession(authSession) + .setSuccess(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP, organization.getName()) + .setAttribute("messageHeader", Messages.CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE) + .setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri) + .setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName()) + .createInfoPage(); + } }
services/src/main/java/org/keycloak/authentication/forms/RegistrationPage.java+1 −1 modified@@ -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();
services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java+1 −1 modified@@ -297,7 +297,7 @@ private boolean validateOrganizationInvitation(ValidationContext context, Multiv 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;
services/src/main/java/org/keycloak/organization/utils/Organizations.java+14 −2 modified@@ -35,6 +35,8 @@ 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; @@ -50,6 +52,7 @@ 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; public class Organizations { @@ -148,15 +151,24 @@ public static void checkEnabled(OrganizationProvider provider) { } } - 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); if (tokenFromQuery == null) { 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) {
services/src/main/java/org/keycloak/services/resources/LoginActionsService.java+23 −16 modified@@ -655,6 +655,11 @@ protected <T extends JsonWebToken & SingleUseObjectKeyModel> Response handleActi 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); } @@ -776,11 +781,7 @@ public Response registerPage(@QueryParam(AUTH_SESSION_ID) String authSessionId, @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); } @@ -799,21 +800,12 @@ public Response processRegister(@QueryParam(AUTH_SESSION_ID) String authSessionI @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)) { @@ -822,10 +814,25 @@ private Response registerRequest(String authSessionId, String code, String execu 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); }
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java+45 −0 modified@@ -22,9 +22,12 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; import static org.keycloak.services.messages.Messages.ORG_MEMBER_ALREADY; import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -37,6 +40,10 @@ import jakarta.mail.internet.MimeMessage; import jakarta.ws.rs.core.Response; import java.time.Duration; + +import jakarta.ws.rs.core.UriBuilder; +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; @@ -45,6 +52,8 @@ 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.utils.DefaultAuthenticationFlows; import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; @@ -66,6 +75,7 @@ import org.keycloak.testsuite.util.MailUtils.EmailBody; import org.keycloak.testsuite.util.oauth.OAuthClient; import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; import org.openqa.selenium.By; public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { @@ -205,6 +215,41 @@ public void testInviteNewUserRegistration() throws IOException, MessagingExcepti 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";
b2519756487bMake sure registration tokens are verified before processing registration
6 files changed · +145 −47
services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java+60 −27 modified@@ -104,40 +104,18 @@ public Response handleToken(InviteOrgActionToken token, ActionTokenContext<Invit OrganizationModel organization = orgProvider.getById(token.getOrgId()); if (organization == null) { - event.user(user).error(Errors.ORG_NOT_FOUND); - return session.getProvider(LoginFormsProvider.class) - .setAuthenticationSession(authSession) - .setInfo(Messages.ORG_NOT_FOUND, token.getOrgId()) - .createInfoPage(); + return invalidOrganizationResponse(tokenContext, token); } if (organization.isMember(user)) { - event.user(user).error(Errors.USER_ORG_MEMBER_ALREADY); - return session.getProvider(LoginFormsProvider.class) - .setAuthenticationSession(authSession) - .setInfo(Messages.ORG_MEMBER_ALREADY, user.getUsername()) - .setAttribute("pageRedirectUri", organization.getRedirectUrl()) - .createInfoPage(); + return alreadyMemberResponse(organization, user, tokenContext, token); } - final UriInfo uriInfo = tokenContext.getUriInfo(); - final RealmModel realm = tokenContext.getRealm(); + UriInfo uriInfo = tokenContext.getUriInfo(); + RealmModel realm = tokenContext.getRealm(); if (tokenContext.isAuthenticationSessionFresh()) { - // Update the authentication session in the token - String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId(); - token.setCompoundAuthenticationSessionId(authSessionEncodedId); - UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo), - authSession.getClient().getClientId(), authSession.getTabId(), AuthenticationProcessor.getClientData(session, authSession)); - String confirmUri = builder.build(realm.getName()).toString(); - - return session.getProvider(LoginFormsProvider.class) - .setAuthenticationSession(authSession) - .setSuccess(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP, organization.getName()) - .setAttribute("messageHeader", Messages.CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE) - .setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri) - .setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName()) - .createInfoPage(); + return confirmMembershipResponse(organization, user, tokenContext, token); } // if we made it this far then go ahead and add the user to the organization @@ -169,4 +147,59 @@ public Response handleToken(InviteOrgActionToken token, ActionTokenContext<Invit return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction); } + + private Response invalidOrganizationResponse(ActionTokenContext<InviteOrgActionToken> tokenContext, InviteOrgActionToken token) { + EventBuilder event = tokenContext.getEvent(); + KeycloakSession session = tokenContext.getSession(); + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + + event.detail(Details.TOKEN_ID, token.getId()) + .detail(Details.EMAIL, token.getEmail()) + .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()) + .createInfoPage(); + } + + private Response alreadyMemberResponse(OrganizationModel organization, UserModel user, ActionTokenContext<InviteOrgActionToken> tokenContext, InviteOrgActionToken token) { + EventBuilder event = tokenContext.getEvent(); + KeycloakSession session = tokenContext.getSession(); + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + + event.detail(Details.TOKEN_ID, token.getId()) + .detail(Details.EMAIL, token.getEmail()) + .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()) + .setAttribute("pageRedirectUri", organization.getRedirectUrl()) + .createInfoPage(); + } + + private Response confirmMembershipResponse(OrganizationModel organization, UserModel user, ActionTokenContext<InviteOrgActionToken> tokenContext, InviteOrgActionToken token) { + KeycloakSession session = tokenContext.getSession(); + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + UriInfo uriInfo = tokenContext.getUriInfo(); + String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId(); + token.setCompoundAuthenticationSessionId(authSessionEncodedId); + RealmModel realm = tokenContext.getRealm(); + UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo), + authSession.getClient().getClientId(), authSession.getTabId(), AuthenticationProcessor.getClientData(session, authSession)); + String confirmUri = builder.build(realm.getName()).toString(); + + return session.getProvider(LoginFormsProvider.class) + .setAuthenticationSession(authSession) + .setSuccess(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP, organization.getName()) + .setAttribute("messageHeader", Messages.CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE) + .setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri) + .setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName()) + .createInfoPage(); + } }
services/src/main/java/org/keycloak/authentication/forms/RegistrationPage.java+1 −1 modified@@ -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();
services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java+1 −1 modified@@ -297,7 +297,7 @@ private boolean validateOrganizationInvitation(ValidationContext context, Multiv 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;
services/src/main/java/org/keycloak/organization/utils/Organizations.java+15 −2 modified@@ -35,12 +35,15 @@ 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; import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel.Type; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationModel; @@ -49,6 +52,7 @@ 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; public class Organizations { @@ -147,15 +151,24 @@ public static void checkEnabled(OrganizationProvider provider) { } } - 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); if (tokenFromQuery == null) { 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) {
services/src/main/java/org/keycloak/services/resources/LoginActionsService.java+23 −16 modified@@ -641,6 +641,11 @@ protected <T extends JsonWebToken & SingleUseObjectKeyModel> Response handleActi 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); } @@ -762,11 +767,7 @@ public Response registerPage(@QueryParam(AUTH_SESSION_ID) String authSessionId, @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); } @@ -785,21 +786,12 @@ public Response processRegister(@QueryParam(AUTH_SESSION_ID) String authSessionI @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)) { @@ -808,10 +800,25 @@ private Response registerRequest(String authSessionId, String code, String execu 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); }
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java+45 −0 modified@@ -22,9 +22,12 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; import static org.keycloak.services.messages.Messages.ORG_MEMBER_ALREADY; import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -37,6 +40,10 @@ import jakarta.mail.internet.MimeMessage; import jakarta.ws.rs.core.Response; import java.time.Duration; + +import jakarta.ws.rs.core.UriBuilder; +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; @@ -45,6 +52,8 @@ 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.utils.DefaultAuthenticationFlows; import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; @@ -66,6 +75,7 @@ import org.keycloak.testsuite.util.MailUtils.EmailBody; import org.keycloak.testsuite.util.oauth.OAuthClient; import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; import org.openqa.selenium.By; public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { @@ -205,6 +215,41 @@ public void testInviteNewUserRegistration() throws IOException, MessagingExcepti 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";
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
13- github.com/advisories/GHSA-hcvw-475w-8g7pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-1529ghsaADVISORY
- access.redhat.com/errata/RHSA-2026:2363nvdWEB
- access.redhat.com/errata/RHSA-2026:2364nvdWEB
- access.redhat.com/errata/RHSA-2026:2365nvdWEB
- access.redhat.com/errata/RHSA-2026:2366nvdWEB
- access.redhat.com/security/cve/CVE-2026-1529nvdWEB
- bugzilla.redhat.com/show_bug.cginvdWEB
- github.com/keycloak/keycloak/commit/82cd7941d1dd28fa14a67a6e6b912301f1a5e1a1ghsaWEB
- github.com/keycloak/keycloak/commit/8fc9a98026106a326f4faa98d4c9a48341ace2d7ghsaWEB
- github.com/keycloak/keycloak/commit/b2519756487b519f95c07aa8b10afe003e492119ghsaWEB
- github.com/keycloak/keycloak/issues/46145ghsaWEB
- github.com/keycloak/keycloak/pull/46155ghsaWEB
News mentions
0No linked articles in our index yet.