Description
In a B2B deployment where an application is shared from the root organization into multiple sub-organizations and users log in via Organization SSO, back-channel logout succeeds for one sub-organization and fails for all the others on the same node, with:
com.nimbusds.jose.KeySourceException: No matching keys found in JWKS endpoint: https://<host>/o/<org-id>/oauth2/jwks
LogoutServerException: Error while validating the logout token signature [OID-60009]
The logout tokens are signed correctly — the kid in the token matches the kid published by the issuing organization's own JWKS endpoint (/o/<org-id>/oauth2/jwks). The defect is that the token is validated against a different organization's JWKS endpoint.
Each sub-organization has its own signing key and publishes it at its org-scoped endpoint /o/<org-id>/oauth2/jwks, so a logout token issued by organization B can only validate against organization B's JWKS. When it is checked against organization A's JWKS, its kid is legitimately absent and validation fails.
The reason the wrong endpoint is used: IdentityProviderManager.getSSOIDP resolves the shared SSO (Organization Login) IdP from CacheBackedIdPMgtDAO, which returns the cached instance by reference, and then mutates that instance by appending a jwksUri property for the organization of the current request. Because the property is appended rather than replaced, and JWTSignatureValidationUtils.getJWKSUri returns the first jwksUri it finds, the first sub-organization to perform a back-channel logout after the cache entry is populated pins the JWKS endpoint for every sub-organization on that node.
Consequences:
- Back-channel logouts from the organization that "claimed" the entry keep succeeding (correct by coincidence).
- Back-channel logouts from every other sub-organization fail.
IdPCacheByName is configured timeout="900" and built with setExpiry(ACCESSED, …) — a 15-minute sliding expiry refreshed on every read — so a busy node can stay pinned indefinitely, and after a quiet gap the next organization claims it. This is why the failure looks intermittent, appears to move between nodes, and can affect several nodes at once (each node has its own cache).
- Secondary effect: the
SSO IdP's property array grows by one jwksUri entry per distinct organization for the lifetime of the cache entry.
Root cause
For a sub-org logout token the lookup by idpIssuerName misses, so FederatedIdpInitLogoutProcessor.getIdentityProvider falls back to getIdPByName(<issuer>, tenantDomain), which routes an issuer-shaped name into the sub-org branch:
// IdentityProviderManager#getIdPByName (L893-897)
// Get the SSO IDP for back-channel logout requests coming from sub-organizations.
if ((identityProvider == null || StringUtils.isBlank(identityProvider.getId()))
&& idPName.contains(IdentityUtil.getHostName()) && ISSUER_PATTERN.matcher(idPName).matches()) {
identityProvider = getSSOIDP(idPName, tenantId, tenantDomain);
}
getSSOIDP mutates the object it gets back from the cache:
// IdentityProviderManager#getSSOIDP (L2902-2911)
IdentityProvider identityProvider = dao.getIdPByName(null, ORGANIZATION_LOGIN_IDP_NAME, tenantId, tenantDomain);
if (identityProvider != null && StringUtils.isNotBlank(identityProvider.getId())) {
identityProvider.setIdpProperties(
addJWKSUriProperty(identityProvider.getIdpProperties(), jwtIssuer)); // writes into the cached instance
}
CacheBackedIdPMgtDAO.getIdPByName returns the cached instance itself, with no defensive copy (and IdPCacheByName is built setStoreByValue(false)):
// CacheBackedIdPMgtDAO#getIdPByName (L236-240)
if (entry != null) {
IdentityProvider identityProvider = entry.getIdentityProvider();
IdPManagementUtil.removeRandomPasswords(identityProvider, false);
return identityProvider; // same instance held in the cache
}
addJWKSUriProperty appends without removing an existing jwksUri:
// IdentityProviderManager#addJWKSUriProperty (L2921-2933)
String jwksUri = jwtIssuer.replace(OAUTH2_TOKEN_EP_URL, OAUTH2_JWKS_EP_URL);
IdentityProviderProperty jwksEndpoint = new IdentityProviderProperty();
jwksEndpoint.setName(JWKS_URI);
jwksEndpoint.setValue(jwksUri);
identityProviderProperties.add(jwksEndpoint);
and the validation side takes the first match and stops:
// JWTSignatureValidationUtils#getJWKSUri (L104-128)
if (StringUtils.equals(identityProviderProperty.getName(), JWKS_URI)) {
jwksUri = identityProviderProperty.getValue();
break; // first jwksUri wins
}
All sub-org back-channel logouts are processed under the root (carbon.super) tenant, so they all resolve the same single SSO cache entry and therefore interfere with one another.
Affected code
org.wso2.carbon.idp.mgt.IdentityProviderManager#getSSOIDP (carbon-identity-framework, idp-mgt) — mutates the cached IdentityProvider returned by the DAO.
org.wso2.carbon.idp.mgt.IdentityProviderManager#addJWKSUriProperty — appends a jwksUri property instead of replacing any existing one.
org.wso2.carbon.idp.mgt.dao.CacheBackedIdPMgtDAO#getIdPByName — returns the cached IdentityProvider by reference with no defensive copy.
org.wso2.carbon.identity.oauth2.util.JWTSignatureValidationUtils#getJWKSUri (identity-inbound-auth-oauth) — returns the first jwksUri property and breaks.
Stacktrace
Server log for a back-channel logout token issued by organization B, resolved against organization A's JWKS (DEBUG lines from org.wso2.carbon.identity.oauth2 included for context):
DEBUG {...idpinit.processor.FederatedIdpInitLogoutProcessor} - IDP not found when retrieving for IDP using property: idpIssuerName with value: https://<host>/o/<orgB-id>/oauth2/token. Attempting to retrieve IDP using IDP Name as issuer.
DEBUG {org.wso2.carbon.identity.oauth2.util.JWTSignatureValidationUtils} - JWKS endpoint set for the identity provider : SSO, jwks_uri : https://<host>/o/<orgA-id>/oauth2/jwks
DEBUG {org.wso2.carbon.identity.oauth2.validators.jwt.JWKSourceDataProvider} - Retrieving JWKS for https://<host>/o/<orgA-id>/oauth2/jwks from cache.
ERROR {org.wso2.carbon.identity.oauth2.validators.jwt.JWKSBasedJWTValidator} - Error occurred while accessing remote JWKS endpoint: https://<host>/o/<orgA-id>/oauth2/jwks
com.nimbusds.jose.KeySourceException: No matching keys found in JWKS endpoint: https://<host>/o/<orgA-id>/oauth2/jwks
at org.wso2.carbon.identity.oauth2.validators.jwt.JWKSBasedJWTValidator.checkCertificateValidity(JWKSBasedJWTValidator.java:162)
at org.wso2.carbon.identity.oauth2.validators.jwt.JWKSBasedJWTValidator.validateSignature(JWKSBasedJWTValidator.java:88)
at org.wso2.carbon.identity.oauth2.util.JWTSignatureValidationUtils.validateUsingJWKSUri(JWTSignatureValidationUtils.java:141)
at org.wso2.carbon.identity.oauth2.util.JWTSignatureValidationUtils.validateSignature(JWTSignatureValidationUtils.java:74)
at ...idpinit.processor.FederatedIdpInitLogoutProcessor.validateSignature(FederatedIdpInitLogoutProcessor.java:320)
at ...idpinit.processor.FederatedIdpInitLogoutProcessor.validateLogoutToken(FederatedIdpInitLogoutProcessor.java:295)
at ...idpinit.processor.FederatedIdpInitLogoutProcessor.doBackChannelLogout(FederatedIdpInitLogoutProcessor.java:145)
DEBUG {...idpinit.processor.FederatedIdpInitLogoutProcessor} - Error while validating the logout token signature Error code: OID-60009
ERROR {org.wso2.carbon.identity.application.authentication.framework.inbound.IdentityServlet} - Failed to process IdentityRequest
ERROR {...idpinit.factory.LogoutResponseFactory} - Back channel logout failed due to server error
Note the mismatch between the token's issuer (/o/<orgB-id>/) and the jwks_uri used to validate it (/o/<orgA-id>/).
Steps to Reproduce
Reproduced end-to-end on WSO2 Identity Server 7.2.0 update level 35, single node, default H2 pack.
- Enable
DEBUG for org.wso2.carbon.identity.oauth2, org.wso2.carbon.identity.application.authenticator and org.wso2.carbon.identity.oidc so the resolved jwks_uri is visible.
- Create two sub-organizations, A and B, under the root organization.
- Register an OIDC application (
authorization_code) in the root organization and share it with both sub-organizations.
Sharing automatically adds SSO / OrganizationAuthenticator as a login option on the root application, and creates a fragment application in each sub-organization whose back-channel logout URL is set to https://<host>/identity/oidc/slo — this is what carries the logout token that fails.
- Create a user in each sub-organization.
- Log in to the shared application via Organization SSO as the organization A user (supplying organization A's handle at the organization prompt), then perform RP-initiated logout at
/oidc/logout and approve the logout consent.
- Within 15 minutes, against the same node, repeat step 5 for the organization B user.
Expected behaviour: each sub-organization's back-channel logout token is validated against that organization's own JWKS endpoint (/o/<org-id>/oauth2/jwks) and the logout completes.
Actual behaviour: the first organization to log out claims the cached SSO IdP entry, and the second organization's logout token is validated against the first organization's JWKS endpoint and fails.
Observed — organization B logging out first (succeeds, and claims the entry):
DEBUG {...idpinit.processor.FederatedIdpInitLogoutProcessor} - Started processing OIDC federated IDP initiated logout request.
DEBUG {...idpinit.processor.FederatedIdpInitLogoutProcessor} - IDP not found when retrieving for IDP using property: idpIssuerName with value: https://localhost:9453/o/82faa113-1c31-4796-b760-de4e030f0c38/oauth2/token. Attempting to retrieve IDP using IDP Name as issuer.
DEBUG {org.wso2.carbon.identity.oauth2.util.JWTSignatureValidationUtils} - JWKS endpoint set for the identity provider : SSO, jwks_uri : https://localhost:9453/o/82faa113-1c31-4796-b760-de4e030f0c38/oauth2/jwks
DEBUG {org.wso2.carbon.identity.oauth2.validators.jwt.JWKSBasedJWTValidator} - Matching key found in JWKS endpoint: https://localhost:9453/o/82faa113-1c31-4796-b760-de4e030f0c38/oauth2/jwks
DEBUG {...idpinit.processor.FederatedIdpInitLogoutProcessor} - Session terminated for session Id: 7faf338e3bb7c40d260a2a52b82627e8a76957bce7d81baa6c0af16c02f0d36e
Then organization A logging out ~5 seconds later — its token (iss = organization A) is validated against organization B's JWKS:
DEBUG {...idpinit.processor.FederatedIdpInitLogoutProcessor} - IDP not found when retrieving for IDP using property: idpIssuerName with value: https://localhost:9453/o/592c2123-d469-498e-b917-5f3bbc6b251c/oauth2/token. Attempting to retrieve IDP using IDP Name as issuer.
DEBUG {org.wso2.carbon.identity.oauth2.util.JWTSignatureValidationUtils} - JWKS endpoint set for the identity provider : SSO, jwks_uri : https://localhost:9453/o/82faa113-1c31-4796-b760-de4e030f0c38/oauth2/jwks
ERROR {org.wso2.carbon.identity.oauth2.validators.jwt.JWKSBasedJWTValidator} - Error occurred while accessing remote JWKS endpoint: https://localhost:9453/o/82faa113-1c31-4796-b760-de4e030f0c38/oauth2/jwks
com.nimbusds.jose.KeySourceException: No matching keys found in JWKS endpoint: https://localhost:9453/o/82faa113-1c31-4796-b760-de4e030f0c38/oauth2/jwks
DEBUG {...idpinit.processor.FederatedIdpInitLogoutProcessor} - Error while validating the logout token signature Error code: OID-60009
ERROR {org.wso2.carbon.identity.application.authentication.framework.inbound.IdentityServlet} - Failed to process IdentityRequest
ERROR {...idpinit.factory.LogoutResponseFactory} - Back channel logout failed due to server error
Running the two organizations in the opposite order reverses which one fails (organization A first → organization B's logout fails against organization A's JWKS), confirming the behaviour is claim-order dependent and not specific to either organization. Restarting the node, or leaving it idle for more than 15 minutes, clears the pinned entry and the next organization to log out claims it.
Environment note (not part of the defect): the default wso2carbon TLS certificate shipped with the 7.2.0 pack expires 2026-01-08. After that date IS cannot complete the back-channel logout POST to its own HTTPS endpoint, so nothing reaches /identity/oidc/slo and the failure above cannot be observed at all. Regenerate the certificate and re-import it into client-truststore.p12 before reproducing.
Suggested Fix
- Primary —
getSSOIDP must not mutate the cached instance. Either operate on a copy of the IdentityProvider, or (cleaner) resolve the JWKS URI for the request and pass it explicitly to signature validation. JWTSignatureValidationUtils.validateUsingJWKSUri(SignedJWT, String jwksUri) is already public and takes the URI directly, so the per-request value need not travel through the IdP's properties at all.
- Defence in depth —
addJWKSUriProperty should replace any existing jwksUri rather than append. This alone is not sufficient: concurrent back-channel logouts from two organizations would still race on the shared object. It does remove the permanent pinning and the unbounded property growth.
- Consider whether
CacheBackedIdPMgtDAO#getIdPByName returning cached IdentityProvider instances by reference is safe in general, given callers are free to mutate them.
Please select the area issue is related to
Identity Server Core
Version
IS 7.2.0, IS 7.3.0
Environment Details (with versions)
N/A
Developer Checklist
Description
In a B2B deployment where an application is shared from the root organization into multiple sub-organizations and users log in via Organization SSO, back-channel logout succeeds for one sub-organization and fails for all the others on the same node, with:
The logout tokens are signed correctly — the
kidin the token matches thekidpublished by the issuing organization's own JWKS endpoint (/o/<org-id>/oauth2/jwks). The defect is that the token is validated against a different organization's JWKS endpoint.Each sub-organization has its own signing key and publishes it at its org-scoped endpoint
/o/<org-id>/oauth2/jwks, so a logout token issued by organization B can only validate against organization B's JWKS. When it is checked against organization A's JWKS, itskidis legitimately absent and validation fails.The reason the wrong endpoint is used:
IdentityProviderManager.getSSOIDPresolves the sharedSSO(Organization Login) IdP fromCacheBackedIdPMgtDAO, which returns the cached instance by reference, and then mutates that instance by appending ajwksUriproperty for the organization of the current request. Because the property is appended rather than replaced, andJWTSignatureValidationUtils.getJWKSUrireturns the firstjwksUriit finds, the first sub-organization to perform a back-channel logout after the cache entry is populated pins the JWKS endpoint for every sub-organization on that node.Consequences:
IdPCacheByNameis configuredtimeout="900"and built withsetExpiry(ACCESSED, …)— a 15-minute sliding expiry refreshed on every read — so a busy node can stay pinned indefinitely, and after a quiet gap the next organization claims it. This is why the failure looks intermittent, appears to move between nodes, and can affect several nodes at once (each node has its own cache).SSOIdP's property array grows by onejwksUrientry per distinct organization for the lifetime of the cache entry.Root cause
For a sub-org logout token the lookup by
idpIssuerNamemisses, soFederatedIdpInitLogoutProcessor.getIdentityProviderfalls back togetIdPByName(<issuer>, tenantDomain), which routes an issuer-shaped name into the sub-org branch:getSSOIDPmutates the object it gets back from the cache:CacheBackedIdPMgtDAO.getIdPByNamereturns the cached instance itself, with no defensive copy (andIdPCacheByNameis builtsetStoreByValue(false)):addJWKSUriPropertyappends without removing an existingjwksUri:and the validation side takes the first match and stops:
All sub-org back-channel logouts are processed under the root (
carbon.super) tenant, so they all resolve the same singleSSOcache entry and therefore interfere with one another.Affected code
org.wso2.carbon.idp.mgt.IdentityProviderManager#getSSOIDP(carbon-identity-framework, idp-mgt) — mutates the cachedIdentityProviderreturned by the DAO.org.wso2.carbon.idp.mgt.IdentityProviderManager#addJWKSUriProperty— appends ajwksUriproperty instead of replacing any existing one.org.wso2.carbon.idp.mgt.dao.CacheBackedIdPMgtDAO#getIdPByName— returns the cachedIdentityProviderby reference with no defensive copy.org.wso2.carbon.identity.oauth2.util.JWTSignatureValidationUtils#getJWKSUri(identity-inbound-auth-oauth) — returns the firstjwksUriproperty and breaks.Stacktrace
Server log for a back-channel logout token issued by organization B, resolved against organization A's JWKS (
DEBUGlines fromorg.wso2.carbon.identity.oauth2included for context):Note the mismatch between the token's issuer (
/o/<orgB-id>/) and thejwks_uriused to validate it (/o/<orgA-id>/).Steps to Reproduce
Reproduced end-to-end on WSO2 Identity Server 7.2.0 update level 35, single node, default H2 pack.
DEBUGfororg.wso2.carbon.identity.oauth2,org.wso2.carbon.identity.application.authenticatorandorg.wso2.carbon.identity.oidcso the resolvedjwks_uriis visible.authorization_code) in the root organization and share it with both sub-organizations.Sharing automatically adds
SSO / OrganizationAuthenticatoras a login option on the root application, and creates a fragment application in each sub-organization whose back-channel logout URL is set tohttps://<host>/identity/oidc/slo— this is what carries the logout token that fails./oidc/logoutand approve the logout consent.Expected behaviour: each sub-organization's back-channel logout token is validated against that organization's own JWKS endpoint (
/o/<org-id>/oauth2/jwks) and the logout completes.Actual behaviour: the first organization to log out claims the cached
SSOIdP entry, and the second organization's logout token is validated against the first organization's JWKS endpoint and fails.Observed — organization B logging out first (succeeds, and claims the entry):
Then organization A logging out ~5 seconds later — its token (
iss= organization A) is validated against organization B's JWKS:Running the two organizations in the opposite order reverses which one fails (organization A first → organization B's logout fails against organization A's JWKS), confirming the behaviour is claim-order dependent and not specific to either organization. Restarting the node, or leaving it idle for more than 15 minutes, clears the pinned entry and the next organization to log out claims it.
Environment note (not part of the defect): the default
wso2carbonTLS certificate shipped with the 7.2.0 pack expires 2026-01-08. After that date IS cannot complete the back-channel logout POST to its own HTTPS endpoint, so nothing reaches/identity/oidc/sloand the failure above cannot be observed at all. Regenerate the certificate and re-import it intoclient-truststore.p12before reproducing.Suggested Fix
getSSOIDPmust not mutate the cached instance. Either operate on a copy of theIdentityProvider, or (cleaner) resolve the JWKS URI for the request and pass it explicitly to signature validation.JWTSignatureValidationUtils.validateUsingJWKSUri(SignedJWT, String jwksUri)is already public and takes the URI directly, so the per-request value need not travel through the IdP's properties at all.addJWKSUriPropertyshould replace any existingjwksUrirather than append. This alone is not sufficient: concurrent back-channel logouts from two organizations would still race on the shared object. It does remove the permanent pinning and the unbounded property growth.CacheBackedIdPMgtDAO#getIdPByNamereturning cachedIdentityProviderinstances by reference is safe in general, given callers are free to mutate them.Please select the area issue is related to
Identity Server Core
Version
IS 7.2.0, IS 7.3.0
Environment Details (with versions)
N/A
Developer Checklist
impact/behavioral-changeadded7.2.0-migration)configadded