Skip to content

OIDC back-channel logout (SLO) for sub-organizations is validated against the wrong organization's JWKS (getSSOIDP mutates the cached SSO IdP instance) #28210

Description

@vfraga

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.

  1. 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.
  2. Create two sub-organizations, A and B, under the root organization.
  3. 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.
  4. Create a user in each sub-organization.
  5. 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.
  6. 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

  1. PrimarygetSSOIDP 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.
  2. Defence in depthaddJWKSUriProperty 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.
  3. 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

  • [Behavioural Change] Does this change introduce a behavioral change to the product?
  •  ↳ Approved by team lead
  •  ↳ Label impact/behavioral-change added
  • [Migration Impact] Does this change have a migration impact?
  •  ↳ Migration label added (e.g., 7.2.0-migration)
  •  ↳ Migration issues created and linked
  • [New Configuration] Does this change introduce a new configuration?
  •  ↳ Label config added
  •  ↳ Configuration is properly documented

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions