Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
import org.keycloak.authorization.store.ResourceStore;
import org.keycloak.authorization.store.ScopeStore;
import org.keycloak.authorization.store.StoreFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.authorization.AuthorizationRequest;
import org.keycloak.representations.idm.authorization.Permission;
import org.keycloak.representations.idm.authorization.PermissionTicketToken;
Expand Down Expand Up @@ -87,65 +89,67 @@ public void onComplete() {
super.onComplete();

if (request.isSubmitRequest()) {
StoreFactory storeFactory = authorization.getStoreFactory();
ResourceStore resourceStore = storeFactory.getResourceStore();
// At this point, onGrant() has already removed granted permissions from the ticket,
// so ticket.getPermissions() contains only the denied ones that need permission tickets.
List<Permission> permissions = ticket.getPermissions();
if (permissions == null || permissions.isEmpty()) return;

if (permissions != null) {
for (Permission permission : permissions) {
Resource resource = resourceStore.findById(resourceServer, permission.getResourceId());
String resourceServerId = resourceServer.getId();
String identityId = identity.getId();
KeycloakSession session = authorization.getKeycloakSession();

// Create tickets in a separate transaction so they survive the rollback
KeycloakModelUtils.enlistAfterCompletion(session, ctx -> {
AuthorizationProvider authz = ctx.session().getProvider(AuthorizationProvider.class);
StoreFactory sf = authz.getStoreFactory();
ResourceServer rs = sf.getResourceServerStore().findById(resourceServerId);
if (rs == null) return;

ResourceStore resourceStore = sf.getResourceStore();
ScopeStore scopeStore = sf.getScopeStore();

for (Permission permission : permissions) {
Resource resource = resourceStore.findById(rs, permission.getResourceId());
if (resource == null) {
resource = resourceStore.findByName(resourceServer, permission.getResourceId(), identity.getId());
resource = resourceStore.findByName(rs, permission.getResourceId(), identityId);
}

if (resource == null || !resource.isOwnerManagedAccess() || resource.getOwner().equals(identity.getId()) || resource.getOwner().equals(resourceServer.getClientId())) {
if (resource == null || !resource.isOwnerManagedAccess()
|| resource.getOwner().equals(identityId)
|| resource.getOwner().equals(rs.getClientId())) {
continue;
}

Set<String> scopes = permission.getScopes();

if (scopes.isEmpty()) {
scopes = resource.getScopes().stream().map(Scope::getName).collect(Collectors.toSet());
}

if (scopes.isEmpty()) {
Map<PermissionTicket.FilterOption, String> filters = new EnumMap<>(PermissionTicket.FilterOption.class);

filters.put(PermissionTicket.FilterOption.RESOURCE_ID, resource.getId());
filters.put(PermissionTicket.FilterOption.REQUESTER, identity.getId());
filters.put(PermissionTicket.FilterOption.SCOPE_IS_NULL, Boolean.TRUE.toString());

List<PermissionTicket> tickets = authorization.getStoreFactory().getPermissionTicketStore().find(resourceServer, filters, null, null);

if (tickets.isEmpty()) {
authorization.getStoreFactory().getPermissionTicketStore().create(resourceServer, resource, null, identity.getId());
}
createTicketIfNotExists(sf, rs, resource, null, identityId);
} else {
ScopeStore scopeStore = authorization.getStoreFactory().getScopeStore();

for (String scopeId : scopes) {
Scope scope = scopeStore.findByName(resourceServer, scopeId);

if (scope == null) {
scope = scopeStore.findById(resourceServer, scopeId);
}

Map<PermissionTicket.FilterOption, String> filters = new EnumMap<>(PermissionTicket.FilterOption.class);

filters.put(PermissionTicket.FilterOption.RESOURCE_ID, resource.getId());
filters.put(PermissionTicket.FilterOption.REQUESTER, identity.getId());
filters.put(PermissionTicket.FilterOption.SCOPE_ID, scope.getId());

List<PermissionTicket> tickets = authorization.getStoreFactory().getPermissionTicketStore().find(resourceServer, filters, null, null);

if (tickets.isEmpty()) {
authorization.getStoreFactory().getPermissionTicketStore().create(resourceServer, resource, scope, identity.getId());
}
for (String scopeName : scopes) {
Scope scope = scopeStore.findByName(rs, scopeName);
if (scope == null) scope = scopeStore.findById(rs, scopeName);
if (scope != null) createTicketIfNotExists(sf, rs, resource, scope, identityId);
}
}
}
}
});
}
}

private static void createTicketIfNotExists(StoreFactory sf, ResourceServer rs, Resource resource, Scope scope, String requester) {
Map<PermissionTicket.FilterOption, String> filters = new EnumMap<>(PermissionTicket.FilterOption.class);
filters.put(PermissionTicket.FilterOption.RESOURCE_ID, resource.getId());
filters.put(PermissionTicket.FilterOption.REQUESTER, requester);
if (scope != null) {
filters.put(PermissionTicket.FilterOption.SCOPE_ID, scope.getId());
} else {
filters.put(PermissionTicket.FilterOption.SCOPE_IS_NULL, Boolean.TRUE.toString());
}

if (sf.getPermissionTicketStore().find(rs, filters, null, null).isEmpty()) {
sf.getPermissionTicketStore().create(rs, resource, scope, requester);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
Expand All @@ -61,6 +62,7 @@
import org.keycloak.component.ComponentModel;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.deployment.DeployedConfigurationsManager;
import org.keycloak.models.AbstractKeycloakTransaction;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.AuthenticationExecutionModel;
Expand Down Expand Up @@ -346,6 +348,57 @@ public static UserModel findUserByNameOrEmail(KeycloakSession session, RealmMode
return session.users().getUserByUsername(realm, username);
}

/**
* Enlists a task that will run in a new, independent transaction when the current
* transaction rolls back. The task is a no-op if the current transaction commits normally.
*
* <p>Use this for cleanup operations that must persist even when an error response
* causes the main transaction to roll back (e.g., session invalidation on token reuse).
* The task receives a {@link SessionLookup} backed by a fresh session
* with realm/client context cloned from the current one.</p>
*
* @see #enlistAfterCompletion(KeycloakSession, Consumer)
*/
public static void enlistAfterRollback(KeycloakSession currentSession, Consumer<SessionLookup> task) {
enlistAfterCompletion(currentSession, task, false);
}

/**
* Enlists a task that will run in a new, independent transaction after the current
* transaction completes, regardless of whether it commits or rolls back.
*
* <p>Use this for operations that must persist in both success and error paths
* (e.g., creating UMA permission tickets).</p>
*
* @see #enlistAfterRollback(KeycloakSession, Consumer)
*/
public static void enlistAfterCompletion(KeycloakSession currentSession, Consumer<SessionLookup> task) {
enlistAfterCompletion(currentSession, task, true);
}

private static void enlistAfterCompletion(KeycloakSession currentSession, Consumer<SessionLookup> task, boolean runOnCommit) {
KeycloakSessionFactory factory = currentSession.getKeycloakSessionFactory();
KeycloakContext context = currentSession.getContext();

currentSession.getTransactionManager().enlistAfterCompletion(new AbstractKeycloakTransaction() {
@Override
protected void commitImpl() {
if (runOnCommit) {
execute();
}
}

@Override
protected void rollbackImpl() {
execute();
}

private void execute() {
runJobInTransaction(factory, context, sub -> task.accept(new SessionLookup(sub)));
}
});
}

/**
* Wrap given runnable job into KeycloakTransaction.
* @param factory The session factory to use
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.keycloak.models.utils;

import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;

/**
* Provides convenient entity lookups within a {@link KeycloakSession},
* resolving entities by their IDs against the session's realm context.
*
* <p>Primarily used inside {@link KeycloakModelUtils#enlistAfterRollback}
* to re-lookup entities from the outer session in a new independent transaction.</p>
*/
public class SessionLookup {

private final KeycloakSession session;
private final RealmModel realm;

public SessionLookup(KeycloakSession session) {
this.session = session;
this.realm = session.getContext().getRealm();
}

public KeycloakSession session() {
return session;
}

public RealmModel realm() {
return realm;
}

public UserSessionModel findUserSession(UserSessionModel outerSession) {
if (outerSession == null) return null;
if (outerSession.isOffline()) {
return session.sessions().getOfflineUserSession(realm, outerSession.getId());
}
return session.sessions().getUserSession(realm, outerSession.getId());
}

public AuthenticatedClientSessionModel findClientSession(AuthenticatedClientSessionModel outerSession) {
if (outerSession == null) return null;
UserSessionModel us = findUserSession(outerSession.getUserSession());
return us != null ? us.getAuthenticatedClientSessionByClient(outerSession.getClient().getId()) : null;
}

public RootAuthenticationSessionModel findRootAuthSession(AuthenticationSessionModel outerSession) {
if (outerSession == null) return null;
return session.authenticationSessions().getRootAuthenticationSession(realm, outerSession.getParentSession().getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,12 @@ public TokenValidation validateToken(KeycloakSession session, UriInfo uriInfo, C

// Revoke timed out offline userSession
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
sessionManager.revokeOfflineUserSession(userSession);
UserSessionModel offlineSession = userSession;
// Revocation must persist even when the error response rolls back the main tx.
KeycloakModelUtils.enlistAfterRollback(session, ctx -> {
UserSessionModel us = ctx.findUserSession(offlineSession);
if (us != null) new UserSessionManager(ctx.session()).revokeOfflineUserSession(us);
});
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Offline session not active", "Offline session not active");
}

Expand All @@ -194,7 +199,16 @@ public TokenValidation validateToken(KeycloakSession session, UriInfo uriInfo, C
// Find userSession regularly for online tokens
userSession = session.sessions().getUserSession(realm, oldToken.getSessionState());
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
AuthenticationManager.backchannelLogout(session, realm, userSession, uriInfo, connection, headers, true);
if (userSession != null) {
UserSessionModel onlineSession = userSession;
// Logout must persist even when the error response rolls back the main tx.
KeycloakModelUtils.enlistAfterRollback(session, ctx -> {
UserSessionModel us = ctx.findUserSession(onlineSession);
if (us != null) {
AuthenticationManager.backchannelLogout(ctx.session(), ctx.realm(), us, uriInfo, connection, headers, true);
}
});
}
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Session not active", "Session not active");
}
}
Expand Down Expand Up @@ -223,7 +237,12 @@ public TokenValidation validateToken(KeycloakSession session, UriInfo uriInfo, C

if (!AuthenticationManager.isClientSessionValid(realm, client, userSession, clientSession)) {
logger.debug("Client session not active");
userSession.removeAuthenticatedClientSessions(Collections.singletonList(client.getId()));
UserSessionModel currentSession = userSession;
// Removal must persist even when the error response rolls back the main tx.
KeycloakModelUtils.enlistAfterRollback(session, ctx -> {
UserSessionModel us = ctx.findUserSession(currentSession);
if (us != null) us.removeAuthenticatedClientSessions(Collections.singletonList(client.getId()));
});
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Client session not active");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
Expand Down Expand Up @@ -86,12 +87,12 @@ public Response process(Context context) {

OAuth2CodeParser.ParseResult parseResult = OAuth2CodeParser.parseCode(session, code, realm, event);
if (parseResult.isIllegalCode()) {
AuthenticatedClientSessionModel clientSession = parseResult.getClientSession();

// Attempt to use same code twice should invalidate existing clientSession
if (clientSession != null) {
clientSession.detachFromUserSession();
}
// Attempt to use same code twice should invalidate existing clientSession.
// Detach must persist even when the error response rolls back the main tx.
KeycloakModelUtils.enlistAfterRollback(session, ctx -> {
AuthenticatedClientSessionModel cs = ctx.findClientSession(parseResult.getClientSession());
if (cs != null) cs.detachFromUserSession();
});

event.error(Errors.INVALID_CODE);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessTokenResponse;
Expand Down Expand Up @@ -124,8 +125,13 @@ public Response process(Context context) {
processor.evaluateRequiredActionTriggers();
UserModel user = authSession.getAuthenticatedUser();
if (user.getRequiredActionsStream().count() > 0 || authSession.getRequiredActions().size() > 0) {
// Remove authentication session as "Resource Owner Password Credentials Grant" is single-request scoped authentication
new AuthenticationSessionManager(session).removeAuthenticationSession(realm, authSession, false);
// Auth session removal must persist even when the error response rolls back the main tx.
KeycloakModelUtils.enlistAfterRollback(session, ctx -> {
RootAuthenticationSessionModel root = ctx.findRootAuthSession(authSession);
if (root != null) {
ctx.session().authenticationSessions().removeRootAuthenticationSession(ctx.realm(), root);
}
});
String errorMessage = "Account is not fully set up";
event.detail(Details.REASON, errorMessage);
event.error(Errors.RESOLVE_REQUIRED_ACTIONS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,11 @@ private void validateTokenReuseForRefresh(KeycloakSession session, RealmModel re
logger.debugf("Failed validation of refresh token %s due it was used before. Realm: %s, client: %s, user: %s, user session: %s. Will detach client session from user session",
refreshToken.getId(), realm.getName(), clientSession.getClient().getClientId(), clientSession.getUserSession().getUser().getUsername(), clientSession.getUserSession().getId());
}
clientSession.detachFromUserSession();
// Detach must persist even when the error response rolls back the main tx.
KeycloakModelUtils.enlistAfterRollback(session, ctx -> {
AuthenticatedClientSessionModel cs = ctx.findClientSession(clientSession);
if (cs != null) cs.detachFromUserSession();
});
throw oee;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

package org.keycloak.services;

import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

Expand All @@ -27,7 +26,7 @@
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class CorsErrorResponseException extends WebApplicationException {
public class CorsErrorResponseException extends RollbackWebApplicationException {

private final Cors cors;
private final String error;
Expand All @@ -47,7 +46,7 @@ public String getErrorDescription() {
}

@Override
public Response getResponse() {
public Response createErrorResponse() {
OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription);
Response.ResponseBuilder builder = Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE);
return cors.add(builder);
Expand Down
Loading
Loading