Skip to content

Add caching support for IdentityProviderStorageProvider.getForLogin operations#9

Open
akshayutture-augment wants to merge 1 commit into
feature-idp-cache-baselinefrom
feature-idp-cache-implementation
Open

Add caching support for IdentityProviderStorageProvider.getForLogin operations#9
akshayutture-augment wants to merge 1 commit into
feature-idp-cache-baselinefrom
feature-idp-cache-implementation

Conversation

@akshayutture-augment

@akshayutture-augment akshayutture-augment commented Nov 14, 2025

Copy link
Copy Markdown

Test 2

Summary by CodeRabbit

Release Notes

  • New Features

    • Implemented caching for identity provider login queries to enhance performance.
    • Enhanced filtering to display only enabled identity providers in login flows.
    • Improved validation for organization-linked identity provider configuration.
  • Tests

    • Added comprehensive test coverage for identity provider caching behavior.

✏️ Tip: You can customize this high-level summary in your review settings.


Replicated from ai-code-review-evaluation/keycloak-coderabbit#2

Closes #32573

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
@akshayutture-augment

Copy link
Copy Markdown
Author

@coderabbitai review

@akshayutture-augment

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 22, 2025

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Nov 22, 2025

Copy link
Copy Markdown

Walkthrough

This PR introduces login-aware caching for identity providers in the Infinispan storage provider, adding cache key generation and retrieval methods with organization-level broker public flag validation. Enabled provider filtering is added to the login bean, and a new test validates cache behavior across multiple scenarios.

Changes

Cohort / File(s) Summary
Infinispan IDP Storage Caching
model/infinispan/.../InfinispanIdentityProviderStorageProvider.java
Added cacheKeyForLogin() static helper to generate per-realm login cache keys. Introduced getForLogin(mode, organizationId) method to retrieve login-eligible IDPs with cache-backed retrieval and fallback logic. Integrated login cache invalidation on create, update, and remove operations via registerIDPLoginInvalidation() and registerIDPLoginInvalidationOnUpdate() helpers.
IDP Storage Provider Predicate
server-spi/.../IdentityProviderStorageProvider.java
Added organization-level guard to login filtering predicate: organization-linked IDPs now require broker_public flag to be true, while realm-level IDPs remain unrestricted.
Login Bean Filtering
services/.../OrganizationAwareIdentityProviderBean.java
Added idp.isEnabled() checks to filter results in both ORG_ONLY and ALL fetch branches of getForLogin(), ensuring only enabled providers are mapped and returned.
Cache Behavior Test
testsuite/...OrganizationCacheTest.java
Added testCacheIDPForLogin() test method covering cache misses, population via getForLogin() calls, invalidation/rehydration across FetchMode options, and IDP operations (add, remove, update, organization linking).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • InfinispanIdentityProviderStorageProvider.java: Cache invalidation logic across multiple operation paths requires careful verification of edge cases and invalidation timing.
  • IdentityProviderStorageProvider.java: Predicate guard logic is straightforward but impacts filtering semantics for organization-linked IDPs—verify interaction with existing filters.
  • OrganizationAwareIdentityProviderBean.java: Enabled checks are simple additions; verify they align with existing enabled-provider handling patterns.
  • OrganizationCacheTest.java: Comprehensive test with multiple FetchMode and operation scenarios—review assertions for correctness and completeness.

Poem

🐰 A cache for logins, organized and spry,
With broker flags that reach toward the sky,
Enabled IDPs filter the way,
Invalidation keeps stale data at bay,
Login-aware caching—hip, hop, hooray!

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description is minimal ('Test 2') and lacks substantive detail about the changes, objectives, or rationale required by professional standards. Provide a comprehensive description explaining the purpose of the changes, what problems it solves, and how it impacts the codebase.
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding caching support for the getForLogin operations in IdentityProviderStorageProvider.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature-idp-cache-implementation

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5ff9e91 and 3e59772.

📒 Files selected for processing (4)
  • model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java (6 hunks)
  • server-spi/src/main/java/org/keycloak/models/IdentityProviderStorageProvider.java (1 hunks)
  • services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java (1 hunks)
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/cache/OrganizationCacheTest.java (3 hunks)
🔇 Additional comments (4)
services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareIdentityProviderBean.java (1)

64-82: Explicit isEnabled filter after getForLogin is correct with wrapped IDPs

