Invalidate in progress authentication sessions on credential reset - #50702
Invalidate in progress authentication sessions on credential reset#50702gaoyikeshuer wants to merge 1 commit into
Conversation
3c44f93 to
bdb2bdb
Compare
Unreported flaky test detectedIf the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR. org.keycloak.testsuite.cluster.ClientScopeInvalidationClusterTest#crudWithoutFailoverorg.keycloak.testsuite.cluster.ClientScopeInvalidationClusterTest#crudWithFailover |
bdb2bdb to
2cbbd79
Compare
There was a problem hiding this comment.
Pull request overview
Adds invalidation of in-progress authentication sessions when credential reset requests sign-out from other devices.
Changes:
- Adds authenticated-user session lookup to the provider SPI.
- Implements lookup for JPA and Infinispan stores.
- Integrates invalidation and adds model tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
AuthenticationSessionTest.java |
Tests lookup and removal behavior. |
AuthenticatorUtil.java |
Invalidates other authentication sessions. |
AuthenticationSessionProvider.java |
Adds the lookup SPI method. |
JPA RootAuthenticationSessionEntity.java |
Adds the user lookup query. |
JpaAuthenticationSessionProvider.java |
Implements JPA lookup. |
RemoteInfinispanAuthenticationSessionProvider.java |
Implements remote-cache lookup. |
InfinispanAuthenticationSessionProvider.java |
Implements embedded-cache lookup. |
Infinispan RootAuthenticationSessionEntity.java |
Adds user-session matching helper. |
2cbbd79 to
e5039c8
Compare
e5039c8 to
5079e0f
Compare
| default Stream<RootAuthenticationSessionModel> getRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user) { | ||
| return Stream.empty(); | ||
| } |
| public Stream<RootAuthenticationSessionModel> getRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user) { | ||
| return getEntityManager() | ||
| .createNamedQuery("findRootAuthSessionIdsByUser", String.class) | ||
| .setParameter("realmId", realm.getId()) | ||
| .setParameter("userId", user.getId()) | ||
| .getResultList() | ||
| .stream() | ||
| .map(id -> getRootAuthenticationSession(realm, id)) | ||
| .filter(Objects::nonNull); | ||
| } |
| return StreamSupport.stream(sessionTx.getCache().entrySet().stream() | ||
| .filter(SessionWrapperPredicate.create(realm.getId())) | ||
| .spliterator(), false) | ||
| .map(entry -> entry.getValue().getEntity()) | ||
| .filter(entity -> entity.hasAuthenticationSessionForUser(user.getId())) | ||
| .map(entity -> (RootAuthenticationSessionModel) wrap(realm, entity)); |
There was a problem hiding this comment.
@gaoyikeshuer Thanks for the PR! Just put a comment there, and we should wait for someone from the @keycloak/sre team to look at it (@pruivo @ryanemerson).
Moreover, it'd be good to check all the Copilot's suggestions, and would be best to comment on it why it's invalid.
|
|
||
| return QueryHelper.streamAll(query, 100, Function.identity()) | ||
| .filter(entity -> entity.hasAuthenticationSessionForUser(user.getId())) | ||
| .map(RootAuthenticationSessionEntity::getId) |
There was a problem hiding this comment.
So we first fetch the IDs and drop the entities, and then getting it again? It'd be nice to avoid it, but probably @pruivo will navigate you for the optimal solution.
My Claude assistant suggested sth like this:
return QueryHelper.streamAll(query, 100, Function.identity())
.filter(entity -> entity.hasAuthenticationSessionForUser(user.getId()))
.map(entity -> {
var updater = transaction.wrap(entity.getId(), entity, Updater.NO_VERSION);
updater.initialize(session, realm, authSessionsLimit);
return (RootAuthenticationSessionModel) updater;
});
But the transaction wrapping needs to be investigated more.
There was a problem hiding this comment.
Thanks @mabartos, yes you are right it fetches the entities, reduces them to IDs, then refetch each via getRootAuthenticationSession. It's redundant now. according to @pruivo , if we delete the find method and do a direct server side delete, then we don't need this method at all. My only concern is the remote part. it would mean adding an index to the cache object to me, idk how big the risk is on clustered side. would like to wait for the final decision from team before implementing
pedroigor
left a comment
There was a problem hiding this comment.
Thanks, @gaoyikeshuer.
The changes LGTM. We probably want a review from @keycloak/sre mainly because of iterating over all sessions to then filter them out by user id, in-memory. Perhaps there is an alternative we do not know about that is more optimal.
|
I'm sorry for the late reply; I was on PTO. I would like to suggest another option: in the WDYT @mabartos @pedroigor @gaoyikeshuer ? |
@pruivo Yes, IMO, it might work nicely :) Delete all except the current one. @rmartinc We should be fine, right? cc: @gaoyikeshuer |
Hi @pruivo , sorry for the late reply. I was checking if this way is feasible. I assume this way would be similar to the one removeUserSessions(realm, user) which could delete them all in one request. but I check auth session record which doesn't have user id at the top. it only has id, realm, and a map of child sessions. and the user id lives in the child which doesn't have index. I'm thinking if we go this way should we add the index on the root? but this will change the stored data format (the cache object field)? Please correct me if I'm wrong. WDYT @pedroigor @rmartinc @mabartos |
5079e0f to
910124f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/remover/query/AuthenticationSessionQueryConditionalRemover.java:83
- This remote delete predicate references
id, butRootAuthenticationSessionEntity.getId()has no indexing annotation; onlyrealmIdand the newauthenticatedUserIdsfield are indexed. With remote indexed querying, the non-null keep-id path used by credential reset will therefore fail query validation instead of deleting sessions; indexidor use the supported cache-key predicate.
return "(realmId = :%s && authenticatedUserIds = :%s && id != :%s)".formatted(realmParameter, userParameter, keepParameter);
910124f to
8ed29d1
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java:95
- This security operation silently does nothing for any custom
AuthenticationSessionProviderthat inherits the default, leaving those deployments vulnerable while credential reset appears successful. Make the operation mandatory or otherwise fail closed rather than providing a no-op default.
default void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) {
}
services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java:220
- This is a snapshot deletion with no synchronization against concurrent authentication. Because
UpdatePassword.java:143-148invokes logout before changing the credential, a request that validates the old password concurrently can persist its authenticated session after this removal and still complete; use a credential/invalidation generation checked at flow completion or serialize these operations.
session.authenticationSessions().removeRootAuthenticationSessionsByAuthenticatedUser(realm, user,
authSession.getParentSession().getId());
model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java:107
- Every user-triggered “sign out other devices” operation now traverses and deserializes the entire distributed authentication-session cache. This is O(all active authentication sessions) and can become a cluster-wide bottleneck at scale; use a user-to-root lookup/index or another targeted removal mechanism.
sessionTx.getCache().entrySet().stream()
.filter(SessionWrapperPredicate.create(realm.getId()))
.filter(entry -> entry.getValue().getEntity().hasAuthenticationSessionForUser(user.getId()))
.map(entry -> entry.getKey())
.filter(rootSessionId -> !Objects.equals(rootSessionId, rootAuthenticationSessionIdToKeep))
.toList()
.forEach(rootSessionId -> sessionTx.addTask(rootSessionId, Tasks.removeSync()));
model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/RootAuthenticationSessionEntity.java:87
- Remote entries created before upgrade, or by old nodes during a rolling upgrade, do not contain this new protobuf field. External authentication-session caches use
indexing.startupMode=NONE, so the indexedremoveByUserquery will miss those existing sessions; add an upgrade-safe fallback or reindex/migration strategy.
@ProtoField(value = 5, collectionImplementation = HashSet.class)
@Basic
public Set<String> getAuthenticatedUserIds() {
| * Removes all root authentication sessions of the given realm that hold an in-progress authentication session | ||
| * for the given authenticated user, except for the provided root authentication session id. |
8ed29d1 to
12cfc09
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/remover/query/AuthenticationSessionQueryConditionalRemover.java:83
- The production caller always supplies a keep ID, but
RootAuthenticationSessionEntity.idis only a ProtoStream field and is not marked@Basic, unlike the other fields used by these indexed Hot Rod queries. This predicate therefore cannot be executed against the remote-cache index; index the ID field or exclude the cache key using a supported key predicate.
return "(realmId = :%s && authenticatedUserIds = :%s && id != :%s)".formatted(realmParameter, userParameter, keepParameter);
| default void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) { | ||
| } |
Closes keycloak#50621 Signed-off-by: Yike Gao <yikegao8@gmail.com>
12cfc09 to
7392225
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (2)
server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java:95
- This default no-op silently leaves the vulnerability open for any third-party
AuthenticationSessionProviderthat has not implemented the new method, while the credential reset still succeeds. The security-sensitive operation should fail closed or require an explicit provider capability/implementation rather than silently skipping invalidation.
default void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) {
}
services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java:219
- Keeping the parent root preserves every sibling tab in that browser, since
RootAuthenticationSessionModelis a multi-tab container (server-spi/src/main/java/org/keycloak/sessions/RootAuthenticationSessionModel.java:26-27). A parked sibling flow for this user can therefore still submit after the reset; preserve onlyauthSession.getTabId()and invalidate the other matching child sessions, removing roots only when empty.
session.authenticationSessions().removeRootAuthenticationSessionsByAuthenticatedUser(realm, user,
authSession.getParentSession().getId());
I missed your comment. If you are talking about About |
|
@pedroigor @rmartinc @mabartos, just for my curiosity, why is the user ID only present in the child session? How is it possible |
AFAIUI the root authentication session is linked to the auth session id cookie, so all the tabs used for login in the same browser share the same root auth session. In each tab the final user can theoretically start the login with a different user. For example in tab1 I can start the login with |
The problem right now is when user reset their credentials and picks "Sign out of other devices", keycloak logged out their finished sessions but not their half-finished ones. An attacker who authenticated with the old password on such stop could complete it after the reset and gain the access.
This PR's idea is to find a user's in-progress authentication sessions, and on the 'reset with sign out others' step, delete all of them except the one is doing the reset.
Closes #50621