Filter out org brokers from the account console

- org-linked brokers should not be available for login
- prepare the endpoint for search/pagination

Closes #31944

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2024-09-02 10:13:27 -03:00 committed by Pedro Igor
parent 4d1e1e0bcb
commit e7a4635620
5 changed files with 239 additions and 83 deletions

View file

@ -91,10 +91,10 @@ public class LinkedAccountRepresentation implements Comparable<LinkedAccountRepr
@Override @Override
public int compareTo(LinkedAccountRepresentation rep) { public int compareTo(LinkedAccountRepresentation rep) {
if (this.getGuiOrder() == null) return -1; if (this.getGuiOrder() == null) return 1;
if (rep.getGuiOrder() == null) return 1; if (rep.getGuiOrder() == null) return -1;
return rep.getGuiOrder().compareTo(this.getGuiOrder()); return Integer.valueOf(this.getGuiOrder()).compareTo(Integer.valueOf(rep.getGuiOrder()));
} }
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.models.jpa; package org.keycloak.models.jpa;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -49,6 +50,7 @@ import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
import static org.keycloak.models.IdentityProviderModel.ALIAS; import static org.keycloak.models.IdentityProviderModel.ALIAS;
import static org.keycloak.models.IdentityProviderModel.ALIAS_NOT_IN;
import static org.keycloak.models.IdentityProviderModel.AUTHENTICATE_BY_DEFAULT; import static org.keycloak.models.IdentityProviderModel.AUTHENTICATE_BY_DEFAULT;
import static org.keycloak.models.IdentityProviderModel.ENABLED; import static org.keycloak.models.IdentityProviderModel.ENABLED;
import static org.keycloak.models.IdentityProviderModel.FIRST_BROKER_LOGIN_FLOW_ID; import static org.keycloak.models.IdentityProviderModel.FIRST_BROKER_LOGIN_FLOW_ID;
@ -254,7 +256,15 @@ public class JpaIdentityProviderStorageProvider implements IdentityProviderStora
predicates.add(this.getAliasSearchPredicate(value, builder, idp)); predicates.add(this.getAliasSearchPredicate(value, builder, idp));
} }
break; break;
} default: { }
case ALIAS_NOT_IN: {
if (StringUtil.isNotBlank(value)) {
List<String> aliases = Arrays.asList(value.split(","));
predicates.add(builder.not(idp.get(ALIAS).in(aliases)));
}
break;
}
default: {
String dbProductName = em.unwrap(Session.class).doReturningWork(connection -> connection.getMetaData().getDatabaseProductName()); String dbProductName = em.unwrap(Session.class).doReturningWork(connection -> connection.getMetaData().getDatabaseProductName());
MapJoin<IdentityProviderEntity, String, String> configJoin = idp.joinMap("config"); MapJoin<IdentityProviderEntity, String, String> configJoin = idp.joinMap("config");
Predicate configNamePredicate = builder.equal(configJoin.key(), key); Predicate configNamePredicate = builder.equal(configJoin.key(), key);

View file

@ -32,6 +32,7 @@ import java.util.Objects;
public class IdentityProviderModel implements Serializable { public class IdentityProviderModel implements Serializable {
public static final String ALIAS = "alias"; public static final String ALIAS = "alias";
public static final String ALIAS_NOT_IN = "aliasNotIn";
public static final String ALLOWED_CLOCK_SKEW = "allowedClockSkew"; public static final String ALLOWED_CLOCK_SKEW = "allowedClockSkew";
public static final String AUTHENTICATE_BY_DEFAULT = "authenticateByDefault"; public static final String AUTHENTICATE_BY_DEFAULT = "authenticateByDefault";
public static final String CASE_SENSITIVE_ORIGINAL_USERNAME = "caseSensitiveOriginalUsername"; public static final String CASE_SENSITIVE_ORIGINAL_USERNAME = "caseSensitiveOriginalUsername";

View file

@ -20,11 +20,10 @@ import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -66,6 +65,7 @@ import org.keycloak.services.managers.Auth;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.theme.Theme; import org.keycloak.theme.Theme;
import org.keycloak.utils.StreamsUtil;
import static org.keycloak.models.Constants.ACCOUNT_CONSOLE_CLIENT_ID; import static org.keycloak.models.Constants.ACCOUNT_CONSOLE_CLIENT_ID;
@ -83,6 +83,7 @@ public class LinkedAccountsResource {
private final UserModel user; private final UserModel user;
private final RealmModel realm; private final RealmModel realm;
private final Auth auth; private final Auth auth;
private final Set<String> socialIds;
public LinkedAccountsResource(KeycloakSession session, public LinkedAccountsResource(KeycloakSession session,
HttpRequest request, HttpRequest request,
@ -94,36 +95,117 @@ public class LinkedAccountsResource {
this.auth = auth; this.auth = auth;
this.event = event; this.event = event;
this.user = user; this.user = user;
realm = session.getContext().getRealm(); this.realm = session.getContext().getRealm();
} this.socialIds = session.getKeycloakSessionFactory().getProviderFactoriesStream(SocialIdentityProvider.class)
@GET
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
public Response linkedAccounts() {
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
SortedSet<LinkedAccountRepresentation> linkedAccounts = getLinkedAccounts(this.session, this.realm, this.user);
return Cors.builder().auth().allowedOrigins(auth.getToken()).add(Response.ok(linkedAccounts));
}
private Set<String> findSocialIds() {
return session.getKeycloakSessionFactory().getProviderFactoriesStream(SocialIdentityProvider.class)
.map(ProviderFactory::getId) .map(ProviderFactory::getId)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
public SortedSet<LinkedAccountRepresentation> getLinkedAccounts(KeycloakSession session, RealmModel realm, UserModel user) { /**
Set<String> socialIds = findSocialIds(); * Returns the enabled identity providers the user is currently linked to, or those available for the user to link their
return session.identityProviders().getAllStream(Map.of(IdentityProviderModel.ENABLED, "true"), null, null) * account to.When the {@code linked} param is {@code true}, all providers currently linked to the user are returned in
.map(provider -> toLinkedAccountRepresentation(provider, socialIds, session.users().getFederatedIdentitiesStream(realm, user))) * the form of {@link LinkedAccountRepresentation} objects, including those associated with organizations.
.collect(Collectors.toCollection(TreeSet::new)); * </p>
* When the {@code linked} param is {@code false}, only the identity providers not linked to organizations (i.e. realm
* level providers) will be returned and be made available for linking.
*
* @param linked a {@link Boolean} indicating whether to return only the linked providers ({@code true}) or only the
* providers available for linking ({@code false}).
* @param search Filter to search specific providers by name. Search can be prefixed (name*), contains (*name*) or exact (\"name\"). Default prefixed.
* @param firstResult Pagination offset.
* @param maxResults Maximum results size.
* @return a set of {@link LinkedAccountRepresentation} sorted by the {code guiOrder}.
*/
@GET
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
public Response linkedAccounts(
@QueryParam("linked") Boolean linked,
@QueryParam("search") String search,
@QueryParam("first") Integer firstResult,
@QueryParam("max") Integer maxResults
) {
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
// TODO: remove this statement once the console and the LinkedAccountsRestServiceTest are updated - this is only here for backwards compatibility
if (linked == null) {
List<LinkedAccountRepresentation> linkedAccounts = getLinkedAccounts(this.session, this.realm, this.user);
return Cors.builder().auth().allowedOrigins(auth.getToken()).add(Response.ok(linkedAccounts));
} }
private LinkedAccountRepresentation toLinkedAccountRepresentation(IdentityProviderModel provider, Set<String> socialIds, List<LinkedAccountRepresentation> linkedAccounts;
Stream<FederatedIdentityModel> identities) { if (linked) {
// we want only linked accounts, fetch those from the federated identities.
linkedAccounts = StreamsUtil.paginatedStream(session.users().getFederatedIdentitiesStream(realm, user)
.map(fedIdentity -> this.toLinkedAccount(session.identityProviders().getByAlias(fedIdentity.getIdentityProvider()), fedIdentity.getUserName()))
.filter(account -> account != null && this.matchesLinkedProvider(account, search))
.sorted(), firstResult, maxResults)
.toList();
} else {
// we want all enabled, realm-level identity providers available (i.e. not already linked) for the user to link their accounts to.
String fedAliasesToExclude = session.users().getFederatedIdentitiesStream(realm, user).map(FederatedIdentityModel::getIdentityProvider)
.collect(Collectors.joining(","));
Map<String, String> searchOptions = Map.of(
IdentityProviderModel.ENABLED, "true",
IdentityProviderModel.ORGANIZATION_ID, "",
IdentityProviderModel.SEARCH, search == null ? "" : search,
IdentityProviderModel.ALIAS_NOT_IN, fedAliasesToExclude);
linkedAccounts = session.identityProviders().getAllStream(searchOptions, firstResult, maxResults)
.map(idp -> this.toLinkedAccount(idp, null))
.toList();
}
return Cors.builder().auth().allowedOrigins(auth.getToken()).add(Response.ok(linkedAccounts));
}
private LinkedAccountRepresentation toLinkedAccount(IdentityProviderModel provider, String fedIdentity) {
if (provider == null || !provider.isEnabled()) {
return null;
}
LinkedAccountRepresentation rep = new LinkedAccountRepresentation();
rep.setConnected(fedIdentity != null);
rep.setSocial(socialIds.contains(provider.getProviderId()));
rep.setProviderAlias(provider.getAlias());
rep.setDisplayName(KeycloakModelUtils.getIdentityProviderDisplayName(session, provider));
rep.setGuiOrder(provider.getConfig() != null ? provider.getConfig().get("guiOrder") : null);
rep.setProviderName(provider.getAlias());
rep.setLinkedUsername(fedIdentity);
return rep;
}
private boolean matchesLinkedProvider(final LinkedAccountRepresentation linkedAccount, final String search) {
if (search == null) {
return true;
}else if (search.startsWith("\"") && search.endsWith("\"")) {
final String name = search.substring(1, search.length() - 1);
return linkedAccount.getProviderAlias().equals(name);
} else if (search.startsWith("*") && search.endsWith("*")) {
final String name = search.substring(1, search.length() - 1);
return linkedAccount.getProviderAlias().contains(name);
} else if (search.endsWith("*")) {
final String name = search.substring(0, search.length() - 1);
return linkedAccount.getProviderAlias().startsWith(name);
} else {
return linkedAccount.getProviderAlias().startsWith(search);
}
}
@Deprecated
public List<LinkedAccountRepresentation> getLinkedAccounts(KeycloakSession session, RealmModel realm, UserModel user) {
return session.identityProviders().getAllStream(Map.of(IdentityProviderModel.ENABLED, "true"), null, null)
.map(provider -> toLinkedAccountRepresentation(provider, session.users().getFederatedIdentitiesStream(realm, user)))
.filter(Objects::nonNull)
.sorted().toList();
}
@Deprecated
private LinkedAccountRepresentation toLinkedAccountRepresentation(IdentityProviderModel provider, Stream<FederatedIdentityModel> identities) {
String providerAlias = provider.getAlias(); String providerAlias = provider.getAlias();
FederatedIdentityModel identity = getIdentity(identities, providerAlias); FederatedIdentityModel identity = getIdentity(identities, providerAlias);
// if idp is not yet linked and is currently bound to an organization, it should not be available for linking.
if (identity == null && provider.getOrganizationId() != null) return null;
String displayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, provider); String displayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, provider);
String guiOrder = provider.getConfig() != null ? provider.getConfig().get("guiOrder") : null; String guiOrder = provider.getConfig() != null ? provider.getConfig().get("guiOrder") : null;
@ -141,6 +223,7 @@ public class LinkedAccountsResource {
return rep; return rep;
} }
@Deprecated
private FederatedIdentityModel getIdentity(Stream<FederatedIdentityModel> identities, String providerAlias) { private FederatedIdentityModel getIdentity(Stream<FederatedIdentityModel> identities, String providerAlias) {
return identities.filter(model -> Objects.equals(model.getIdentityProvider(), providerAlias)) return identities.filter(model -> Objects.equals(model.getIdentityProvider(), providerAlias))
.findFirst().orElse(null); .findFirst().orElse(null);

View file

@ -23,7 +23,6 @@ import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
@ -37,12 +36,15 @@ import java.nio.charset.Charset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.SortedSet; import java.util.SortedSet;
import org.apache.http.NameValuePair; import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils; import org.apache.http.client.utils.URLEncodedUtils;
import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.util.IdentityProviderBuilder; import org.keycloak.testsuite.util.IdentityProviderBuilder;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
@ -87,45 +89,38 @@ public class LinkedAccountsRestServiceTest extends AbstractTestRealmKeycloakTest
testRealm.getUsers().add(UserBuilder.create().username("no-account-access").password("password").build()); testRealm.getUsers().add(UserBuilder.create().username("no-account-access").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("view-account-access").role("account", "view-profile").password("password").build()); testRealm.getUsers().add(UserBuilder.create().username("view-account-access").role("account", "view-profile").password("password").build());
String[] providers = new String[]{"saml:mysaml", "oidc:myoidc", "github", "gitlab", "twitter", "facebook", "bitbucket", "microsoft"};
for (int i = 0; i < providers.length; i++) {
String[] idpInfo = providers[i].split(":");
testRealm.addIdentityProvider(IdentityProviderBuilder.create() testRealm.addIdentityProvider(IdentityProviderBuilder.create()
.providerId("github") .providerId(idpInfo[0])
.alias("github") .alias(idpInfo.length == 1 ? idpInfo[0] : idpInfo[1])
.setAttribute("guiOrder", "2") .setAttribute("guiOrder", String.valueOf(i))
.build()); .build());
testRealm.addIdentityProvider(IdentityProviderBuilder.create()
.providerId("saml")
.alias("mysaml")
.setAttribute("guiOrder", "0")
.build());
testRealm.addIdentityProvider(IdentityProviderBuilder.create()
.providerId("oidc")
.alias("myoidc")
.displayName("MyOIDC")
.setAttribute("guiOrder", "1")
.build());
addGitHubIdentity(testRealm);
} }
private void addGitHubIdentity(RealmRepresentation testRealm) { addFederatedIdentities(testRealm, "github", "gitlab", "twitter");
UserRepresentation acctMgtUser = findUser(testRealm, "test-user@localhost"); }
private void addFederatedIdentities(RealmRepresentation testRealm, String... idpAliases) {
UserRepresentation acctMgtUser = findUser(testRealm, "test-user@localhost");
if (acctMgtUser != null) {
ArrayList<FederatedIdentityRepresentation> fedIdps = new ArrayList<>();
for (String alias : idpAliases) {
FederatedIdentityRepresentation fedIdp = new FederatedIdentityRepresentation(); FederatedIdentityRepresentation fedIdp = new FederatedIdentityRepresentation();
fedIdp.setIdentityProvider("github"); fedIdp.setIdentityProvider(alias);
fedIdp.setUserId("foo"); fedIdp.setUserId("foo");
fedIdp.setUserName("foo"); fedIdp.setUserName("foo");
ArrayList<FederatedIdentityRepresentation> fedIdps = new ArrayList<>();
fedIdps.add(fedIdp); fedIdps.add(fedIdp);
}
acctMgtUser.setFederatedIdentities(fedIdps); acctMgtUser.setFederatedIdentities(fedIdps);
} }
}
private UserRepresentation findUser(RealmRepresentation testRealm, String userName) { private UserRepresentation findUser(RealmRepresentation testRealm, String userName) {
for (UserRepresentation user : testRealm.getUsers()) { for (UserRepresentation user : testRealm.getUsers()) {
if (user.getUsername().equals(userName)) return user; if (user.getUsername().equals(userName)) return user;
} }
return null; return null;
} }
@ -133,8 +128,12 @@ public class LinkedAccountsRestServiceTest extends AbstractTestRealmKeycloakTest
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account" + (resource != null ? "/" + resource : ""); return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account" + (resource != null ? "/" + resource : "");
} }
private SortedSet<LinkedAccountRepresentation> linkedAccountsRep() throws IOException { private List<LinkedAccountRepresentation> linkedAccountsRep() throws IOException {
return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken()).asJson(new TypeReference<SortedSet<LinkedAccountRepresentation>>() {}); return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts"), client).auth(tokenUtil.getToken()).asJson(new TypeReference<>() {});
}
private List<LinkedAccountRepresentation> linkedAccountsRep(String params) throws IOException {
return SimpleHttpDefault.doGet(getAccountUrl("linked-accounts?" + params), client).auth(tokenUtil.getToken()).asJson(new TypeReference<>() {});
} }
private LinkedAccountRepresentation findLinkedAccount(String providerAlias) throws IOException { private LinkedAccountRepresentation findLinkedAccount(String providerAlias) throws IOException {
@ -177,25 +176,88 @@ public class LinkedAccountsRestServiceTest extends AbstractTestRealmKeycloakTest
@Test @Test
public void testGetLinkedAccounts() throws IOException { public void testGetLinkedAccounts() throws IOException {
SortedSet<LinkedAccountRepresentation> details = linkedAccountsRep(); List<LinkedAccountRepresentation> details = linkedAccountsRep();
assertEquals(3, details.size()); assertEquals(8, details.size());
int order = 0; // test order of linked accounts
List<String> linkedAccountAliases = details.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("mysaml", "myoidc", "github", "gitlab", "twitter", "facebook", "bitbucket", "microsoft"));
List<String> expectedConnectedAccounts = List.of("github", "gitlab", "twitter");
for (LinkedAccountRepresentation account : details) { for (LinkedAccountRepresentation account : details) {
if (account.getProviderAlias().equals("github")) { if (expectedConnectedAccounts.contains(account.getProviderAlias())) {
assertTrue(account.isConnected()); assertTrue(account.isConnected());
} else { } else {
assertFalse(account.isConnected()); assertFalse(account.isConnected());
} }
// test that accounts were sorted by guiOrder
if (order == 0) assertEquals("mysaml", account.getDisplayName());
if (order == 1) assertEquals("MyOIDC", account.getDisplayName());
if (order == 2) assertEquals("GitHub", account.getDisplayName());
order++;
} }
} }
@Test
public void testGetLinkedAccountsWithPagination() throws IOException {
// search only connected accounts, with a max result size of 10 - should fetch all connected accounts.
List<LinkedAccountRepresentation> accounts = linkedAccountsRep("linked=true&first=0&max=10");
assertEquals(3, accounts.size());
List<String> linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("github", "gitlab", "twitter"));
for (LinkedAccountRepresentation account : accounts) {
assertTrue(account.isConnected());
}
// same search, but testing the pagination.
accounts = linkedAccountsRep("linked=true&first=0&max=2");
assertEquals(2, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("github", "gitlab"));
accounts = linkedAccountsRep("linked=true&first=2&max=4");
assertEquals(1, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("twitter"));
// now use a search string to further filter the results.
accounts = linkedAccountsRep("linked=true&search=git*");
assertEquals(2, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("github", "gitlab"));
// search unlinked identity providers.
accounts = linkedAccountsRep("linked=false&first=0&max=10");
assertEquals(5, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
// the unlinked accounts are ordered by alias, not gui order (this test needs to be adjusted if the model is fixed to order by gui order)
assertThat(linkedAccountAliases, contains("bitbucket", "facebook", "microsoft", "myoidc", "mysaml"));
for (LinkedAccountRepresentation account : accounts) {
assertFalse(account.isConnected());
}
// same search, but testing the pagination.
accounts = linkedAccountsRep("linked=false&first=0&max=3");
assertEquals(3, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("bitbucket", "facebook", "microsoft"));
accounts = linkedAccountsRep("linked=false&first=3&max=3");
assertEquals(2, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("myoidc", "mysaml"));
// now use a search string to filter the results.
accounts = linkedAccountsRep("linked=false&search=*o*");
assertEquals(3, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("facebook", "microsoft", "myoidc"));
// finally use the search string with pagination.
accounts = linkedAccountsRep("linked=false&search=*o*&first=1&max=1");
assertEquals(1, accounts.size());
linkedAccountAliases = accounts.stream().map(LinkedAccountRepresentation::getProviderAlias).toList();
assertThat(linkedAccountAliases, contains("microsoft"));
}
@Test @Test
public void testRemoveLinkedAccount() throws IOException { public void testRemoveLinkedAccount() throws IOException {
assertTrue(findLinkedAccount("github").isConnected()); assertTrue(findLinkedAccount("github").isConnected());