Re-checking idp.isEnabled() on the ORG_ONLY and ALL login streams is appropriate given createOrganizationAwareIdentityProviderModel can dynamically report disabled for org-bound providers; this avoids exposing IDPs that are no longer effectively enabled while still leveraging the shared login cache.

server-spi/src/main/java/org/keycloak/models/IdentityProviderStorageProvider.java (1)

252-255: Org-aware guard in getLoginPredicate correctly restricts login IDPs

The added condition idp.getOrganizationId() == null || BROKER_PUBLIC == true aligns the in-memory login predicate with the search options used in getForLogin, ensuring only public org-linked IDPs are considered login-eligible while preserving realm-level behavior. This also gives the Infinispan layer a precise signal for when login caches must be invalidated.

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/cache/OrganizationCacheTest.java (1)

20-37: New imports correctly tie test to login cache API

Static import of cacheKeyForLogin and the FetchMode import keep the login-cache assertions readable and consistent with existing cache key usage in this test class.

model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java (1)

19-22: Login-aware caching and invalidation logic are consistent with SPI semantics

The new login cache key (IDP_LOGIN_SUFFIX + cacheKeyForLogin), the getForLogin override, and the registerIDPLoginInvalidation* helpers integrate cleanly with existing IdentityProviderListQuery patterns:

  • getForLogin caches internal IDs per (realm, FetchMode, organizationId) and re-hydrates through session.identityProviders().getById, so the organization-aware isEnabled override is honoured at lookup time.
  • registerIDPLoginInvalidation and registerIDPLoginInvalidationOnUpdate both rely on LoginFilter.getLoginPredicate, keeping the definition of “login-eligible” aligned between filtering and invalidation and avoiding unnecessary cache churn when updates don’t affect login availability or org linkage.

Overall, the design matches the behavior asserted in OrganizationCacheTest.testCacheIDPForLogin.

Also applies to: 40-48, 77-80, 213-255, 382-418

Comment on lines 97 to 113
@Override
public boolean remove(String alias) {
String cacheKey = cacheKeyIdpAlias(getRealm(), alias);
IdentityProviderModel storedIdp = idpDelegate.getByAlias(alias);
if (isInvalid(cacheKey)) {
//lookup idp by alias in cache to be able to invalidate its internalId
registerIDPInvalidation(idpDelegate.getByAlias(alias));
registerIDPInvalidation(storedIdp);
} else {
CachedIdentityProvider cached = realmCache.getCache().get(cacheKey, CachedIdentityProvider.class);
if (cached != null) {
registerIDPInvalidation(cached.getIdentityProvider());
}
}
registerCountInvalidation();
registerIDPLoginInvalidation(storedIdp);
return idpDelegate.remove(alias);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard against possible NPE when removing a non-existent IDP with an invalidated alias key

In remove(String alias), if storedIdp = idpDelegate.getByAlias(alias) returns null while isInvalid(cacheKey) is true, registerIDPInvalidation(storedIdp) will dereference idp.getInternalId() and throw an NPE. Although this situation is unlikely, it’s a correctness risk.

Consider guarding the call:

     String cacheKey = cacheKeyIdpAlias(getRealm(), alias);
-    IdentityProviderModel storedIdp = idpDelegate.getByAlias(alias);
+    IdentityProviderModel storedIdp = idpDelegate.getByAlias(alias);
     if (isInvalid(cacheKey)) {
-        //lookup idp by alias in cache to be able to invalidate its internalId
-        registerIDPInvalidation(storedIdp);
+        // lookup idp by alias in cache to be able to invalidate its internalId
+        if (storedIdp != null) {
+            registerIDPInvalidation(storedIdp);
+        }
     } else {
         CachedIdentityProvider cached = realmCache.getCache().get(cacheKey, CachedIdentityProvider.class);
         if (cached != null) {
             registerIDPInvalidation(cached.getIdentityProvider());
         }
     }
     registerCountInvalidation();
-    registerIDPLoginInvalidation(storedIdp);
+    if (storedIdp != null) {
+        registerIDPLoginInvalidation(storedIdp);
+    }
     return idpDelegate.remove(alias);

This keeps behavior unchanged when the IDP exists but avoids an NPE when it doesn’t.

🤖 Prompt for AI Agents
In
model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/idp/InfinispanIdentityProviderStorageProvider.java
around lines 97–113, guard against a possible NPE by checking for null before
calling invalidation helpers: only call registerIDPInvalidation(storedIdp) when
storedIdp != null (i.e., wrap that call in a null-check) and likewise ensure
registerIDPLoginInvalidation(storedIdp) is only invoked if storedIdp is
non-null; leave other logic unchanged.

Comment on lines +369 to +535
@Test
public void testCacheIDPForLogin() {
// create 20 providers, and associate 10 of them with an organization.
for (int i = 0; i < 20; i++) {
IdentityProviderRepresentation idpRep = new IdentityProviderRepresentation();
idpRep.setAlias("idp-alias-" + i);
idpRep.setEnabled((i % 2) == 0); // half of the IDPs will be disabled and won't qualify for login.
idpRep.setDisplayName("Broker " + i);
idpRep.setProviderId("keycloak-oidc");
if (i >= 10)
idpRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString());
testRealm().identityProviders().create(idpRep).close();
getCleanup().addCleanup(testRealm().identityProviders().get("alias")::remove);
}

String orgaId = testRealm().organizations().getAll().get(0).getId();
for (int i = 10; i < 20; i++) {
testRealm().organizations().get(orgaId).identityProviders().addIdentityProvider("idp-alias-" + i);
}

getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
RealmCacheSession realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);

