Resolve scopes from authenticated client sessions when selecting attributes

Closes #35192

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-11-19 22:05:36 -03:00 committed by Alexander Schwartz
parent e11db03d76
commit 5d6b9c1460
11 changed files with 106 additions and 77 deletions

View File

@ -104,4 +104,8 @@ public interface KeycloakContext {
void setHttpRequest(HttpRequest httpRequest);
void setHttpResponse(HttpResponse httpResponse);
UserSessionModel getUserSession();
void setUserSession(UserSessionModel session);
}

View File

@ -52,7 +52,7 @@ public enum UserProfileContext {
/**
* In this context, a user profile is managed by themselves through the account console.
*/
ACCOUNT(false, false, true),
ACCOUNT(false, true, true),
/**
* In this context, a user profile is managed by themselves when authenticating through a broker.

View File

@ -697,6 +697,10 @@ public class TokenManager {
/** Return client itself + all default client scopes of client + optional client scopes requested by scope parameter **/
public static Stream<ClientScopeModel> getRequestedClientScopes(KeycloakSession session, String scopeParam, ClientModel client, UserModel user) {
if (client == null) {
return Stream.of();
}
// Add all default client scopes automatically and client itself
Stream<ClientScopeModel> clientScopes = Stream.concat(
client.getClientScopes(true).values().stream(),

View File

@ -29,6 +29,7 @@ import org.keycloak.models.KeycloakUriInfo;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.theme.Theme;
import org.keycloak.urls.UrlType;
@ -37,6 +38,7 @@ import java.net.URI;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -54,6 +56,7 @@ public abstract class DefaultKeycloakContext implements KeycloakContext {
private Map<UrlType, KeycloakUriInfo> uriInfo;
private AuthenticationSessionModel authenticationSession;
private UserSessionModel userSession;
private HttpRequest request;
private HttpResponse response;
private ClientConnection clientConnection;
@ -113,6 +116,11 @@ public abstract class DefaultKeycloakContext implements KeycloakContext {
@Override
public ClientModel getClient() {
if (client == null) {
client = Optional.ofNullable(authenticationSession)
.map(AuthenticationSessionModel::getClient)
.orElse(null);
}
return client;
}
@ -205,4 +213,13 @@ public abstract class DefaultKeycloakContext implements KeycloakContext {
this.response = httpResponse;
}
@Override
public UserSessionModel getUserSession() {
return userSession;
}
@Override
public void setUserSession(UserSessionModel userSession) {
this.userSession = userSession;
}
}

View File

@ -1532,6 +1532,8 @@ public class AuthenticationManager {
}
}
KeycloakContext context = session.getContext();
if (token.getSessionState() != null && !isSessionValid(realm, userSession)) {
// Check if accessToken was for the offline session.
if (!isCookie) {
@ -1542,6 +1544,8 @@ public class AuthenticationManager {
if (!isClientValid(offlineUserSession, client, token)) {
return null;
}
context.setUserSession(offlineUserSession);
context.setClient(client);
return new AuthResult(user, offlineUserSession, token, client);
}
}
@ -1549,7 +1553,7 @@ public class AuthenticationManager {
if (userSession != null) {
String userSessionId = userSession.getId();
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session.getContext(), newSession -> {
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), context, newSession -> {
RealmModel realmModel = newSession.realms().getRealm(realm.getId());
UserSessionModel userSessionModel = newSession.sessions().getUserSession(realmModel, userSessionId);
backchannelLogout(newSession, realmModel, userSessionModel, uriInfo, connection, headers, true);
@ -1572,7 +1576,11 @@ public class AuthenticationManager {
if (!isClientValid(userSession, client, token)) {
return null;
}
context.setClient(client);
}
context.setUserSession(userSession);
return new AuthResult(user, userSession, token, client);
} catch (VerificationException e) {
logger.debugf("Failed to verify identity token: %s", e.getMessage());
@ -1678,4 +1686,30 @@ public class AuthenticationManager {
return HashUtils.sha256UrlEncodedHash(input, StandardCharsets.ISO_8859_1);
}
public static String getRequestedScopes(KeycloakSession session) {
return getRequestedScopes(session, session.getContext().getClient());
}
public static String getRequestedScopes(KeycloakSession session, ClientModel client) {
KeycloakContext context = session.getContext();
AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();
if (authenticationSession != null) {
return authenticationSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM);
}
UserSessionModel userSession = context.getUserSession();
if (userSession == null) {
return null;
}
Map<String, AuthenticatedClientSessionModel> clientSessions = userSession.getAuthenticatedClientSessions();
return clientSessions.values().stream().filter(c -> c.getClient().equals(client))
.map((c) -> c.getNotes().get(OIDCLoginProtocol.SCOPE_PARAM))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
}

View File

@ -13,8 +13,6 @@ import org.keycloak.authentication.requiredactions.DeleteAccount;
import org.keycloak.common.Profile;
import org.keycloak.common.Version;
import org.keycloak.common.util.Environment;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.utils.SecureContextResolver;
@ -33,7 +31,6 @@ import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resource.AccountResourceProvider;
import org.keycloak.services.resources.AbstractSecuredLocalService;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.services.util.ViteManifest;
@ -146,7 +143,7 @@ public class AccountConsole implements AccountResourceProvider {
map.put("resourceCommonUrl", Urls.themeRoot(serverBaseUri).getPath() + "/common/keycloak");
map.put("resourceVersion", Version.RESOURCES_VERSION);
var requestedScopes = getRequestedScopes();
var requestedScopes = AuthenticationManager.getRequestedScopes(session, realm.getClientByClientId(Constants.ACCOUNT_CONSOLE_CLIENT_ID));
if (requestedScopes != null) {
map.put(OIDCLoginProtocol.SCOPE_PARAM, requestedScopes);
@ -361,26 +358,4 @@ public class AccountConsole implements AccountResourceProvider {
return new String[]{referrer, referrerName, referrerUri};
}
private String getRequestedScopes() {
if (auth == null) {
return null;
}
UserSessionModel userSession = auth.getSession();
if (userSession == null) {
return null;
}
for (AuthenticatedClientSessionModel c : userSession.getAuthenticatedClientSessions().values()) {
ClientModel client = c.getClient();
if (Constants.ACCOUNT_CONSOLE_CLIENT_ID.equals(client.getClientId())) {
return c.getNote(OIDCLoginProtocol.SCOPE_PARAM);
}
}
return null;
}
}

View File

@ -36,11 +36,11 @@ import java.util.stream.Collectors;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.userprofile.config.DeclarativeUserProfileModel;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
@ -77,17 +77,18 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
* @return
*/
private static boolean requestedScopePredicate(AttributeContext context, Set<String> configuredScopes) {
KeycloakSession session = context.getSession();
AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession();
if (authenticationSession == null) {
return false;
// any attribute is enabled and available when managing through the User Admin API
if (UserProfileContext.USER_API.equals(context.getContext())) {
return true;
}
String requestedScopesString = authenticationSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM);
ClientModel client = authenticationSession.getClient();
KeycloakSession session = context.getSession();
String requestedScopes = AuthenticationManager.getRequestedScopes(session);
ClientModel client = session.getContext().getClient();
return getRequestedClientScopes(session, requestedScopesString, client, context.getUser()).map((csm) -> csm.getName()).anyMatch(configuredScopes::contains);
return getRequestedClientScopes(session, requestedScopes, client, context.getUser())
.map(ClientScopeModel::getName)
.anyMatch(configuredScopes::contains);
}
private final KeycloakSession session;

View File

@ -105,7 +105,7 @@ public class AccountRestServiceWithUserProfileTest extends AbstractRestServiceTe
assertUserProfileAttributeMetadata(user, "username", "${username}", true, false);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
UserProfileAttributeMetadata uam = assertUserProfileAttributeMetadata(user, "firstName", "${profile.firstName}", false, false);
UserProfileAttributeMetadata uam = assertUserProfileAttributeMetadata(user, "firstName", "${profile.firstName}", true, false);
assertNull(uam.getAnnotations());
Map<String, Object> vc = assertValidatorExists(uam, "length");
assertEquals(255, vc.get("max"));
@ -121,7 +121,7 @@ public class AccountRestServiceWithUserProfileTest extends AbstractRestServiceTe
assertUserProfileAttributeMetadata(user, "attr_required", "attr_required", true, false);
assertUserProfileAttributeMetadata(user, "attr_required_by_role", "attr_required_by_role", true, false);
assertUserProfileAttributeMetadata(user, "attr_required_by_scope", "attr_required_by_scope", false, false);
assertUserProfileAttributeMetadata(user, "attr_required_by_scope", "attr_required_by_scope", true, false);
assertUserProfileAttributeMetadata(user, "attr_not_required_due_to_role", "attr_not_required_due_to_role", false, false);
assertUserProfileAttributeMetadata(user, "attr_readonly", "attr_readonly", false, true);
@ -229,7 +229,7 @@ public class AccountRestServiceWithUserProfileTest extends AbstractRestServiceTe
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
UserProfileAttributeMetadata uam = assertUserProfileAttributeMetadata(user, "firstName", "${profile.firstName}", false, false);
UserProfileAttributeMetadata uam = assertUserProfileAttributeMetadata(user, "firstName", "${profile.firstName}", true, false);
assertNull(uam.getAnnotations());
Map<String, Object> vc = assertValidatorExists(uam, "length");
assertEquals(255, vc.get("max"));
@ -245,7 +245,7 @@ public class AccountRestServiceWithUserProfileTest extends AbstractRestServiceTe
assertUserProfileAttributeMetadata(user, "attr_required", "attr_required", true, false);
assertUserProfileAttributeMetadata(user, "attr_required_by_role", "attr_required_by_role", true, false);
assertUserProfileAttributeMetadata(user, "attr_required_by_scope", "attr_required_by_scope", false, false);
assertUserProfileAttributeMetadata(user, "attr_required_by_scope", "attr_required_by_scope", true, false);
assertUserProfileAttributeMetadata(user, "attr_not_required_due_to_role", "attr_not_required_due_to_role", false, false);
assertUserProfileAttributeMetadata(user, "attr_readonly", "attr_readonly", false, true);

View File

@ -58,7 +58,9 @@ public abstract class AbstractUserProfileTest extends AbstractTestRealmKeycloakT
protected static void configureAuthenticationSession(KeycloakSession session, String clientId, Set<String> requestedScopes) {
RealmModel realm = session.getContext().getRealm();
session.getContext().setAuthenticationSession(createAuthenticationSession(realm.getClientByClientId(clientId), requestedScopes));
ClientModel client = realm.getClientByClientId(clientId);
session.getContext().setAuthenticationSession(createAuthenticationSession(client, requestedScopes));
session.getContext().setClient(client);
}
protected static Optional<ComponentModel> setAndGetDefaultConfiguration(KeycloakSession session) {

View File

@ -105,7 +105,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
testRealm.getClientScopes().add(ClientScopeBuilder.create().name("client-a").protocol("openid-connect").build());
testRealm.getClientScopes().add(ClientScopeBuilder.create().name("some-optional-scope").protocol("openid-connect").build());
ClientRepresentation client = KeycloakModelUtils.createClient(testRealm, "client-a");
client.setDefaultClientScopes(Collections.singletonList("customer"));
client.setDefaultClientScopes(List.of("customer"));
client.setOptionalClientScopes(Collections.singletonList("some-optional-scope"));
KeycloakModelUtils.createClient(testRealm, "client-b");
}
@ -1736,11 +1736,10 @@ public class UserProfileTest extends AbstractUserProfileTest {
}
@Test
public void testRequiredByClientScope() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testRequiredByClientScope);
}
private static void testRequiredByClientScope(KeycloakSession session) {
@ModelTest
public void testRequiredByClientScope(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName("test");
session.getContext().setRealm(realm);
UserProfileProvider provider = getUserProfileProvider(session);
UPConfig config = UPConfigUtils.parseSystemDefaultConfig();
config.addOrReplaceAttribute(new UPAttribute(ATT_ADDRESS, new UPAttributePermissions(Set.of(), Set.of(ROLE_USER)), new UPAttributeRequired(Set.of(), Set.of("client-a"))));
@ -1753,33 +1752,22 @@ public class UserProfileTest extends AbstractUserProfileTest {
attributes.put(UserModel.LAST_NAME, "Doe");
attributes.put(UserModel.EMAIL, "user@email.test");
// client with default scopes for which is attribute NOT configured as required
configureAuthenticationSession(session, "client-b", null);
// no fail on User API nor Account console as they do not have scopes
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
profile.validate();
profile = provider.create(UserProfileContext.ACCOUNT, attributes);
profile.validate();
// no fail on auth flow scopes when scope is not required
profile = provider.create(UserProfileContext.REGISTRATION, attributes);
profile.validate();
profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
profile.validate();
profile = provider.create(UserProfileContext.IDP_REVIEW, attributes);
profile.validate();
// client with default scope for which is attribute configured as required
configureAuthenticationSession(session, "client-a", null);
// no fail on User API nor Account console as they do not have scopes
profile = provider.create(UserProfileContext.USER_API, attributes);
profile.validate();
profile = provider.create(UserProfileContext.ACCOUNT, attributes);
// no fail on User API because they don't have access to scopes yet
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
profile.validate();
// fail on auth flow scopes when scope is required
try {
profile = provider.create(UserProfileContext.ACCOUNT, attributes);
profile.validate();
fail("Should fail validation");
} catch (ValidationException ve) {
assertTrue(ve.isAttributeOnError(ATT_ADDRESS));
}
try {
profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
profile.validate();
@ -1801,7 +1789,6 @@ public class UserProfileTest extends AbstractUserProfileTest {
} catch (ValidationException ve) {
assertTrue(ve.isAttributeOnError(ATT_ADDRESS));
}
}
@Test
@ -1825,13 +1812,13 @@ public class UserProfileTest extends AbstractUserProfileTest {
// client with default scopes. No address scope included
configureAuthenticationSession(session, "client-a", null);
// No fail on admin and account console as they do not have scopes
// no fail on User API because they don't have access to scopes yet
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
profile.validate();
profile = provider.create(UserProfileContext.ACCOUNT, attributes);
profile.validate();
// no fail on auth flow scopes when scope is not required
profile = provider.create(UserProfileContext.ACCOUNT, attributes);
profile.validate();
profile = provider.create(UserProfileContext.REGISTRATION, attributes);
profile.validate();
profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
@ -1845,10 +1832,15 @@ public class UserProfileTest extends AbstractUserProfileTest {
// No fail on admin and account console as they do not have scopes
profile = provider.create(UserProfileContext.USER_API, attributes);
profile.validate();
profile = provider.create(UserProfileContext.ACCOUNT, attributes);
profile.validate();
// fail on auth flow scopes when scope is required
try {
profile = provider.create(UserProfileContext.ACCOUNT, attributes);
profile.validate();
fail("Should fail validation");
} catch (ValidationException ve) {
assertTrue(ve.isAttributeOnError(ATT_ADDRESS));
}
try {
profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes);
profile.validate();

View File

@ -37,9 +37,9 @@ public class UPConfigUtilsTest {
@Test
public void canBeAuthFlowContext() {
Assert.assertFalse(UserProfileContext.ACCOUNT.canBeAuthFlowContext());
Assert.assertFalse(UserProfileContext.USER_API.canBeAuthFlowContext());
Assert.assertTrue(UserProfileContext.ACCOUNT.canBeAuthFlowContext());
Assert.assertTrue(UserProfileContext.IDP_REVIEW.canBeAuthFlowContext());
Assert.assertTrue(UserProfileContext.REGISTRATION.canBeAuthFlowContext());
Assert.assertTrue(UserProfileContext.UPDATE_PROFILE.canBeAuthFlowContext());