diff --git a/dependencies.gradle b/dependencies.gradle index b0dac4e28ea..40bfabe796b 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -17,7 +17,7 @@ versions.tomcatCargoVersion = "9.0.86" versions.guavaVersion = "33.0.0-jre" versions.seleniumVersion = "4.18.1" versions.braveVersion = "6.0.2" -versions.jacksonVersion = "2.16.1" +versions.jacksonVersion = "2.16.2" versions.jsonPathVersion = "2.9.0" // Versions we're overriding from the Spring Boot Bom (Dependabot does not issue PRs to bump these versions, so we need to manually bump them) @@ -50,7 +50,7 @@ libraries.braveInstrumentationSpringWebmvc = "io.zipkin.brave:brave-instrumentat libraries.braveContextSlf4j = "io.zipkin.brave:brave-context-slf4j:${versions.braveVersion}" libraries.commonsIo = "commons-io:commons-io:2.15.1" libraries.dumbster = "dumbster:dumbster:1.6" -libraries.eclipseJgit = "org.eclipse.jgit:org.eclipse.jgit:6.8.0.202311291450-r" +libraries.eclipseJgit = "org.eclipse.jgit:org.eclipse.jgit:6.9.0.202403050737-r" libraries.flywayCore = "org.flywaydb:flyway-core" libraries.greenmail = "com.icegreen:greenmail:1.6.15" libraries.guava = "com.google.guava:guava:${versions.guavaVersion}" diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/health/HealthzEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/health/HealthzEndpoint.java index dba799a01f8..99bc032a018 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/health/HealthzEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/health/HealthzEndpoint.java @@ -1,14 +1,19 @@ package org.cloudfoundry.identity.uaa.health; +import javax.servlet.http.HttpServletResponse; +import javax.sql.DataSource; + +import java.sql.Connection; +import java.sql.Statement; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; -import javax.servlet.http.HttpServletResponse; - /** * Simple controller that just returns "ok" in a request body for the purposes * of monitoring health of the application. It also registers a shutdown hook @@ -18,10 +23,13 @@ public class HealthzEndpoint { private static Logger logger = LoggerFactory.getLogger(HealthzEndpoint.class); private volatile boolean stopping = false; + private volatile Boolean wasLastConnectionSuccessful = null; + private DataSource dataSource; public HealthzEndpoint( @Value("${uaa.shutdown.sleep:10000}") final long sleepTime, - final Runtime runtime) { + final Runtime runtime, + final DataSource dataSource) { Thread shutdownHook = new Thread(() -> { stopping = true; logger.warn("Shutdown hook received, future requests to this endpoint will return 503"); @@ -35,6 +43,7 @@ public HealthzEndpoint( } }); runtime.addShutdownHook(shutdownHook); + this.dataSource = dataSource; } @GetMapping("/healthz") @@ -45,8 +54,28 @@ public String getHealthz(HttpServletResponse response) { response.setStatus(503); return "stopping\n"; } else { - return "ok\n"; + if (wasLastConnectionSuccessful == null) { + return "UAA running. Database status unknown.\n"; + } + + if (wasLastConnectionSuccessful) { + return "ok\n"; + } else { + response.setStatus(503); + return "Database Connection failed.\n"; + } } } -} \ No newline at end of file + @Scheduled(fixedRateString = "${uaa.health.db.rate:10000}") + void isDataSourceConnectionAvailable() { + try (Connection c = dataSource.getConnection(); Statement statement = c.createStatement()) { + statement.execute("SELECT 1 from identity_zone;"); //"SELECT 1;" Not supported by HSQLDB + wasLastConnectionSuccessful = true; + return; + } catch (Exception ex) { + logger.error("Could not establish connection to DB - " + ex.getMessage()); + } + wasLastConnectionSuccessful = false; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/home/BuildInfo.java b/server/src/main/java/org/cloudfoundry/identity/uaa/home/BuildInfo.java index 5a98c468537..3df68298404 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/home/BuildInfo.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/home/BuildInfo.java @@ -15,7 +15,7 @@ public class BuildInfo implements InitializingBean { private final Logger logger = LoggerFactory.getLogger(getClass()); - @Value("${uaa.url:http://localhost:8080/uaa}") + @Value("${uaa.url:#{T(org.cloudfoundry.identity.uaa.util.UaaStringUtils).DEFAULT_UAA_URL}}") private String uaaUrl; private String version; private String commitId; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/logging/LogSanitizerUtil.java b/server/src/main/java/org/cloudfoundry/identity/uaa/logging/LogSanitizerUtil.java index 4412bc9ff89..3e266df033d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/logging/LogSanitizerUtil.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/logging/LogSanitizerUtil.java @@ -6,9 +6,12 @@ public class LogSanitizerUtil { public static final String SANITIZED_FLAG = "[SANITIZED]"; + private LogSanitizerUtil() { + } + @Nullable public static String sanitize(String original) { - if (original == null) return original; + if (original == null) return null; String cleaned = original.replace("\r","|") .replace("\n","|") diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactory.java b/server/src/main/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactory.java index 81956f292a0..509d7482f9c 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactory.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactory.java @@ -1,15 +1,17 @@ package org.cloudfoundry.identity.uaa.logging; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; /** * Returns Log instance that replaces \n, \r, \t with a | to prevent log forging. */ public class SanitizedLogFactory { + private SanitizedLogFactory() { } + public static SanitizedLog getLog(Class clazz) { - return new SanitizedLog(LoggerFactory.getLogger(clazz)); + return new SanitizedLog(LogManager.getLogger(clazz)); } public static class SanitizedLog { @@ -24,19 +26,63 @@ public boolean isDebugEnabled() { } public void info(String message) { - fallback.info(LogSanitizerUtil.sanitize(message)); + if (fallback.isInfoEnabled()) { + fallback.info(LogSanitizerUtil.sanitize(message)); + } + } + + public void info(String message, Throwable t) { + if (fallback.isInfoEnabled()) { + fallback.info(LogSanitizerUtil.sanitize(message), t); + } } public void warn(String message) { - fallback.warn(LogSanitizerUtil.sanitize(message)); + if (fallback.isWarnEnabled()) { + fallback.warn(LogSanitizerUtil.sanitize(message)); + } + } + + public void warn(String message, Throwable t) { + if (fallback.isWarnEnabled()) { + fallback.warn(LogSanitizerUtil.sanitize(message), t); + } } public void debug(String message) { - fallback.debug(LogSanitizerUtil.sanitize(message)); + if (fallback.isDebugEnabled()) { + fallback.debug(LogSanitizerUtil.sanitize(message)); + } } public void debug(String message, Throwable t) { - fallback.debug(LogSanitizerUtil.sanitize(message), t); + if (fallback.isDebugEnabled()) { + fallback.debug(LogSanitizerUtil.sanitize(message), t); + } + } + + public void error(String message) { + if (fallback.isErrorEnabled()) { + fallback.error(LogSanitizerUtil.sanitize(message)); + } + } + + public void error(String message, Throwable t) { + if (fallback.isErrorEnabled()) { + fallback.error(LogSanitizerUtil.sanitize(message), t); + } + } + + public void trace(String message) { + if (fallback.isTraceEnabled()) { + fallback.trace(LogSanitizerUtil.sanitize(message)); + } + } + + public void trace(String message, Throwable t) { + if (fallback.isTraceEnabled()) { + fallback.trace(LogSanitizerUtil.sanitize(message), t); + } } } -} \ No newline at end of file +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java index 198dd94286a..046c99fcf3e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java @@ -23,6 +23,7 @@ import java.net.MalformedURLException; import java.net.URL; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -88,26 +89,30 @@ private AbstractExternalOAuthIdentityProviderDefinition getExternalOIDCIdentityP idpDefinitionMap.get("passwordGrantEnabled") == null ? false : (boolean) idpDefinitionMap.get("passwordGrantEnabled")); oidcIdentityProviderDefinition.setSetForwardHeader(idpDefinitionMap.get("setForwardHeader") == null ? false : (boolean) idpDefinitionMap.get("passwordGrantEnabled")); oidcIdentityProviderDefinition.setPrompts((List) idpDefinitionMap.get("prompts")); - setJwtClientAuthentication("jwtclientAuthentication", idpDefinitionMap, oidcIdentityProviderDefinition); + setJwtClientAuthentication(idpDefinitionMap, oidcIdentityProviderDefinition); oauthIdpDefinitions.put(alias, oidcIdentityProviderDefinition); rawDef = oidcIdentityProviderDefinition; provider.setType(OriginKeys.OIDC10); return rawDef; } - private static void setJwtClientAuthentication(String entry, Map map, OIDCIdentityProviderDefinition definition) { - if (map.get(entry) != null) { - if (map.get(entry) instanceof Boolean) { - boolean jwtClientAuthentication = (Boolean) map.get(entry); - if (jwtClientAuthentication) { - definition.setJwtClientAuthentication(new HashMap<>()); + private static void setJwtClientAuthentication(Map map, OIDCIdentityProviderDefinition definition) { + Object jwtClientAuthDetails = getJwtClientAuthenticationDetails(map, List.of("jwtClientAuthentication", "jwtclientAuthentication")); + if (jwtClientAuthDetails != null) { + if (jwtClientAuthDetails instanceof Boolean boolValue) { + if (boolValue.booleanValue()) { + definition.setJwtClientAuthentication(Collections.emptyMap()); } - } else if (map.get(entry) instanceof HashMap) { - definition.setJwtClientAuthentication(map.get(entry)); + } else if (jwtClientAuthDetails instanceof Map) { + definition.setJwtClientAuthentication(jwtClientAuthDetails); } } } + private static Object getJwtClientAuthenticationDetails(Map uaaYamlMap, List entryInUaaYaml) { + return entryInUaaYaml.stream().filter(e -> uaaYamlMap.get(e) != null).findFirst().map(uaaYamlMap::get).orElse(null); + } + public static IdentityProviderWrapper getIdentityProviderWrapper(String origin, AbstractExternalOAuthIdentityProviderDefinition rawDef, IdentityProvider provider, boolean override) { provider.setOriginKey(origin); provider.setName("UAA Oauth Identity Provider["+provider.getOriginKey()+"]"); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/AbstractQueryable.java b/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/AbstractQueryable.java index 6d6b42a6dd1..28292abf8b2 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/AbstractQueryable.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/AbstractQueryable.java @@ -19,7 +19,7 @@ public abstract class AbstractQueryable implements Queryable { private NamedParameterJdbcTemplate namedParameterJdbcTemplate; - private JdbcPagingListFactory pagingListFactory; + protected final JdbcPagingListFactory pagingListFactory; protected RowMapper rowMapper; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/SimpleSearchQueryConverter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/SimpleSearchQueryConverter.java index 368eb3326f9..b1774ba674a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/SimpleSearchQueryConverter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/resources/jdbc/SimpleSearchQueryConverter.java @@ -9,6 +9,7 @@ import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; @@ -125,9 +126,12 @@ private String generateParameterPrefix(String filter) { } } + /** + * @return the WHERE and (optional) ORDER BY clauses WITHOUT the "WHERE" keyword in the beginning + */ private String getWhereClause( - final String filter, - final String sortBy, + @Nullable final String filter, + @Nullable final String sortBy, final boolean ascending, final Map values, final AttributeNameMapper mapper, diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java index 93965e0f219..59e8992d4ff 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java @@ -29,6 +29,16 @@ public interface ScimUserProvisioning extends ResourceManager, Queryab List retrieveByUsernameAndZone(String username, String zoneId); + /** + * Retrieve all users that satisfy the given SCIM filter and stem from active IdPs. + */ + List retrieveByScimFilterOnlyActive( + String filter, + String sortBy, + boolean ascending, + String zoneId + ); + List retrieveByUsernameAndOriginAndZone(String username, String origin, String zoneId); void changePassword(String id, String oldPassword, String newPassword, String zoneId) throws ScimResourceNotFoundException; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java index afd48303c4a..2239aa2590b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java @@ -92,6 +92,8 @@ import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.REGISTRATION; import static org.springframework.util.StringUtils.isEmpty; +import lombok.Getter; + /** * User provisioning and query endpoints. Implements the core API from the * Simple Cloud Identity Management (SCIM) @@ -120,6 +122,7 @@ public class ScimUserEndpoints implements InitializingBean, ApplicationEventPubl private final ExpiringCodeStore codeStore; private final ApprovalStore approvalStore; private final ScimGroupMembershipManager membershipManager; + @Getter private final int userMaxCount; private final HttpMessageConverter[] messageConverters; private final AtomicInteger scimUpdates; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpoints.java index bf4ce223d4c..1097f7e69ae 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpoints.java @@ -1,16 +1,20 @@ package org.cloudfoundry.identity.uaa.scim.endpoints; -import com.unboundid.scim.sdk.SCIMException; -import com.unboundid.scim.sdk.SCIMFilter; -import org.cloudfoundry.identity.uaa.constants.OriginKeys; -import org.cloudfoundry.identity.uaa.provider.IdentityProvider; -import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning; -import org.cloudfoundry.identity.uaa.resources.SearchResults; +import javax.servlet.http.HttpServletRequest; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.cloudfoundry.identity.uaa.resources.SearchResultsFactory; import org.cloudfoundry.identity.uaa.scim.ScimCore; +import org.cloudfoundry.identity.uaa.scim.ScimUser; +import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.ScimException; import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor; +import org.cloudfoundry.identity.uaa.util.UaaPagingUtils; import org.cloudfoundry.identity.uaa.util.UaaStringUtils; -import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -28,29 +32,34 @@ import org.springframework.web.servlet.View; import org.springframework.web.util.HtmlUtils; -import javax.servlet.http.HttpServletRequest; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; +import com.unboundid.scim.sdk.SCIMException; +import com.unboundid.scim.sdk.SCIMFilter; @Controller public class UserIdConversionEndpoints implements InitializingBean { + private static final String FIELD_USERNAME = "userName"; + private static final String FIELD_ID = "id"; + private static final String FIELD_ORIGIN = "origin"; + private final Logger logger = LoggerFactory.getLogger(getClass()); - private final IdentityProviderProvisioning provisioning; + private final ScimUserProvisioning scimUserProvisioning; private final SecurityContextAccessor securityContextAccessor; private final ScimUserEndpoints scimUserEndpoints; - - private boolean enabled; - - public UserIdConversionEndpoints(final @Qualifier("identityProviderProvisioning") IdentityProviderProvisioning provisioning, - final SecurityContextAccessor securityContextAccessor, - final ScimUserEndpoints scimUserEndpoints, - final @Value("${scim.userids_enabled:true}") boolean enabled) { - this.provisioning = provisioning; + private final IdentityZoneManager identityZoneManager; + private final boolean enabled; + + public UserIdConversionEndpoints( + final SecurityContextAccessor securityContextAccessor, + final ScimUserEndpoints scimUserEndpoints, + final @Qualifier("scimUserProvisioning") ScimUserProvisioning scimUserProvisioning, + final IdentityZoneManager identityZoneManager, + final @Value("${scim.userids_enabled:true}") boolean enabled + ) { this.securityContextAccessor = securityContextAccessor; this.scimUserEndpoints = scimUserEndpoints; + this.scimUserProvisioning = scimUserProvisioning; + this.identityZoneManager = identityZoneManager; this.enabled = enabled; } @@ -58,31 +67,63 @@ public UserIdConversionEndpoints(final @Qualifier("identityProviderProvisioning" @ResponseBody public ResponseEntity findUsers( @RequestParam(defaultValue = "") String filter, - @RequestParam(required = false, defaultValue = "ascending") String sortOrder, + @RequestParam(required = false, defaultValue = "ascending") final String sortOrder, @RequestParam(required = false, defaultValue = "1") int startIndex, @RequestParam(required = false, defaultValue = "100") int count, - @RequestParam(required = false, defaultValue = "false") boolean includeInactive) { + @RequestParam(required = false, defaultValue = "false") final boolean includeInactive + ) { if (!enabled) { logger.info("Request from user {} received at disabled Id translation endpoint with filter:{}", - UaaStringUtils.getCleanedUserControlString(securityContextAccessor.getAuthenticationInfo()), - UaaStringUtils.getCleanedUserControlString(filter)); + UaaStringUtils.getCleanedUserControlString(securityContextAccessor.getAuthenticationInfo()), + UaaStringUtils.getCleanedUserControlString(filter)); return new ResponseEntity<>("Illegal Operation: Endpoint not enabled.", HttpStatus.BAD_REQUEST); } + if (startIndex < 1) { + startIndex = 1; + } + + if (count > scimUserEndpoints.getUserMaxCount()) { + count = scimUserEndpoints.getUserMaxCount(); + } + filter = filter.trim(); checkFilter(filter); - List activeIdentityProviders = provisioning.retrieveActive(IdentityZoneHolder.get().getId()); - - if (!includeInactive) { - if (activeIdentityProviders.isEmpty()) { - return new ResponseEntity<>(new SearchResults<>(Arrays.asList(ScimCore.SCHEMAS), new ArrayList<>(), startIndex, count, 0), HttpStatus.OK); - } - String originFilter = activeIdentityProviders.stream().map(identityProvider -> "".concat("origin eq \"" + identityProvider.getOriginKey() + "\"")).collect(Collectors.joining(" OR ")); - filter += " AND (" + originFilter + " )"; + // get all users for the given filter and the current page + final boolean ascending = sortOrder.equalsIgnoreCase("ascending"); + final List filteredUsers; + if (includeInactive) { + filteredUsers = scimUserProvisioning.query( + filter, FIELD_USERNAME, ascending, identityZoneManager.getCurrentIdentityZoneId() + ); + } else { + filteredUsers = scimUserProvisioning.retrieveByScimFilterOnlyActive( + filter, FIELD_USERNAME, ascending, identityZoneManager.getCurrentIdentityZoneId() + ); } - - return new ResponseEntity<>(scimUserEndpoints.findUsers("id,userName,origin", filter, "userName", sortOrder, startIndex, count), HttpStatus.OK); + final List usersCurrentPage = UaaPagingUtils.subList(filteredUsers, startIndex, count); + + // map to result structure + final List> result = usersCurrentPage.stream() + .map(scimUser -> Map.of( + FIELD_ID, scimUser.getId(), + FIELD_USERNAME, scimUser.getUserName(), + FIELD_ORIGIN, scimUser.getOrigin() + )) + .toList(); + + return new ResponseEntity<>( + SearchResultsFactory.buildSearchResultFrom( + result, + startIndex, + count, + filteredUsers.size(), + new String[]{FIELD_ID, FIELD_USERNAME, FIELD_ORIGIN}, + Arrays.asList(ScimCore.SCHEMAS) + ), + HttpStatus.OK + ); } @ExceptionHandler @@ -123,10 +164,10 @@ private boolean containsIdOrUserNameClause(SCIMFilter filter) { return containsIdOrUserNameClause(filter.getFilterComponents().get(1)) || resultLeftOperand; case EQUALITY: String name = filter.getFilterAttribute().getAttributeName(); - if ("id".equalsIgnoreCase(name) || - "userName".equalsIgnoreCase(name)) { + if (FIELD_ID.equalsIgnoreCase(name) || + FIELD_USERNAME.equalsIgnoreCase(name)) { return true; - } else if (OriginKeys.ORIGIN.equalsIgnoreCase(name)) { + } else if (FIELD_ORIGIN.equalsIgnoreCase(name)) { return false; } else { throw new ScimException("Invalid filter attribute.", HttpStatus.BAD_REQUEST); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java index 6ad15f7f379..c590c640028 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java @@ -12,19 +12,32 @@ *******************************************************************************/ package org.cloudfoundry.identity.uaa.scim.jdbc; -import org.cloudfoundry.identity.uaa.util.UaaStringUtils; -import org.cloudfoundry.identity.uaa.zone.IdentityZone; -import org.cloudfoundry.identity.uaa.zone.JdbcIdentityZoneProvisioning; -import org.cloudfoundry.identity.uaa.zone.UserConfig; -import org.cloudfoundry.identity.uaa.zone.ZoneDoesNotExistsException; -import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import static java.sql.Types.VARCHAR; +import static java.util.stream.Collectors.joining; +import static org.springframework.util.StringUtils.hasText; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Stream; + import org.cloudfoundry.identity.uaa.audit.event.SystemDeletable; import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.resources.AttributeNameMapper; import org.cloudfoundry.identity.uaa.resources.ResourceMonitor; import org.cloudfoundry.identity.uaa.resources.jdbc.AbstractQueryable; import org.cloudfoundry.identity.uaa.resources.jdbc.JdbcPagingListFactory; +import org.cloudfoundry.identity.uaa.resources.jdbc.SearchQueryConverter.ProcessedFilter; import org.cloudfoundry.identity.uaa.resources.jdbc.SimpleSearchQueryConverter; import org.cloudfoundry.identity.uaa.scim.ScimMeta; import org.cloudfoundry.identity.uaa.scim.ScimUser; @@ -39,32 +52,25 @@ import org.cloudfoundry.identity.uaa.user.JdbcUaaUserDatabase; import org.cloudfoundry.identity.uaa.util.TimeService; import org.cloudfoundry.identity.uaa.util.TimeServiceImpl; +import org.cloudfoundry.identity.uaa.util.UaaStringUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.JdbcIdentityZoneProvisioning; +import org.cloudfoundry.identity.uaa.zone.UserConfig; +import org.cloudfoundry.identity.uaa.zone.ZoneDoesNotExistsException; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.util.Assert; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.sql.Types; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.regex.Pattern; - -import static java.sql.Types.VARCHAR; -import static org.springframework.util.StringUtils.hasText; - public class JdbcScimUserProvisioning extends AbstractQueryable implements ScimUserProvisioning, ResourceMonitor, SystemDeletable { @@ -170,6 +176,69 @@ public List retrieveByUsernameAndZone(String username, String zoneId) return jdbcTemplate.query(USER_BY_USERNAME_AND_ZONE_QUERY , mapper, username, zoneId); } + @Override + public List retrieveByScimFilterOnlyActive( + final String filter, + final String sortBy, + final boolean ascending, + final String zoneId + ) { + final SimpleSearchQueryConverter queryConverter = new SimpleSearchQueryConverter(); + validateOrderBy(queryConverter.map(sortBy)); + + /* since the two tables used in the query ('users' and 'identity_provider') have columns with identical names, + * we must ensure that the columns of 'users' are used in the WHERE clause generated for the SCIM filter */ + final AttributeNameMapper attributeNameMapper = new AttributeNameMapper() { + @Override + public String mapToInternal(final String attr) { + // in the later query, 'users' will have the alias 'u' + return "u." + attr; + } + + @Override + public String[] mapToInternal(final String[] attr) { + return Stream.of(attr).map(this::mapToInternal).toArray(String[]::new); + } + + @Override + public String mapFromInternal(final String attr) { + return attr.substring(2); + } + + @Override + public String[] mapFromInternal(final String[] attr) { + return Stream.of(attr).map(this::mapFromInternal).toArray(String[]::new); + } + }; + queryConverter.setAttributeNameMapper(attributeNameMapper); + + // build WHERE clause + final ProcessedFilter where = queryConverter.convert(filter, sortBy, ascending, zoneId); + final String whereClauseScimFilter = where.getSql(); + String whereClause = "idp.active is true and ("; + if (where.hasOrderBy()) { + whereClause += whereClauseScimFilter.replace(ProcessedFilter.ORDER_BY, ")" + ProcessedFilter.ORDER_BY); + } else { + whereClause += whereClauseScimFilter + ")"; + } + + final String userFieldsWithPrefix = Arrays.stream(USER_FIELDS.split(",")) + .map(field -> "u." + field) + .collect(joining(", ")); + final String sql = String.format( + "select %s from users u join identity_provider idp on u.origin = idp.origin_key and u.identity_zone_id = idp.identity_zone_id where %s", + userFieldsWithPrefix, + whereClause + ); + + if (getPageSize() > 0 && getPageSize() < Integer.MAX_VALUE) { + return pagingListFactory.createJdbcPagingList(sql, where.getParams(), rowMapper, getPageSize()); + } + + final NamedParameterJdbcTemplate namedParameterJdbcTemplate = new NamedParameterJdbcTemplate(jdbcTemplate); + return namedParameterJdbcTemplate.query(sql, where.getParams(), rowMapper); + } + @Override public List retrieveByUsernameAndOriginAndZone(String username, String origin, String zoneId) { return jdbcTemplate.query(USER_BY_USERNAME_AND_ORIGIN_AND_ZONE_QUERY , mapper, username, origin, zoneId); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/HealthzEndpointTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/health/HealthzEndpointTests.java similarity index 65% rename from server/src/test/java/org/cloudfoundry/identity/uaa/web/HealthzEndpointTests.java rename to server/src/test/java/org/cloudfoundry/identity/uaa/health/HealthzEndpointTests.java index 4baeb895211..153cac19bc0 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/web/HealthzEndpointTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/health/HealthzEndpointTests.java @@ -1,18 +1,25 @@ -package org.cloudfoundry.identity.uaa.web; - -import org.cloudfoundry.identity.uaa.health.HealthzEndpoint; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.mock.web.MockHttpServletResponse; +package org.cloudfoundry.identity.uaa.health; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import javax.sql.DataSource; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletResponse; class HealthzEndpointTests { @@ -21,11 +28,19 @@ class HealthzEndpointTests { private HealthzEndpoint endpoint; private MockHttpServletResponse response; private Thread shutdownHook; + private DataSource dataSource; + private Connection connection; + private Statement statement; @BeforeEach - void setUp() { + void setUp() throws SQLException { Runtime mockRuntime = mock(Runtime.class); - endpoint = new HealthzEndpoint(SLEEP_UPON_SHUTDOWN, mockRuntime); + dataSource = mock(DataSource.class); + connection = mock(Connection.class); + statement = mock(Statement.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.createStatement()).thenReturn(statement); + endpoint = new HealthzEndpoint(SLEEP_UPON_SHUTDOWN, mockRuntime, dataSource); response = new MockHttpServletResponse(); ArgumentCaptor threadArgumentCaptor = ArgumentCaptor.forClass(Thread.class); @@ -35,8 +50,21 @@ void setUp() { @Test void getHealthz() { + assertEquals("UAA running. Database status unknown.\n", endpoint.getHealthz(response)); + } + + @Test + void getHealthz_connectionSuccess() { + endpoint.isDataSourceConnectionAvailable(); assertEquals("ok\n", endpoint.getHealthz(response)); } + @Test + void getHealthz_connectionFailed() throws SQLException { + when(statement.execute(anyString())).thenThrow(new SQLException()); + endpoint.isDataSourceConnectionAvailable(); + assertEquals("Database Connection failed.\n", endpoint.getHealthz(response)); + assertEquals(503, response.getStatus()); + } @Test void shutdownSendsStopping() throws InterruptedException { @@ -54,7 +82,8 @@ class WithoutSleeping { @BeforeEach void setUp() { Runtime mockRuntime = mock(Runtime.class); - endpoint = new HealthzEndpoint(-1, mockRuntime); + DataSource dataSource = mock(DataSource.class); + endpoint = new HealthzEndpoint(-1, mockRuntime, dataSource); response = new MockHttpServletResponse(); ArgumentCaptor threadArgumentCaptor = ArgumentCaptor.forClass(Thread.class); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactoryTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactoryTest.java index 60bf5e795be..f464c56f936 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactoryTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/logging/SanitizedLogFactoryTest.java @@ -1,61 +1,155 @@ package org.cloudfoundry.identity.uaa.logging; -import org.slf4j.Logger; +import org.apache.logging.log4j.Logger; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.commons.lang3.RandomStringUtils; public class SanitizedLogFactoryTest { + private final String dirtyMessage = "one\ntwo\tthree\rfour"; + private final String sanitizedMsg = "one|two|three|four[SANITIZED]"; + private final String cleanMessage = "one two three four"; + Logger mockLog; + SanitizedLogFactory.SanitizedLog log; + Exception ex; @Before public void setUp() { mockLog = mock(Logger.class); + log = new SanitizedLogFactory.SanitizedLog(mockLog); + ex = new Exception(RandomStringUtils.randomAlphanumeric(8)); } @Test - public void testSanitizeDebug() { - SanitizedLogFactory.SanitizedLog log = new SanitizedLogFactory.SanitizedLog(mockLog); - log.debug("one\ntwo\tthree\rfour"); - verify(mockLog).debug("one|two|three|four[SANITIZED]"); + public void testInit() { + Assert.assertNotNull(SanitizedLogFactory.getLog(SanitizedLogFactoryTest.class)); } @Test - public void testSanitizeDebugCleanMessage() { - SanitizedLogFactory.SanitizedLog log = new SanitizedLogFactory.SanitizedLog(mockLog); - log.debug("one two three four"); - verify(mockLog).debug("one two three four"); + public void testSanitizeInfo() { + when(mockLog.isInfoEnabled()).thenReturn(true); + log.info(dirtyMessage); + verify(mockLog).info(sanitizedMsg); + log.info(dirtyMessage, ex); + verify(mockLog).info(sanitizedMsg, ex); } @Test - public void testSanitizeInfo() { - SanitizedLogFactory.SanitizedLog log = new SanitizedLogFactory.SanitizedLog(mockLog); - log.info("one\ntwo\tthree\rfour"); - verify(mockLog).info("one|two|three|four[SANITIZED]"); + public void testSanitizeInfoCleanMessage() { + when(mockLog.isInfoEnabled()).thenReturn(true); + log.info(cleanMessage); + verify(mockLog).info(cleanMessage); + log.info(cleanMessage, ex); + verify(mockLog).info(cleanMessage, ex); } @Test - public void testSanitizeInfoCleanMessage() { - SanitizedLogFactory.SanitizedLog log = new SanitizedLogFactory.SanitizedLog(mockLog); - log.info("one two three four"); - verify(mockLog).info("one two three four"); + public void testSanitizeDebug() { + when(mockLog.isDebugEnabled()).thenReturn(true); + log.debug(dirtyMessage); + verify(mockLog).debug(sanitizedMsg); + log.debug(dirtyMessage, ex); + verify(mockLog).debug(sanitizedMsg, ex); } + @Test + public void testSanitizeDebugCleanMessage() { + when(mockLog.isDebugEnabled()).thenReturn(true); + log.debug(cleanMessage); + verify(mockLog).debug(cleanMessage); + log.debug(cleanMessage, ex); + verify(mockLog).debug(cleanMessage, ex); + } + + @Test + public void testSanitizeDebugCleanMessageNotEnabled() { + when(mockLog.isDebugEnabled()).thenReturn(false); + log.debug(cleanMessage); + verify(mockLog, never()).debug(cleanMessage); + log.debug(cleanMessage, ex); + verify(mockLog, never()).debug(cleanMessage, ex); + Assert.assertFalse(log.isDebugEnabled()); + } + + @Test public void testSanitizeWarn() { - SanitizedLogFactory.SanitizedLog log = new SanitizedLogFactory.SanitizedLog(mockLog); - log.warn("one\ntwo\tthree\rfour"); - verify(mockLog).warn("one|two|three|four[SANITIZED]"); + when(mockLog.isWarnEnabled()).thenReturn(true); + log.warn(dirtyMessage); + verify(mockLog).warn(sanitizedMsg); + log.warn(dirtyMessage, ex); + verify(mockLog).warn(sanitizedMsg, ex); } @Test public void testSanitizeWarnCleanMessage() { - SanitizedLogFactory.SanitizedLog log = new SanitizedLogFactory.SanitizedLog(mockLog); - log.warn("one two three four"); - verify(mockLog).warn("one two three four"); + when(mockLog.isWarnEnabled()).thenReturn(true); + log.warn(cleanMessage); + verify(mockLog).warn(cleanMessage); + log.warn(cleanMessage, ex); + verify(mockLog).warn(cleanMessage, ex); } -} \ No newline at end of file + @Test + public void testSanitizeWarnCleanMessageNotEnabled() { + when(mockLog.isWarnEnabled()).thenReturn(false); + log.warn(cleanMessage); + verify(mockLog, never()).warn(cleanMessage); + log.warn(cleanMessage, ex); + verify(mockLog, never()).warn(cleanMessage, ex); + } + + @Test + public void testSanitizeError() { + when(mockLog.isErrorEnabled()).thenReturn(true); + log.error(dirtyMessage); + verify(mockLog).error(sanitizedMsg); + log.error(dirtyMessage, ex); + verify(mockLog).error(sanitizedMsg, ex); + } + + @Test + public void testSanitizeErrorCleanMessage() { + when(mockLog.isErrorEnabled()).thenReturn(true); + log.error(cleanMessage); + verify(mockLog).error(cleanMessage); + log.error(cleanMessage, ex); + verify(mockLog).error(cleanMessage, ex); + } + + @Test + public void testSanitizeTrace() { + when(mockLog.isTraceEnabled()).thenReturn(true); + log.trace(dirtyMessage); + verify(mockLog).trace(sanitizedMsg); + log.trace(dirtyMessage, ex); + verify(mockLog).trace(sanitizedMsg, ex); + } + + @Test + public void testSanitizeTraceCleanMessage() { + when(mockLog.isTraceEnabled()).thenReturn(true); + log.trace(cleanMessage); + verify(mockLog).trace(cleanMessage); + log.trace(cleanMessage, ex); + verify(mockLog).trace(cleanMessage, ex); + } + + @Test + public void testSanitizeTraceCleanMessageWhenNotEnabled() { + when(mockLog.isTraceEnabled()).thenReturn(false); + log.trace(cleanMessage); + verify(mockLog, never()).trace(cleanMessage); + log.trace(cleanMessage, ex); + verify(mockLog, never()).trace(cleanMessage, ex); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java index b0ed32d74d1..559433b59bd 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java @@ -30,6 +30,7 @@ import static org.cloudfoundry.identity.uaa.util.UaaMapUtils.map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -120,7 +121,7 @@ public void external_group_mapping_default_in_body() { @Test public void jwtClientAuthenticationTrue() { Map definitions = new HashMap<>(); - idpDefinitionMap.put("jwtclientAuthentication", new Boolean(true)); + idpDefinitionMap.put("jwtclientAuthentication", Boolean.valueOf (true)); idpDefinitionMap.put("type", OriginKeys.OIDC10); definitions.put("test", idpDefinitionMap); factoryBean = new OauthIDPWrapperFactoryBean(definitions); @@ -143,7 +144,7 @@ public void jwtClientAuthenticationNull() { @Test public void jwtClientAuthenticationInvalidType() { Map definitions = new HashMap<>(); - idpDefinitionMap.put("jwtclientAuthentication", new Integer(1)); + idpDefinitionMap.put("jwtclientAuthentication", Integer.valueOf(1)); idpDefinitionMap.put("type", OriginKeys.OIDC10); definitions.put("test", idpDefinitionMap); factoryBean = new OauthIDPWrapperFactoryBean(definitions); @@ -167,6 +168,24 @@ public void jwtClientAuthenticationWithCustomSetting() { assertEquals("issuer", (((Map)((OIDCIdentityProviderDefinition) factoryBean.getProviders().get(0).getProvider().getConfig()).getJwtClientAuthentication()).get("iss"))); } + @Test + public void jwtClientAuthenticationWith2EntriesButNewOneMustWin() { + // given: 2 similar entry because of issue #2752 + idpDefinitionMap.put("jwtclientAuthentication", Map.of("iss", "issuer")); + idpDefinitionMap.put("jwtClientAuthentication", Map.of("iss", "trueIssuer")); + idpDefinitionMap.put("type", OriginKeys.OIDC10); + Map definitions = new HashMap<>(); + definitions.put("test", idpDefinitionMap); + // when: load beans from uaa.yml + factoryBean = new OauthIDPWrapperFactoryBean(definitions); + factoryBean.setCommonProperties(idpDefinitionMap, providerDefinition); + // then + assertTrue(factoryBean.getProviders().get(0).getProvider().getConfig() instanceof OIDCIdentityProviderDefinition); + assertNotNull(((OIDCIdentityProviderDefinition) factoryBean.getProviders().get(0).getProvider().getConfig()).getJwtClientAuthentication()); + assertNotEquals("issuer", (((Map)((OIDCIdentityProviderDefinition) factoryBean.getProviders().get(0).getProvider().getConfig()).getJwtClientAuthentication()).get("iss"))); + assertEquals("trueIssuer", (((Map)((OIDCIdentityProviderDefinition) factoryBean.getProviders().get(0).getProvider().getConfig()).getJwtClientAuthentication()).get("iss"))); + } + @Test public void testNoDiscoveryUrl() { Map definitions = new HashMap<>(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpointsTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpointsTests.java index da46efb7dd0..61cf3d8d227 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpointsTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/UserIdConversionEndpointsTests.java @@ -13,225 +13,281 @@ package org.cloudfoundry.identity.uaa.scim.endpoints; -import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning; +import static java.util.Collections.singletonList; +import static java.util.UUID.randomUUID; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + import org.cloudfoundry.identity.uaa.resources.SearchResults; +import org.cloudfoundry.identity.uaa.scim.ScimUser; +import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.ScimException; import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor; -import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.mockito.Mockito; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.authority.AuthorityUtils; -import java.util.Collection; -import java.util.Collections; - -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertTrue; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.is; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; - /** * @author Dave Syer * @author Luke Taylor - * */ -public class UserIdConversionEndpointsTests { - - @Rule - public ExpectedException expected = ExpectedException.none(); - - private IdentityProviderProvisioning provisioning = Mockito.mock(IdentityProviderProvisioning.class); - +@ExtendWith(MockitoExtension.class) +class UserIdConversionEndpointsTests { private UserIdConversionEndpoints endpoints; + @Mock private SecurityContextAccessor mockSecurityContextAccessor; - - private ScimUserEndpoints scimUserEndpoints = Mockito.mock(ScimUserEndpoints.class); + @Mock + private ScimUserEndpoints scimUserEndpoints; + @Mock + private ScimUserProvisioning scimUserProvisioning; + @Mock + private IdentityZoneManager identityZoneManager; @SuppressWarnings("rawtypes") - private Collection authorities = AuthorityUtils - .commaSeparatedStringToAuthorityList("orgs.foo,uaa.user"); + private final Collection authorities = AuthorityUtils + .commaSeparatedStringToAuthorityList("orgs.foo,uaa.user"); @SuppressWarnings("unchecked") - @Before + @BeforeEach public void init() { - mockSecurityContextAccessor = Mockito.mock(SecurityContextAccessor.class); - endpoints = new UserIdConversionEndpoints(provisioning, mockSecurityContextAccessor, scimUserEndpoints, true); - when(mockSecurityContextAccessor.getAuthorities()).thenReturn(authorities); - when(mockSecurityContextAccessor.getAuthenticationInfo()).thenReturn("mock object"); - when(provisioning.retrieveActive(anyString())).thenReturn(Collections.singletonList(MultitenancyFixture.identityProvider("test-origin", "uaa"))); + endpoints = new UserIdConversionEndpoints(mockSecurityContextAccessor, scimUserEndpoints, scimUserProvisioning, identityZoneManager, true); + lenient().when(mockSecurityContextAccessor.getAuthorities()).thenReturn(authorities); + lenient().when(mockSecurityContextAccessor.getAuthenticationInfo()).thenReturn("mock object"); + lenient().when(scimUserEndpoints.getUserMaxCount()).thenReturn(10_000); } @Test - public void testHappyDay() { - endpoints.findUsers("userName eq \"marissa\"", "ascending", 0, 100, false); + void testHappyDay() { + arrangeCurrentIdentityZone("uaa"); + assertThatNoException() + .isThrownBy(() -> endpoints.findUsers("userName eq \"marissa\"", "ascending", 0, 100, false)); } @Test - public void testSanitizeExceptionInFilter() { - expected.expect(ScimException.class); - expected.expectMessage(is("Invalid filter '<svg onload=alert(document.domain)>'")); - endpoints.findUsers("", "ascending", 0, 100, false); + void testSanitizeExceptionInFilter() { + assertThatExceptionOfType(ScimException.class) + .isThrownBy(() -> endpoints.findUsers("", "ascending", 0, 100, false)) + .withMessage("Invalid filter '<svg onload=alert(document.domain)>'"); } @Test - public void testBadFieldInFilter() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid filter")); - endpoints.findUsers("emails.value eq \"foo@bar.org\"", "ascending", 0, 100, false); - } + void testGoodFilter_IncludeInactive() { + final String idzId = randomUUID().toString(); + arrangeCurrentIdentityZone(idzId); - @Test - public void testBadFilterWithGroup() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid filter")); - endpoints.findUsers("groups.display eq \"foo\"", "ascending", 0, 100, false); - } + final String filter = "(username eq \"foo\" and id eq \"bar\") or username eq \"bar\""; - @Test - public void testGoodFilter1() { - final ResponseEntity response = endpoints.findUsers( - "(id eq \"foo\" or username eq \"bar\") and origin eq \"uaa\"", - "ascending", - 0, - 100, - false - ); - assertEquals(HttpStatus.OK, response.getStatusCode()); - } + final List allScimUsers = new ArrayList<>(); + for (int i = 0; i < 5; ++i) { + final ScimUser scimUser = new ScimUser(randomUUID().toString(), "bar", "Some", "Name"); + scimUser.setOrigin("idp2"); + allScimUsers.add(scimUser); + } + final ScimUser scimUser6 = new ScimUser("bar", "foo", "Some", "Name"); + scimUser6.setOrigin("idp1"); + allScimUsers.add(scimUser6); + assertThat(allScimUsers).hasSize(6); + arrangeScimUsersForFilter(filter, allScimUsers, true, idzId); - @Test - public void testGoodFilter2() { - final ResponseEntity response = endpoints.findUsers( - "origin eq \"uaa\" and (id eq \"foo\" or username eq \"bar\")", - "ascending", - 0, - 100, - false - ); - assertEquals(HttpStatus.OK, response.getStatusCode()); + // check different page sizes -> should return all users, since 'includeInactive' is true + assertEndpointReturnsCorrectResult(filter, 1, allScimUsers, true); + assertEndpointReturnsCorrectResult(filter, 2, allScimUsers, true); + assertEndpointReturnsCorrectResult(filter, 3, allScimUsers, true); + assertEndpointReturnsCorrectResult(filter, 4, allScimUsers, true); + assertEndpointReturnsCorrectResult(filter, 10, allScimUsers, true); } @Test - public void testGoodFilter3() { - final ResponseEntity response = endpoints.findUsers( - "(id eq \"foo\" and username eq \"bar\") or id eq \"bar\"", - "ascending", - 0, - 100, - false - ); - assertEquals(HttpStatus.OK, response.getStatusCode()); + void testGoodFilter_OnlyActive() { + final String idzId = randomUUID().toString(); + arrangeCurrentIdentityZone(idzId); + + final String filter = "(username eq \"foo\" and id eq \"bar\") or username eq \"bar\""; + + // one active user + final ScimUser scimUser = new ScimUser("bar", "foo", "Some", "Name"); + scimUser.setOrigin("idp1"); + final List expectedUsers = singletonList(scimUser); + arrangeScimUsersForFilter(filter, expectedUsers, false, idzId); + + // check different page sizes + assertEndpointReturnsCorrectResult(filter, 1, expectedUsers, false); + assertEndpointReturnsCorrectResult(filter, 2, expectedUsers, false); + assertEndpointReturnsCorrectResult(filter, 3, expectedUsers, false); + assertEndpointReturnsCorrectResult(filter, 4, expectedUsers, false); + assertEndpointReturnsCorrectResult(filter, 10, expectedUsers, false); } - @Test - public void testGoodFilter4() { + @ParameterizedTest + @ValueSource(strings = { + "(id eq \"foo\" or username eq \"bar\") and origin eq \"uaa\"", + "origin eq \"uaa\" and (id eq \"foo\" or username eq \"bar\")", + "(id eq \"foo\" and username eq \"bar\") or id eq \"bar\"", + "id eq \"bar\" and (id eq \"foo\" and username eq \"bar\")" + }) + void testGoodFilter(final String filter) { + arrangeCurrentIdentityZone("uaa"); final ResponseEntity response = endpoints.findUsers( - "id eq \"bar\" and (id eq \"foo\" and username eq \"bar\")", + filter, "ascending", 0, 100, false ); - assertEquals(HttpStatus.OK, response.getStatusCode()); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } - @Test - public void testBadFilter1() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Wildcards are not allowed in filter.")); - endpoints.findUsers("id co \"foo\"", "ascending", 0, 100, false); + @ParameterizedTest + @ValueSource(strings = { + "id co \"foo\"", + "id sw \"foo\"", + "id pr", + "id eq \"foo\" or origin co \"uaa\"" + }) + void testBadFilter_WildcardsNotAllowed(final String filter) { + assertThatExceptionOfType(ScimException.class) + .isThrownBy(() -> endpoints.findUsers(filter, "ascending", 0, 100, false)) + .withMessage("Wildcards are not allowed in filter."); } - @Test - public void testBadFilter2() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid filter")); - endpoints.findUsers("id sq \"foo\"", "ascending", 0, 100, false); + @ParameterizedTest + @ValueSource(strings = { + "id gt \"foo\"", + "id le \"foo\"", + "id lt \"foo\"" + }) + void testBadFilter_UnsupportedOperator(final String filter) { + assertThatExceptionOfType(ScimException.class) + .isThrownBy(() -> endpoints.findUsers(filter, "ascending", 0, 100, false)) + .withMessage("Invalid operator."); } - @Test - public void testBadFilter3() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Wildcards are not allowed in filter.")); - endpoints.findUsers("id sw \"foo\"", "ascending", 0, 100, false); + @ParameterizedTest + @ValueSource(strings = { + "id sq \"foo\"" + }) + void testBadFilter_UnrecognizedOperator(final String filter) { + assertThatExceptionOfType(ScimException.class) + .isThrownBy(() -> endpoints.findUsers(filter, "ascending", 0, 100, false)) + .withMessageStartingWith("Invalid filter '"); } - @Test - public void testBadFilter4() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Wildcards are not allowed in filter.")); - endpoints.findUsers("id pr", "ascending", 0, 100, false); + @ParameterizedTest + @ValueSource(strings = { + "origin eq \"uaa\"", + "emails.value eq \"foo@bar.org\"", + "origin eq \"uaa\" or origin eq \"bar\"", + "groups.display eq \"foo\"" + }) + void testBadFilter_DoesNotContainClauseWithIdOrUserName(final String filter) { + assertThatExceptionOfType(ScimException.class) + .isThrownBy(() -> endpoints.findUsers(filter, "ascending", 0, 100, false)) + .withMessage("Invalid filter attribute."); } @Test - public void testBadFilter5() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid operator.")); - endpoints.findUsers("id gt \"foo\"", "ascending", 0, 100, false); - } - @Test - public void testBadFilter6() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid operator.")); - endpoints.findUsers("id gt \"foo\"", "ascending", 0, 100, false); + void testDisabled() { + endpoints = new UserIdConversionEndpoints(mockSecurityContextAccessor, scimUserEndpoints, scimUserProvisioning, identityZoneManager, false); + ResponseEntity response = endpoints.findUsers("id eq \"foo\"", "ascending", 0, 100, false); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isEqualTo("Illegal Operation: Endpoint not enabled."); } + @Test - public void testBadFilter7() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid operator.")); - endpoints.findUsers("id lt \"foo\"", "ascending", 0, 100, false); + void noActiveIdps_ReturnsEmptyResources() { + arrangeCurrentIdentityZone("uaa"); + SearchResults searchResults = (SearchResults) endpoints.findUsers("username eq \"foo\"", "ascending", 0, 100, false).getBody(); + assertThat(searchResults).isNotNull(); + assertThat(searchResults.getResources()).isEmpty(); } - @Test - public void testBadFilter8() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid operator.")); - endpoints.findUsers("id le \"foo\"", "ascending", 0, 100, false); + + private void arrangeCurrentIdentityZone(final String idzId) { + when(identityZoneManager.getCurrentIdentityZoneId()).thenReturn(idzId); } - @Test - public void testBadFilter9() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid filter")); - endpoints.findUsers("origin eq \"uaa\"", "ascending", 0, 100, false); + private void arrangeScimUsersForFilter( + final String filter, + final List allScimUsers, + final boolean includeInactive, + final String zoneId + ) { + if (includeInactive) { + when(scimUserProvisioning.query(filter, "userName", true, zoneId)).thenReturn(allScimUsers); + } else { + when(scimUserProvisioning.retrieveByScimFilterOnlyActive(filter, "userName", true, zoneId)) + .thenReturn(allScimUsers); + } } - @Test - public void testBadFilter10() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Wildcards are not allowed in filter.")); + private void assertEndpointReturnsCorrectResult( + final String filter, + final int resultsPerPage, + final List expectedUsers, + final boolean includeInactive + ) { + final boolean lastPageIncomplete = expectedUsers.size() % resultsPerPage != 0; + final int expectedPages = expectedUsers.size() / resultsPerPage + (lastPageIncomplete ? 1 : 0); - // illegal operator in right operand of root-level "or" -> all branches need to be checked even if left operands are valid - endpoints.findUsers("id eq \"foo\" or origin co \"uaa\"", "ascending", 0, 100, false); - } + final Function>> fetchNextPage = (startIndex) -> { + final ResponseEntity response = endpoints.findUsers( + filter, "ascending", startIndex, resultsPerPage, includeInactive + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull().isInstanceOf(SearchResults.class); + return (SearchResults>) response.getBody(); + }; - @Test - public void testBadFilter11() { - expected.expect(ScimException.class); - expected.expectMessage(containsString("Invalid filter")); - endpoints.findUsers("origin eq \"uaa\" or origin eq \"bar\"", "ascending", 0, 100, false); - } + // collect all users in several pages + final List> observedUsers = new ArrayList<>(); + int currentStartIndex = 1; + for (int i = 0; i < expectedPages; i++) { + final SearchResults> responseBody = fetchNextPage.apply(currentStartIndex); + assertThat(responseBody.getTotalResults()).isEqualTo(expectedUsers.size()); - @Test - public void testDisabled() { - endpoints = new UserIdConversionEndpoints(provisioning, mockSecurityContextAccessor, scimUserEndpoints, false); - ResponseEntity response = endpoints.findUsers("id eq \"foo\"", "ascending", 0, 100, false); - Assert.assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - Assert.assertEquals("Illegal Operation: Endpoint not enabled.", response.getBody()); - } + final int expectedNumberOfResultsInPage; + if (i == expectedPages - 1 && lastPageIncomplete) { + // last page -> might contain less elements + expectedNumberOfResultsInPage = expectedUsers.size() % resultsPerPage; + } else { + // complete page + expectedNumberOfResultsInPage = resultsPerPage; + } + assertThat(responseBody.getResources()).hasSize(expectedNumberOfResultsInPage); - @Test - public void noActiveIdps_ReturnsEmptyResources() { - when(provisioning.retrieveActive(anyString())).thenReturn(Collections.emptyList()); - SearchResults searchResults = (SearchResults) endpoints.findUsers("username eq \"foo\"", "ascending", 0, 100, false).getBody(); - assertTrue(searchResults.getResources().isEmpty()); + observedUsers.addAll(responseBody.getResources()); + currentStartIndex += responseBody.getResources().size(); + } + + // check next page -> should be empty + final SearchResults> responseBody = fetchNextPage.apply(currentStartIndex);; + assertThat(responseBody.getTotalResults()).isEqualTo(expectedUsers.size()); + assertThat(responseBody.getResources()).isNotNull().isEmpty(); + + final List> expectedResponse = expectedUsers.stream().map(scimUser -> Map.of( + "id", (Object) scimUser.getId(), + "userName", scimUser.getUserName(), + "origin", scimUser.getOrigin() + )).collect(toList()); + + assertThat(observedUsers).hasSameElementsAs(expectedResponse); } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java index 36286f1992c..373faccb0a1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioningTests.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.scim.jdbc; import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; import static org.cloudfoundry.identity.uaa.constants.OriginKeys.LOGIN_SERVER; import static org.cloudfoundry.identity.uaa.constants.OriginKeys.UAA; import static org.cloudfoundry.identity.uaa.util.AssertThrowsWithMessage.assertThrowsWithMessageThat; @@ -28,6 +29,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Function; import java.util.stream.Stream; import org.assertj.core.api.Assertions; @@ -71,6 +73,7 @@ import org.springframework.security.oauth2.common.util.RandomValueStringGenerator; import org.springframework.test.util.ReflectionTestUtils; + @WithDatabaseContext class JdbcScimUserProvisioningTests { @@ -248,6 +251,108 @@ void canDeleteProviderUsersInDefaultZone() { assertThat(jdbcTemplate.queryForObject("select count(*) from group_membership where member_id=?", new Object[]{created.getId()}, Integer.class), is(0)); } + @Test + void retrieveByScimFilterOnlyActive() { + final String originActive = randomString(); + addIdentityProvider(jdbcTemplate, currentIdentityZoneId, originActive, true); + + final String originInactive = randomString(); + addIdentityProvider(jdbcTemplate, currentIdentityZoneId, originInactive, false); + + final ScimUser user1 = new ScimUser(null, "jo@foo.com", "Jo", "User"); + user1.addEmail("jo@blah.com"); + user1.setOrigin(originActive); + final ScimUser created1 = jdbcScimUserProvisioning.createUser(user1, "j7hyqpassX", currentIdentityZoneId); + + final ScimUser user2 = new ScimUser(null, "jo2@foo.com", "Jo", "User"); + user2.addEmail("jo2@blah.com"); + user2.setOrigin(originInactive); + final ScimUser created2 = jdbcScimUserProvisioning.createUser(user2, "j7hyqpassX", currentIdentityZoneId); + + final Function> retrieveByScimFilter = (scimFilter) -> { + final List result = jdbcScimUserProvisioning.retrieveByScimFilterOnlyActive( + scimFilter, + "userName", + true, + currentIdentityZoneId + ); + Assertions.assertThat(result).isNotNull(); + final List usernames = result.stream().map(ScimUser::getUserName).collect(toList()); + Assertions.assertThat(usernames).isSorted(); + return usernames; + }; + + // case 1: should return only user 1 + String filter = String.format("id eq '%s' or origin eq '%s'", created1.getId(), created2.getOrigin()); + List usernames = retrieveByScimFilter.apply(filter); + Assertions.assertThat(usernames) + .hasSize(1) + .contains(created1.getUserName()); + + // case 2: should return empty list + filter = String.format("origin eq '%s'", created2.getOrigin()); + usernames = retrieveByScimFilter.apply(filter); + Assertions.assertThat(usernames).isEmpty(); + + // case 3: should return empty list (filtered by origin and ID) + filter = String.format("origin eq '%s' and id eq '%s'", created2.getOrigin(), created2.getId()); + usernames = retrieveByScimFilter.apply(filter); + Assertions.assertThat(usernames).isEmpty(); + } + + @Test + void retrieveByScimFilter_IncludeInactive() { + final String originActive = randomString(); + addIdentityProvider(jdbcTemplate, currentIdentityZoneId, originActive, true); + + final String originInactive = randomString(); + addIdentityProvider(jdbcTemplate, currentIdentityZoneId, originInactive, false); + + final ScimUser user1 = new ScimUser(null, "jo@foo.com", "Jo", "User"); + user1.addEmail("jo@blah.com"); + user1.setOrigin(originActive); + final ScimUser created1 = jdbcScimUserProvisioning.createUser(user1, "j7hyqpassX", currentIdentityZoneId); + + final ScimUser user2 = new ScimUser(null, "jo2@foo.com", "Jo", "User"); + user2.addEmail("jo2@blah.com"); + user2.setOrigin(originInactive); + final ScimUser created2 = jdbcScimUserProvisioning.createUser(user2, "j7hyqpassX", currentIdentityZoneId); + + final Function> retrieveByScimFilter = (scimFilter) -> { + final List result = jdbcScimUserProvisioning.query( + scimFilter, + "userName", + true, + currentIdentityZoneId + ); + Assertions.assertThat(result).isNotNull(); + final List usernames = result.stream().map(ScimUser::getUserName).collect(toList()); + Assertions.assertThat(usernames).isSorted(); + return usernames; + }; + + // case 1: should return both + String filter = String.format("id eq '%s' or origin eq '%s'", created1.getId(), created2.getOrigin()); + List usernames = retrieveByScimFilter.apply(filter); + Assertions.assertThat(usernames) + .hasSize(2) + .contains(created1.getUserName(), created2.getUserName()); + + // case 2: should return user 2 + filter = String.format("origin eq '%s'", created2.getOrigin()); + usernames = retrieveByScimFilter.apply(filter); + Assertions.assertThat(usernames) + .hasSize(1) + .contains(created2.getUserName()); + + // case 3: should return user 2 (filtered by origin and ID) + filter = String.format("origin eq '%s' and id eq '%s'", created2.getOrigin(), created2.getId()); + usernames = retrieveByScimFilter.apply(filter); + Assertions.assertThat(usernames) + .hasSize(1) + .contains(created2.getUserName()); + } + @Test void canDeleteProviderUsersInOtherZone() { ScimUser user = new ScimUser(null, "jo@foo.com", "Jo", "User"); @@ -1389,7 +1494,11 @@ private static void addMembership(final JdbcTemplate jdbcTemplate, } private static void addIdentityProvider(JdbcTemplate jdbcTemplate, String idzId, String originKey) { - jdbcTemplate.update("insert into identity_provider (id,identity_zone_id,name,origin_key,type) values (?,?,?,?,'UNKNOWN')", UUID.randomUUID().toString(), idzId, originKey, originKey); + addIdentityProvider(jdbcTemplate, idzId, originKey, true); + } + + private static void addIdentityProvider(JdbcTemplate jdbcTemplate, String idzId, String originKey, boolean active) { + jdbcTemplate.update("insert into identity_provider (id,identity_zone_id,name,origin_key,type,active) values (?,?,?,?,'UNKNOWN',?)", UUID.randomUUID().toString(), idzId, originKey, originKey, active); } private String randomString() { diff --git a/uaa/slate/Gemfile.lock b/uaa/slate/Gemfile.lock index 18da0c5aa1d..b69cc8a2369 100644 --- a/uaa/slate/Gemfile.lock +++ b/uaa/slate/Gemfile.lock @@ -97,7 +97,7 @@ GEM parslet (2.0.0) public_suffix (5.0.4) racc (1.7.3) - rack (2.2.8) + rack (2.2.8.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 2a0d77a5a47..00c624b9bc1 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -378,6 +378,39 @@ _Response Fields_ Added in UAA 3.3.0 +Both access and refresh tokens can be passed to the ``/revoke`` endpoint. + +When the ``/revoke`` endpoint is successfully invoked with an access token, and then when the same token is +passed to the UAA Introspect Token endpoint (``/introspect``), the UAA Introspect Token endpoint +will respond with ``"active": false``. + +If the access token is in the JWT format (as opposed to the opaque format), the server config ``uaa.jwt.revocable`` or +the Identity Zone config ``config.tokenPolicy.jwtRevocable`` must be set to ``true`` for +the revocation to work. However, OAuth resource servers are not required to call the UAA Introspect +Token endpoint to validate the token. Once issued, from a security point of view, a valid access token +in the JWT format should be considered valid until its expiry. Hence, we do not recommend +relying on this endpoint to revoke access tokens in the JWT format. If the ability +to remove/limit access after the tokens are issued is important to you, we recommend the following instead: + +* Ask the OAuth client to use opaque tokens only, so that the OAuth resource server is required to use +the UAA Introspect Token endpoint to validate that the tokens have not been revoked. +* If the access tokens are in the JWT format, configure the access tokens to be short-lived +(e.g. a few minutes), and when needed, revoke the more long-lived refresh tokens so that they +may no longer be used to obtain refreshed access tokens. + +When the ``/revoke`` endpoint is successfully invoked with a refresh token, +the refresh token can no longer be used to perform the Refresh Token grant. + +Refresh tokens in any format can be revoked using the "Revoke all tokens for a user" endpoint (``/oauth/token/revoke/user/{userId}``), +the "Revoke all tokens for a client" endpoint (``/oauth/token/revoke/client/{clientId}``), or +the "Revoke all tokens for a user and client combination" endpoint (``/oauth/token/revoke/user/{userId}/client/{clientId}``). + +Refresh tokens in the opaque format can be individually revoked using +the "Revoke a single token" endpoint (``/oauth/token/revoke/{tokenId}``). +However, refresh tokens in the JWT format can only be individually revoked using +the "Revoke a single token" endpoint when the server config ``uaa.jwt.revocable`` or +the Identity Zone config ``config.tokenPolicy.jwtRevocable`` is set to ``true``. + ### Revoke all tokens for a user <%= render('TokenEndpointDocs/revokeAllTokens_forAUser/curl-request.md') %> diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/HealthzEndpointIntegrationTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/HealthzEndpointIntegrationTests.java index 86c55dfe07e..d339b066869 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/HealthzEndpointIntegrationTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/HealthzEndpointIntegrationTests.java @@ -42,7 +42,6 @@ public void testHappyDay() { String body = response.getBody(); assertTrue(body.contains("ok")); - } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index 65bc30912f3..2989bc2ac54 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -104,7 +104,7 @@ class TokenEndpointDocs extends AbstractTokenMockMvcTests { private final ParameterDescriptor clientIdParameter = parameterWithName(CLIENT_ID).optional(null).type(STRING).description("A unique string representing the registration information provided by the client, the recipient of the token. Optional if it is passed as part of the Basic Authorization header or as part of the client_assertion."); private final ParameterDescriptor clientSecretParameter = parameterWithName("client_secret").optional(null).type(STRING).description("The [secret passphrase configured](#change-secret) for the OAuth client. Optional if it is passed as part of the Basic Authorization header or if client_assertion is sent as part of private_key_jwt authentication."); - private final ParameterDescriptor opaqueFormatParameter = parameterWithName(REQUEST_TOKEN_FORMAT).optional(null).type(STRING).description("Can be set to `" + OPAQUE.getStringValue() + "` to retrieve an opaque and revocable token or to `" + JWT.getStringValue() + "` to retrieve a JWT token. If not set the zone setting config.tokenPolicy.jwtRevocable is used."); + private final ParameterDescriptor opaqueFormatParameter = parameterWithName(REQUEST_TOKEN_FORMAT).optional(null).type(STRING).description("Can be set to `" + OPAQUE.getStringValue() + "` to retrieve an opaque token or to `" + JWT.getStringValue() + "` to retrieve a JWT token. Please refer to the Revoke Tokens endpoint doc for information about the revocability of opaque vs. jwt tokens. If not set the zone setting config.tokenPolicy.jwtRevocable is used."); private final ParameterDescriptor scopeParameter = parameterWithName(SCOPE).optional(null).type(STRING).description("The list of scopes requested for the token. Use when you wish to reduce the number of scopes the token will have."); private final ParameterDescriptor loginHintParameter = parameterWithName("login_hint").optional(null).type(STRING).description("UAA 75.5.0 Indicates the identity provider to be used. The passed string has to be a URL-Encoded JSON Object, containing the field `origin` with value as `origin_key` of an identity provider. Note that this identity provider must support the grant type `password`."); private final ParameterDescriptor codeVerifier = parameterWithName(PkceValidationService.CODE_VERIFIER).description("UAA 75.5.0 [PKCE](https://tools.ietf.org/html/rfc7636) Code Verifier. A `code_verifier` parameter must be provided if a `code_challenge` parameter was present in the previous call to `/oauth/authorize`. The `code_verifier` must match the used `code_challenge` (according to the selected `code_challenge_method`)").attributes(key("constraints").value("Optional"), key("type").value(STRING));