// check all caches for login don't exist yet
for (FetchMode fetchMode : IdentityProviderStorageProvider.FetchMode.values()) {
String cachedKey = cacheKeyForLogin(realm, fetchMode);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cachedKey, IdentityProviderListQuery.class);
assertNull(identityProviderListQuery);
}

// perform some login IDP searches and ensure they are cached.
session.identityProviders().getForLogin(FetchMode.REALM_ONLY, null);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.REALM_ONLY), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(5, identityProviderListQuery.getIDPs("").size());

session.identityProviders().getForLogin(FetchMode.ORG_ONLY, orgaId);
identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.ORG_ONLY), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(5, identityProviderListQuery.getIDPs(orgaId).size());

session.identityProviders().getForLogin(FetchMode.ALL, orgaId);
identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.ALL), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(10, identityProviderListQuery.getIDPs(orgaId).size());
});

// 1- add/remove IDPs that are not available for login - none of these operations should invalidate the login caches.
IdentityProviderRepresentation idpRep = new IdentityProviderRepresentation();
idpRep.setAlias("idp-alias-" + 20);
idpRep.setEnabled(true);
idpRep.setHideOnLogin(true); // this will make the new IDP not available for login.
idpRep.setDisplayName("Broker " + 20);
idpRep.setProviderId("keycloak-oidc");
testRealm().identityProviders().create(idpRep).close();
getCleanup().addCleanup(testRealm().identityProviders().get("alias")::remove);

// remove one IDP that was not available for login.
testRealm().identityProviders().get("idp-alias-1").remove();

getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
RealmCacheSession realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);

// check all caches for login are still there.
for (FetchMode fetchMode : IdentityProviderStorageProvider.FetchMode.values()) {
String cachedKey = cacheKeyForLogin(realm, fetchMode);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cachedKey, IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
}
});

// 2- update a couple of idps (one not available for login, one available), but don't change their login-availability status
// none of these operations should invalidate the login caches.
idpRep = testRealm().identityProviders().get("idp-alias-20").toRepresentation();
idpRep.getConfig().put("somekey", "somevalue");
idpRep.setTrustEmail(true);
testRealm().identityProviders().get("idp-alias-20").update(idpRep); // should still be unavailable for login

idpRep = testRealm().identityProviders().get("idp-alias-0").toRepresentation();
idpRep.getConfig().put("somekey", "somevalue");
idpRep.setTrustEmail(true);
testRealm().identityProviders().get("idp-alias-0").update(idpRep); // should still be available for login

getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
RealmCacheSession realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);

// check all caches for login are still there.
for (FetchMode fetchMode : IdentityProviderStorageProvider.FetchMode.values()) {
String cachedKey = cacheKeyForLogin(realm, fetchMode);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cachedKey, IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
}
});

// 3- update an IDP, changing the availability for login - this should invalidate the caches.
idpRep = testRealm().identityProviders().get("idp-alias-20").toRepresentation();
idpRep.setHideOnLogin(false);
testRealm().identityProviders().get("idp-alias-20").update(idpRep);

getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
RealmCacheSession realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);

// check all caches have been cleared.
for (FetchMode fetchMode : IdentityProviderStorageProvider.FetchMode.values()) {
String cachedKey = cacheKeyForLogin(realm, fetchMode);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cachedKey, IdentityProviderListQuery.class);
assertNull(identityProviderListQuery);
}

// re-do searches to populate the caches again and check the updated results.
session.identityProviders().getForLogin(FetchMode.REALM_ONLY, null);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.REALM_ONLY), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(6, identityProviderListQuery.getIDPs("").size());

session.identityProviders().getForLogin(FetchMode.ORG_ONLY, orgaId);
identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.ORG_ONLY), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(5, identityProviderListQuery.getIDPs(orgaId).size());

session.identityProviders().getForLogin(FetchMode.ALL, orgaId);
identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.ALL), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(11, identityProviderListQuery.getIDPs(orgaId).size());

});

// 4- finally, change one of the realm-level login IDPs, linking it to an org - although it still qualifies for login, it is now
// linked to an org, which should invalidate all login caches.
idpRep = testRealm().identityProviders().get("idp-alias-20").toRepresentation();
idpRep.getConfig().put(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString());
testRealm().identityProviders().get("idp-alias-20").update(idpRep);
testRealm().organizations().get(orgaId).identityProviders().addIdentityProvider("idp-alias-20");

getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
RealmCacheSession realmCache = (RealmCacheSession) session.getProvider(CacheRealmProvider.class);

// check all caches have been cleared.
for (FetchMode fetchMode : IdentityProviderStorageProvider.FetchMode.values()) {
String cachedKey = cacheKeyForLogin(realm, fetchMode);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cachedKey, IdentityProviderListQuery.class);
assertNull(identityProviderListQuery);
}

// re-do searches to populate the caches again and check the updated results.
session.identityProviders().getForLogin(FetchMode.REALM_ONLY, null);
IdentityProviderListQuery identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.REALM_ONLY), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(5, identityProviderListQuery.getIDPs("").size());

session.identityProviders().getForLogin(FetchMode.ORG_ONLY, orgaId);
identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.ORG_ONLY), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(6, identityProviderListQuery.getIDPs(orgaId).size());

session.identityProviders().getForLogin(FetchMode.ALL, orgaId);
identityProviderListQuery = realmCache.getCache().get(cacheKeyForLogin(realm, FetchMode.ALL), IdentityProviderListQuery.class);
assertNotNull(identityProviderListQuery);
assertEquals(11, identityProviderListQuery.getIDPs(orgaId).size());

});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix cleanup alias and consider making login-count assertions less brittle

The test flow and assertions around cache presence and sizes look correct for the modeled scenarios, but there are two minor issues:

  1. Cleanup uses literal "alias"
    getCleanup().addCleanup(testRealm().identityProviders().get("alias")::remove); will not remove the providers created as "idp-alias-" + i. This matches an existing pattern in testCacheIDPByOrg but is almost certainly unintended and leaves extra IDPs behind.

    A minimal fix inside this test would be:

    -            testRealm().identityProviders().create(idpRep).close();
    -            getCleanup().addCleanup(testRealm().identityProviders().get("alias")::remove);
    +            testRealm().identityProviders().create(idpRep).close();
    +            final String alias = idpRep.getAlias();
    +            getCleanup().addCleanup(testRealm().identityProviders().get(alias)::remove);

    (You may want to apply the same pattern to testCacheIDPByOrg for consistency.)

  2. Hard-coded expected counts assume a fixed realm baseline
    The expectations 5/5/10, then 6/5/11, then 5/6/11 are correct given only the IDPs created in this test are login-eligible, but they will fail if the test realm configuration or other tests introduce additional login-capable IDPs. If future changes make the base realm less minimal, consider deriving the expected counts from the created representations (e.g., via local calculations) rather than relying on a fixed global total.

🤖 Prompt for AI Agents
In
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/cache/OrganizationCacheTest.java
around lines 369-535, the cleanup registration incorrectly uses the literal
"alias" and the test uses hard-coded expected IDP counts; fix by registering
cleanup with the actual alias created (e.g., capture the alias string per
iteration and call
getCleanup().addCleanup(testRealm().identityProviders().get(alias)::remove)) so
each created provider is removed, and replace brittle hard-coded assertions with
computed expected counts derived from the representations you created in the
test (count login-eligible providers for realm/organization based on
enabled/hideOnLogin/BROKER_PUBLIC flags) so assertions adapt if the realm
baseline changes; also apply the same cleanup fix to testCacheIDPByOrg for
consistency.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants