Commit 1ed624d30bf67c7f893e21bb1d643e480b43e551

Authored by Igor Kulikov
1 parent dbc3ef95

Introduce mobile app oauth2 request authentication using application token signe…

…d by application secret.
Showing 23 changed files with 226 additions and 84 deletions
@@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile ( @@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile (
136 oauth2_params_id uuid NOT NULL, 136 oauth2_params_id uuid NOT NULL,
137 created_time bigint NOT NULL, 137 created_time bigint NOT NULL,
138 pkg_name varchar(255), 138 pkg_name varchar(255),
139 - callback_url_scheme varchar(255), 139 + app_secret varchar(2048),
140 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE, 140 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
141 CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name) 141 CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
142 ); 142 );
@@ -39,6 +39,7 @@ import org.springframework.web.util.UriComponentsBuilder; @@ -39,6 +39,7 @@ import org.springframework.web.util.UriComponentsBuilder;
39 import org.thingsboard.server.dao.oauth2.OAuth2Configuration; 39 import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
40 import org.thingsboard.server.dao.oauth2.OAuth2Service; 40 import org.thingsboard.server.dao.oauth2.OAuth2Service;
41 import org.thingsboard.server.service.security.auth.oauth2.TbOAuth2ParameterNames; 41 import org.thingsboard.server.service.security.auth.oauth2.TbOAuth2ParameterNames;
  42 +import org.thingsboard.server.service.security.model.token.OAuth2AppTokenFactory;
42 import org.thingsboard.server.utils.MiscUtils; 43 import org.thingsboard.server.utils.MiscUtils;
43 44
44 import javax.servlet.http.HttpServletRequest; 45 import javax.servlet.http.HttpServletRequest;
@@ -69,6 +70,9 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza @@ -69,6 +70,9 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
69 @Autowired 70 @Autowired
70 private OAuth2Service oAuth2Service; 71 private OAuth2Service oAuth2Service;
71 72
  73 + @Autowired
  74 + private OAuth2AppTokenFactory oAuth2AppTokenFactory;
  75 +
72 @Autowired(required = false) 76 @Autowired(required = false)
73 private OAuth2Configuration oauth2Configuration; 77 private OAuth2Configuration oauth2Configuration;
74 78
@@ -78,7 +82,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza @@ -78,7 +82,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
78 String registrationId = this.resolveRegistrationId(request); 82 String registrationId = this.resolveRegistrationId(request);
79 String redirectUriAction = getAction(request, "login"); 83 String redirectUriAction = getAction(request, "login");
80 String appPackage = getAppPackage(request); 84 String appPackage = getAppPackage(request);
81 - return resolve(request, registrationId, redirectUriAction, appPackage); 85 + String appToken = getAppToken(request);
  86 + return resolve(request, registrationId, redirectUriAction, appPackage, appToken);
82 } 87 }
83 88
84 @Override 89 @Override
@@ -88,7 +93,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza @@ -88,7 +93,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
88 } 93 }
89 String redirectUriAction = getAction(request, "authorize"); 94 String redirectUriAction = getAction(request, "authorize");
90 String appPackage = getAppPackage(request); 95 String appPackage = getAppPackage(request);
91 - return resolve(request, registrationId, redirectUriAction, appPackage); 96 + String appToken = getAppToken(request);
  97 + return resolve(request, registrationId, redirectUriAction, appPackage, appToken);
92 } 98 }
93 99
94 private String getAction(HttpServletRequest request, String defaultAction) { 100 private String getAction(HttpServletRequest request, String defaultAction) {
@@ -103,8 +109,12 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza @@ -103,8 +109,12 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
103 return request.getParameter("pkg"); 109 return request.getParameter("pkg");
104 } 110 }
105 111
  112 + private String getAppToken(HttpServletRequest request) {
  113 + return request.getParameter("appToken");
  114 + }
  115 +
106 @SuppressWarnings("deprecation") 116 @SuppressWarnings("deprecation")
107 - private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction, String appPackage) { 117 + private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction, String appPackage, String appToken) {
108 if (registrationId == null) { 118 if (registrationId == null) {
109 return null; 119 return null;
110 } 120 }
@@ -117,10 +127,14 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza @@ -117,10 +127,14 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
117 Map<String, Object> attributes = new HashMap<>(); 127 Map<String, Object> attributes = new HashMap<>();
118 attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId()); 128 attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId());
119 if (!StringUtils.isEmpty(appPackage)) { 129 if (!StringUtils.isEmpty(appPackage)) {
120 - String callbackUrlScheme = this.oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), appPackage);  
121 - if (StringUtils.isEmpty(callbackUrlScheme)) {  
122 - throw new IllegalArgumentException("Invalid package: " + appPackage + ". No package info found for Client Registration."); 130 + if (StringUtils.isEmpty(appToken)) {
  131 + throw new IllegalArgumentException("Invalid application token.");
123 } else { 132 } else {
  133 + String appSecret = this.oAuth2Service.findAppSecret(UUID.fromString(registrationId), appPackage);
  134 + if (StringUtils.isEmpty(appSecret)) {
  135 + throw new IllegalArgumentException("Invalid package: " + appPackage + ". No application secret found for Client Registration with given application package.");
  136 + }
  137 + String callbackUrlScheme = this.oAuth2AppTokenFactory.validateTokenAndGetCallbackUrlScheme(appPackage, appToken, appSecret);
124 attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme); 138 attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme);
125 } 139 }
126 } 140 }
  1 +/**
  2 + * Copyright © 2016-2021 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.service.security.model.token;
  17 +
  18 +import io.jsonwebtoken.Claims;
  19 +import io.jsonwebtoken.ExpiredJwtException;
  20 +import io.jsonwebtoken.Jws;
  21 +import io.jsonwebtoken.Jwts;
  22 +import io.jsonwebtoken.MalformedJwtException;
  23 +import io.jsonwebtoken.SignatureException;
  24 +import io.jsonwebtoken.UnsupportedJwtException;
  25 +import io.micrometer.core.instrument.util.StringUtils;
  26 +import lombok.extern.slf4j.Slf4j;
  27 +import org.springframework.stereotype.Component;
  28 +
  29 +import java.util.Date;
  30 +import java.util.concurrent.TimeUnit;
  31 +
  32 +@Component
  33 +@Slf4j
  34 +public class OAuth2AppTokenFactory {
  35 +
  36 + private static final String CALLBACK_URL_SCHEME = "callbackUrlScheme";
  37 +
  38 + private static final long MAX_EXPIRATION_TIME_DIFF_MS = TimeUnit.MINUTES.toMillis(5);
  39 +
  40 + public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) {
  41 + Jws<Claims> jwsClaims;
  42 + try {
  43 + jwsClaims = Jwts.parser().setSigningKey(appSecret).parseClaimsJws(appToken);
  44 + }
  45 + catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) {
  46 + throw new IllegalArgumentException("Invalid Application token: ", ex);
  47 + } catch (ExpiredJwtException expiredEx) {
  48 + throw new IllegalArgumentException("Application token expired", expiredEx);
  49 + }
  50 + Claims claims = jwsClaims.getBody();
  51 + Date expiration = claims.getExpiration();
  52 + if (expiration == null) {
  53 + throw new IllegalArgumentException("Application token must have expiration date");
  54 + }
  55 + long timeDiff = expiration.getTime() - System.currentTimeMillis();
  56 + if (timeDiff > MAX_EXPIRATION_TIME_DIFF_MS) {
  57 + throw new IllegalArgumentException("Application token expiration time can't be longer than 5 minutes");
  58 + }
  59 + if (!claims.getIssuer().equals(appPackage)) {
  60 + throw new IllegalArgumentException("Application token issuer doesn't match application package");
  61 + }
  62 + String callbackUrlScheme = claims.get(CALLBACK_URL_SCHEME, String.class);
  63 + if (StringUtils.isEmpty(callbackUrlScheme)) {
  64 + throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme");
  65 + }
  66 + return callbackUrlScheme;
  67 + }
  68 +
  69 +}
@@ -42,5 +42,5 @@ public interface OAuth2Service { @@ -42,5 +42,5 @@ public interface OAuth2Service {
42 42
43 List<OAuth2Registration> findAllRegistrations(); 43 List<OAuth2Registration> findAllRegistrations();
44 44
45 - String findCallbackUrlScheme(UUID registrationId, String pkgName); 45 + String findAppSecret(UUID registrationId, String pkgName);
46 } 46 }
@@ -31,12 +31,12 @@ public class OAuth2Mobile extends BaseData<OAuth2MobileId> { @@ -31,12 +31,12 @@ public class OAuth2Mobile extends BaseData<OAuth2MobileId> {
31 31
32 private OAuth2ParamsId oauth2ParamsId; 32 private OAuth2ParamsId oauth2ParamsId;
33 private String pkgName; 33 private String pkgName;
34 - private String callbackUrlScheme; 34 + private String appSecret;
35 35
36 public OAuth2Mobile(OAuth2Mobile mobile) { 36 public OAuth2Mobile(OAuth2Mobile mobile) {
37 super(mobile); 37 super(mobile);
38 this.oauth2ParamsId = mobile.oauth2ParamsId; 38 this.oauth2ParamsId = mobile.oauth2ParamsId;
39 this.pkgName = mobile.pkgName; 39 this.pkgName = mobile.pkgName;
40 - this.callbackUrlScheme = mobile.callbackUrlScheme; 40 + this.appSecret = mobile.appSecret;
41 } 41 }
42 } 42 }
@@ -30,5 +30,5 @@ import lombok.ToString; @@ -30,5 +30,5 @@ import lombok.ToString;
30 @Builder 30 @Builder
31 public class OAuth2MobileInfo { 31 public class OAuth2MobileInfo {
32 private String pkgName; 32 private String pkgName;
33 - private String callbackUrlScheme; 33 + private String appSecret;
34 } 34 }
@@ -418,7 +418,7 @@ public class ModelConstants { @@ -418,7 +418,7 @@ public class ModelConstants {
418 public static final String OAUTH2_MOBILE_COLUMN_FAMILY_NAME = "oauth2_mobile"; 418 public static final String OAUTH2_MOBILE_COLUMN_FAMILY_NAME = "oauth2_mobile";
419 public static final String OAUTH2_PARAMS_ID_PROPERTY = "oauth2_params_id"; 419 public static final String OAUTH2_PARAMS_ID_PROPERTY = "oauth2_params_id";
420 public static final String OAUTH2_PKG_NAME_PROPERTY = "pkg_name"; 420 public static final String OAUTH2_PKG_NAME_PROPERTY = "pkg_name";
421 - public static final String OAUTH2_CALLBACK_URL_SCHEME_PROPERTY = "callback_url_scheme"; 421 + public static final String OAUTH2_APP_SECRET_PROPERTY = "app_secret";
422 422
423 public static final String OAUTH2_CLIENT_REGISTRATION_INFO_COLUMN_FAMILY_NAME = "oauth2_client_registration_info"; 423 public static final String OAUTH2_CLIENT_REGISTRATION_INFO_COLUMN_FAMILY_NAME = "oauth2_client_registration_info";
424 public static final String OAUTH2_CLIENT_REGISTRATION_COLUMN_FAMILY_NAME = "oauth2_client_registration"; 424 public static final String OAUTH2_CLIENT_REGISTRATION_COLUMN_FAMILY_NAME = "oauth2_client_registration";
@@ -40,8 +40,8 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> { @@ -40,8 +40,8 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
40 @Column(name = ModelConstants.OAUTH2_PKG_NAME_PROPERTY) 40 @Column(name = ModelConstants.OAUTH2_PKG_NAME_PROPERTY)
41 private String pkgName; 41 private String pkgName;
42 42
43 - @Column(name = ModelConstants.OAUTH2_CALLBACK_URL_SCHEME_PROPERTY)  
44 - private String callbackUrlScheme; 43 + @Column(name = ModelConstants.OAUTH2_APP_SECRET_PROPERTY)
  44 + private String appSecret;
45 45
46 public OAuth2MobileEntity() { 46 public OAuth2MobileEntity() {
47 super(); 47 super();
@@ -56,7 +56,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> { @@ -56,7 +56,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
56 this.oauth2ParamsId = mobile.getOauth2ParamsId().getId(); 56 this.oauth2ParamsId = mobile.getOauth2ParamsId().getId();
57 } 57 }
58 this.pkgName = mobile.getPkgName(); 58 this.pkgName = mobile.getPkgName();
59 - this.callbackUrlScheme = mobile.getCallbackUrlScheme(); 59 + this.appSecret = mobile.getAppSecret();
60 } 60 }
61 61
62 @Override 62 @Override
@@ -66,7 +66,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> { @@ -66,7 +66,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
66 mobile.setCreatedTime(createdTime); 66 mobile.setCreatedTime(createdTime);
67 mobile.setOauth2ParamsId(new OAuth2ParamsId(oauth2ParamsId)); 67 mobile.setOauth2ParamsId(new OAuth2ParamsId(oauth2ParamsId));
68 mobile.setPkgName(pkgName); 68 mobile.setPkgName(pkgName);
69 - mobile.setCallbackUrlScheme(callbackUrlScheme); 69 + mobile.setAppSecret(appSecret);
70 return mobile; 70 return mobile;
71 } 71 }
72 } 72 }
@@ -29,6 +29,6 @@ public interface OAuth2RegistrationDao extends Dao<OAuth2Registration> { @@ -29,6 +29,6 @@ public interface OAuth2RegistrationDao extends Dao<OAuth2Registration> {
29 29
30 List<OAuth2Registration> findByOAuth2ParamsId(UUID oauth2ParamsId); 30 List<OAuth2Registration> findByOAuth2ParamsId(UUID oauth2ParamsId);
31 31
32 - String findCallbackUrlScheme(UUID id, String pkgName); 32 + String findAppSecret(UUID id, String pkgName);
33 33
34 } 34 }
@@ -21,7 +21,23 @@ import org.springframework.stereotype.Service; @@ -21,7 +21,23 @@ import org.springframework.stereotype.Service;
21 import org.springframework.util.StringUtils; 21 import org.springframework.util.StringUtils;
22 import org.thingsboard.server.common.data.BaseData; 22 import org.thingsboard.server.common.data.BaseData;
23 import org.thingsboard.server.common.data.id.TenantId; 23 import org.thingsboard.server.common.data.id.TenantId;
24 -import org.thingsboard.server.common.data.oauth2.*; 24 +import org.thingsboard.server.common.data.oauth2.MapperType;
  25 +import org.thingsboard.server.common.data.oauth2.OAuth2BasicMapperConfig;
  26 +import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo;
  27 +import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig;
  28 +import org.thingsboard.server.common.data.oauth2.OAuth2Domain;
  29 +import org.thingsboard.server.common.data.oauth2.OAuth2DomainInfo;
  30 +import org.thingsboard.server.common.data.oauth2.OAuth2Info;
  31 +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig;
  32 +import org.thingsboard.server.common.data.oauth2.OAuth2Mobile;
  33 +import org.thingsboard.server.common.data.oauth2.OAuth2MobileInfo;
  34 +import org.thingsboard.server.common.data.oauth2.OAuth2Params;
  35 +import org.thingsboard.server.common.data.oauth2.OAuth2ParamsInfo;
  36 +import org.thingsboard.server.common.data.oauth2.OAuth2Registration;
  37 +import org.thingsboard.server.common.data.oauth2.OAuth2RegistrationInfo;
  38 +import org.thingsboard.server.common.data.oauth2.PlatformType;
  39 +import org.thingsboard.server.common.data.oauth2.SchemeType;
  40 +import org.thingsboard.server.common.data.oauth2.TenantNameStrategyType;
25 import org.thingsboard.server.common.data.oauth2.deprecated.ClientRegistrationDto; 41 import org.thingsboard.server.common.data.oauth2.deprecated.ClientRegistrationDto;
26 import org.thingsboard.server.common.data.oauth2.deprecated.DomainInfo; 42 import org.thingsboard.server.common.data.oauth2.deprecated.DomainInfo;
27 import org.thingsboard.server.common.data.oauth2.deprecated.ExtendedOAuth2ClientRegistrationInfo; 43 import org.thingsboard.server.common.data.oauth2.deprecated.ExtendedOAuth2ClientRegistrationInfo;
@@ -36,7 +52,11 @@ import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationDao; @@ -36,7 +52,11 @@ import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationDao;
36 import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationInfoDao; 52 import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationInfoDao;
37 53
38 import javax.transaction.Transactional; 54 import javax.transaction.Transactional;
39 -import java.util.*; 55 +import java.util.ArrayList;
  56 +import java.util.Arrays;
  57 +import java.util.Comparator;
  58 +import java.util.List;
  59 +import java.util.UUID;
40 import java.util.function.Consumer; 60 import java.util.function.Consumer;
41 import java.util.stream.Collectors; 61 import java.util.stream.Collectors;
42 62
@@ -164,11 +184,11 @@ public class OAuth2ServiceImpl extends AbstractEntityService implements OAuth2Se @@ -164,11 +184,11 @@ public class OAuth2ServiceImpl extends AbstractEntityService implements OAuth2Se
164 } 184 }
165 185
166 @Override 186 @Override
167 - public String findCallbackUrlScheme(UUID id, String pkgName) {  
168 - log.trace("Executing findCallbackUrlScheme [{}][{}]", id, pkgName); 187 + public String findAppSecret(UUID id, String pkgName) {
  188 + log.trace("Executing findAppSecret [{}][{}]", id, pkgName);
169 validateId(id, INCORRECT_CLIENT_REGISTRATION_ID + id); 189 validateId(id, INCORRECT_CLIENT_REGISTRATION_ID + id);
170 validateString(pkgName, "Incorrect package name"); 190 validateString(pkgName, "Incorrect package name");
171 - return oauth2RegistrationDao.findCallbackUrlScheme(id, pkgName); 191 + return oauth2RegistrationDao.findAppSecret(id, pkgName);
172 } 192 }
173 193
174 194
@@ -323,8 +343,11 @@ public class OAuth2ServiceImpl extends AbstractEntityService implements OAuth2Se @@ -323,8 +343,11 @@ public class OAuth2ServiceImpl extends AbstractEntityService implements OAuth2Se
323 if (StringUtils.isEmpty(mobileInfo.getPkgName())) { 343 if (StringUtils.isEmpty(mobileInfo.getPkgName())) {
324 throw new DataValidationException("Package should be specified!"); 344 throw new DataValidationException("Package should be specified!");
325 } 345 }
326 - if (StringUtils.isEmpty(mobileInfo.getCallbackUrlScheme())) {  
327 - throw new DataValidationException("Callback URL scheme should be specified!"); 346 + if (StringUtils.isEmpty(mobileInfo.getAppSecret())) {
  347 + throw new DataValidationException("Application secret should be specified!");
  348 + }
  349 + if (mobileInfo.getAppSecret().length() < 16) {
  350 + throw new DataValidationException("Application secret should be at least 16 characters!");
328 } 351 }
329 } 352 }
330 oauth2Params.getMobileInfos().stream() 353 oauth2Params.getMobileInfos().stream()
@@ -148,7 +148,7 @@ public class OAuth2Utils { @@ -148,7 +148,7 @@ public class OAuth2Utils {
148 public static OAuth2MobileInfo toOAuth2MobileInfo(OAuth2Mobile mobile) { 148 public static OAuth2MobileInfo toOAuth2MobileInfo(OAuth2Mobile mobile) {
149 return OAuth2MobileInfo.builder() 149 return OAuth2MobileInfo.builder()
150 .pkgName(mobile.getPkgName()) 150 .pkgName(mobile.getPkgName())
151 - .callbackUrlScheme(mobile.getCallbackUrlScheme()) 151 + .appSecret(mobile.getAppSecret())
152 .build(); 152 .build();
153 } 153 }
154 154
@@ -191,7 +191,7 @@ public class OAuth2Utils { @@ -191,7 +191,7 @@ public class OAuth2Utils {
191 OAuth2Mobile mobile = new OAuth2Mobile(); 191 OAuth2Mobile mobile = new OAuth2Mobile();
192 mobile.setOauth2ParamsId(oauth2ParamsId); 192 mobile.setOauth2ParamsId(oauth2ParamsId);
193 mobile.setPkgName(mobileInfo.getPkgName()); 193 mobile.setPkgName(mobileInfo.getPkgName());
194 - mobile.setCallbackUrlScheme(mobileInfo.getCallbackUrlScheme()); 194 + mobile.setAppSecret(mobileInfo.getAppSecret());
195 return mobile; 195 return mobile;
196 } 196 }
197 197
@@ -57,8 +57,8 @@ public class JpaOAuth2RegistrationDao extends JpaAbstractDao<OAuth2RegistrationE @@ -57,8 +57,8 @@ public class JpaOAuth2RegistrationDao extends JpaAbstractDao<OAuth2RegistrationE
57 } 57 }
58 58
59 @Override 59 @Override
60 - public String findCallbackUrlScheme(UUID id, String pkgName) {  
61 - return repository.findCallbackUrlScheme(id, pkgName); 60 + public String findAppSecret(UUID id, String pkgName) {
  61 + return repository.findAppSecret(id, pkgName);
62 } 62 }
63 63
64 } 64 }
@@ -42,12 +42,12 @@ public interface OAuth2RegistrationRepository extends CrudRepository<OAuth2Regis @@ -42,12 +42,12 @@ public interface OAuth2RegistrationRepository extends CrudRepository<OAuth2Regis
42 42
43 List<OAuth2RegistrationEntity> findByOauth2ParamsId(UUID oauth2ParamsId); 43 List<OAuth2RegistrationEntity> findByOauth2ParamsId(UUID oauth2ParamsId);
44 44
45 - @Query("SELECT mobile.callbackUrlScheme " + 45 + @Query("SELECT mobile.appSecret " +
46 "FROM OAuth2MobileEntity mobile " + 46 "FROM OAuth2MobileEntity mobile " +
47 "LEFT JOIN OAuth2RegistrationEntity reg on mobile.oauth2ParamsId = reg.oauth2ParamsId " + 47 "LEFT JOIN OAuth2RegistrationEntity reg on mobile.oauth2ParamsId = reg.oauth2ParamsId " +
48 "WHERE reg.id = :registrationId " + 48 "WHERE reg.id = :registrationId " +
49 "AND mobile.pkgName = :pkgName") 49 "AND mobile.pkgName = :pkgName")
50 - String findCallbackUrlScheme(@Param("registrationId") UUID id,  
51 - @Param("pkgName") String pkgName); 50 + String findAppSecret(@Param("registrationId") UUID id,
  51 + @Param("pkgName") String pkgName);
52 52
53 } 53 }
@@ -431,7 +431,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile ( @@ -431,7 +431,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile (
431 oauth2_params_id uuid NOT NULL, 431 oauth2_params_id uuid NOT NULL,
432 created_time bigint NOT NULL, 432 created_time bigint NOT NULL,
433 pkg_name varchar(255), 433 pkg_name varchar(255),
434 - callback_url_scheme varchar(255), 434 + app_secret varchar(2048),
435 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE, 435 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
436 CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name) 436 CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
437 ); 437 );
@@ -468,7 +468,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile ( @@ -468,7 +468,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile (
468 oauth2_params_id uuid NOT NULL, 468 oauth2_params_id uuid NOT NULL,
469 created_time bigint NOT NULL, 469 created_time bigint NOT NULL,
470 pkg_name varchar(255), 470 pkg_name varchar(255),
471 - callback_url_scheme varchar(255), 471 + app_secret varchar(2048),
472 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE, 472 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
473 CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name) 473 CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
474 ); 474 );
@@ -15,8 +15,8 @@ @@ -15,8 +15,8 @@
15 */ 15 */
16 package org.thingsboard.server.dao.service; 16 package org.thingsboard.server.dao.service;
17 17
18 -import com.fasterxml.jackson.databind.node.ObjectNode;  
19 import com.google.common.collect.Lists; 18 import com.google.common.collect.Lists;
  19 +import org.apache.commons.lang3.RandomStringUtils;
20 import org.junit.After; 20 import org.junit.After;
21 import org.junit.Assert; 21 import org.junit.Assert;
22 import org.junit.Before; 22 import org.junit.Before;
@@ -487,7 +487,7 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest { @@ -487,7 +487,7 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
487 } 487 }
488 488
489 @Test 489 @Test
490 - public void testFindCallbackUrlScheme() { 490 + public void testFindAppSecret() {
491 OAuth2Info oAuth2Info = new OAuth2Info(true, Lists.newArrayList( 491 OAuth2Info oAuth2Info = new OAuth2Info(true, Lists.newArrayList(
492 OAuth2ParamsInfo.builder() 492 OAuth2ParamsInfo.builder()
493 .domainInfos(Lists.newArrayList( 493 .domainInfos(Lists.newArrayList(
@@ -496,8 +496,8 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest { @@ -496,8 +496,8 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
496 OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build() 496 OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
497 )) 497 ))
498 .mobileInfos(Lists.newArrayList( 498 .mobileInfos(Lists.newArrayList(
499 - OAuth2MobileInfo.builder().pkgName("com.test.pkg1").callbackUrlScheme("testPkg1Callback").build(),  
500 - OAuth2MobileInfo.builder().pkgName("com.test.pkg2").callbackUrlScheme("testPkg2Callback").build() 499 + validMobileInfo("com.test.pkg1", "testPkg1AppSecret"),
  500 + validMobileInfo("com.test.pkg2", "testPkg2AppSecret")
501 )) 501 ))
502 .clientRegistrations(Lists.newArrayList( 502 .clientRegistrations(Lists.newArrayList(
503 validRegistrationInfo(), 503 validRegistrationInfo(),
@@ -527,14 +527,14 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest { @@ -527,14 +527,14 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
527 for (OAuth2ClientInfo clientInfo : firstDomainHttpClients) { 527 for (OAuth2ClientInfo clientInfo : firstDomainHttpClients) {
528 String[] segments = clientInfo.getUrl().split("/"); 528 String[] segments = clientInfo.getUrl().split("/");
529 String registrationId = segments[segments.length-1]; 529 String registrationId = segments[segments.length-1];
530 - String callbackUrlScheme = oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), "com.test.pkg1");  
531 - Assert.assertNotNull(callbackUrlScheme);  
532 - Assert.assertEquals("testPkg1Callback", callbackUrlScheme);  
533 - callbackUrlScheme = oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), "com.test.pkg2");  
534 - Assert.assertNotNull(callbackUrlScheme);  
535 - Assert.assertEquals("testPkg2Callback", callbackUrlScheme);  
536 - callbackUrlScheme = oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), "com.test.pkg3");  
537 - Assert.assertNull(callbackUrlScheme); 530 + String appSecret = oAuth2Service.findAppSecret(UUID.fromString(registrationId), "com.test.pkg1");
  531 + Assert.assertNotNull(appSecret);
  532 + Assert.assertEquals("testPkg1AppSecret", appSecret);
  533 + appSecret = oAuth2Service.findAppSecret(UUID.fromString(registrationId), "com.test.pkg2");
  534 + Assert.assertNotNull(appSecret);
  535 + Assert.assertEquals("testPkg2AppSecret", appSecret);
  536 + appSecret = oAuth2Service.findAppSecret(UUID.fromString(registrationId), "com.test.pkg3");
  537 + Assert.assertNull(appSecret);
538 } 538 }
539 } 539 }
540 540
@@ -548,8 +548,8 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest { @@ -548,8 +548,8 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
548 OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build() 548 OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
549 )) 549 ))
550 .mobileInfos(Lists.newArrayList( 550 .mobileInfos(Lists.newArrayList(
551 - OAuth2MobileInfo.builder().pkgName("com.test.pkg1").callbackUrlScheme("testPkg1Callback").build(),  
552 - OAuth2MobileInfo.builder().pkgName("com.test.pkg2").callbackUrlScheme("testPkg2Callback").build() 551 + validMobileInfo("com.test.pkg1", "testPkg1Callback"),
  552 + validMobileInfo("com.test.pkg2", "testPkg2Callback")
553 )) 553 ))
554 .clientRegistrations(Lists.newArrayList( 554 .clientRegistrations(Lists.newArrayList(
555 validRegistrationInfo("Google", Arrays.asList(PlatformType.WEB, PlatformType.ANDROID)), 555 validRegistrationInfo("Google", Arrays.asList(PlatformType.WEB, PlatformType.ANDROID)),
@@ -651,4 +651,10 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest { @@ -651,4 +651,10 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
651 ) 651 )
652 .build(); 652 .build();
653 } 653 }
  654 +
  655 + private OAuth2MobileInfo validMobileInfo(String pkgName, String appSecret) {
  656 + return OAuth2MobileInfo.builder().pkgName(pkgName)
  657 + .appSecret(appSecret != null ? appSecret : RandomStringUtils.randomAlphanumeric(24))
  658 + .build();
  659 + }
654 } 660 }
@@ -445,3 +445,14 @@ export function validateEntityId(entityId: EntityId | null): boolean { @@ -445,3 +445,14 @@ export function validateEntityId(entityId: EntityId | null): boolean {
445 export function isMobileApp(): boolean { 445 export function isMobileApp(): boolean {
446 return isDefined((window as any).flutter_inappwebview); 446 return isDefined((window as any).flutter_inappwebview);
447 } 447 }
  448 +
  449 +const alphanumericCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  450 +const alphanumericCharactersLength = alphanumericCharacters.length;
  451 +
  452 +export function randomAlphanumeric(length: number): string {
  453 + let result = '';
  454 + for ( let i = 0; i < length; i++ ) {
  455 + result += alphanumericCharacters.charAt(Math.floor(Math.random() * alphanumericCharactersLength));
  456 + }
  457 + return result;
  458 +}
@@ -90,23 +90,26 @@ @@ -90,23 +90,26 @@
90 <mat-form-field fxFlex class="mat-block"> 90 <mat-form-field fxFlex class="mat-block">
91 <mat-label translate>admin.oauth2.redirect-uri-template</mat-label> 91 <mat-label translate>admin.oauth2.redirect-uri-template</mat-label>
92 <input matInput [value]="redirectURI(domainInfo)" readonly> 92 <input matInput [value]="redirectURI(domainInfo)" readonly>
93 - <button mat-icon-button color="primary" matSuffix type="button"  
94 - ngxClipboard cbContent="{{ redirectURI(domainInfo) }}"  
95 - matTooltip="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"  
96 - matTooltipPosition="above">  
97 - <mat-icon class="material-icons" svgIcon="mdi:clipboard-arrow-left"></mat-icon>  
98 - </button> 93 + <tb-copy-button
  94 + matSuffix
  95 + color="primary"
  96 + [copyText]="redirectURI(domainInfo)"
  97 + tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
  98 + tooltipPosition="above"
  99 + mdiIcon="mdi:clipboard-arrow-left">
  100 + </tb-copy-button>
99 </mat-form-field> 101 </mat-form-field>
100 -  
101 <mat-form-field fxFlex *ngIf="domainInfo.get('scheme').value === 'MIXED'" class="mat-block"> 102 <mat-form-field fxFlex *ngIf="domainInfo.get('scheme').value === 'MIXED'" class="mat-block">
102 <mat-label></mat-label> 103 <mat-label></mat-label>
103 <input matInput [value]="redirectURIMixed(domainInfo)" readonly> 104 <input matInput [value]="redirectURIMixed(domainInfo)" readonly>
104 - <button mat-icon-button color="primary" matSuffix type="button"  
105 - ngxClipboard cbContent="{{ redirectURIMixed(domainInfo) }}"  
106 - matTooltip="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"  
107 - matTooltipPosition="above">  
108 - <mat-icon class="material-icons" svgIcon="mdi:clipboard-arrow-left"></mat-icon>  
109 - </button> 105 + <tb-copy-button
  106 + matSuffix
  107 + color="primary"
  108 + [copyText]="redirectURIMixed(domainInfo)"
  109 + tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
  110 + tooltipPosition="above"
  111 + mdiIcon="mdi:clipboard-arrow-left">
  112 + </tb-copy-button>
110 </mat-form-field> 113 </mat-form-field>
111 </div> 114 </div>
112 </div> 115 </div>
@@ -144,18 +147,32 @@ @@ -144,18 +147,32 @@
144 <div [formGroupName]="n" fxLayout="row" fxLayoutGap="8px"> 147 <div [formGroupName]="n" fxLayout="row" fxLayoutGap="8px">
145 <div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px"> 148 <div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px">
146 <div fxFlex fxLayout="column"> 149 <div fxFlex fxLayout="column">
147 - <mat-form-field fxFlex class="mat-block"> 150 + <mat-form-field fxFlex class="mat-block" floatLabel="always">
148 <mat-label translate>admin.oauth2.mobile-package</mat-label> 151 <mat-label translate>admin.oauth2.mobile-package</mat-label>
149 - <input matInput formControlName="pkgName" required> 152 + <input matInput formControlName="pkgName" placeholder="{{ 'admin.oauth2.mobile-package-placeholder' | translate }}" required>
  153 + <mat-hint translate>admin.oauth2.mobile-package-hint</mat-hint>
150 </mat-form-field> 154 </mat-form-field>
151 <mat-error *ngIf="mobileInfo.hasError('unique')"> 155 <mat-error *ngIf="mobileInfo.hasError('unique')">
152 {{ 'admin.oauth2.mobile-package-unique' | translate }} 156 {{ 'admin.oauth2.mobile-package-unique' | translate }}
153 </mat-error> 157 </mat-error>
154 </div> 158 </div>
155 - <mat-form-field fxFlex class="mat-block">  
156 - <mat-label translate>admin.oauth2.mobile-callback-url-scheme</mat-label>  
157 - <input matInput formControlName="callbackUrlScheme" required>  
158 - </mat-form-field> 159 + <div fxFlex fxLayout="row">
  160 + <mat-form-field fxFlex class="mat-block">
  161 + <mat-label translate>admin.oauth2.mobile-app-secret</mat-label>
  162 + <textarea matInput formControlName="appSecret" rows="1" required></textarea>
  163 + <tb-copy-button
  164 + matSuffix
  165 + color="primary"
  166 + [copyText]="mobileInfo.get('appSecret').value"
  167 + tooltipText="{{ 'admin.oauth2.copy-mobile-app-secret' | translate }}"
  168 + tooltipPosition="above"
  169 + mdiIcon="mdi:clipboard-arrow-left">
  170 + </tb-copy-button>
  171 + <mat-error *ngIf="mobileInfo.get('appSecret').invalid">
  172 + {{ 'admin.oauth2.invalid-mobile-app-secret' | translate }}
  173 + </mat-error>
  174 + </mat-form-field>
  175 + </div>
159 </div> 176 </div>
160 <div fxLayout="column" fxLayoutAlign="center start"> 177 <div fxLayout="column" fxLayoutAlign="center start">
161 <button type="button" mat-icon-button color="primary" 178 <button type="button" mat-icon-button color="primary"
@@ -42,7 +42,7 @@ import { WINDOW } from '@core/services/window.service'; @@ -42,7 +42,7 @@ import { WINDOW } from '@core/services/window.service';
42 import { forkJoin, Subscription } from 'rxjs'; 42 import { forkJoin, Subscription } from 'rxjs';
43 import { DialogService } from '@core/services/dialog.service'; 43 import { DialogService } from '@core/services/dialog.service';
44 import { TranslateService } from '@ngx-translate/core'; 44 import { TranslateService } from '@ngx-translate/core';
45 -import { isDefined, isDefinedAndNotNull } from '@core/utils'; 45 +import { isDefined, isDefinedAndNotNull, randomAlphanumeric } from '@core/utils';
46 import { OAuth2Service } from '@core/http/oauth2.service'; 46 import { OAuth2Service } from '@core/http/oauth2.service';
47 import { ActivatedRoute } from '@angular/router'; 47 import { ActivatedRoute } from '@angular/router';
48 48
@@ -275,7 +275,8 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha @@ -275,7 +275,8 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha
275 private buildMobileInfoForm(mobileInfo?: OAuth2MobileInfo): FormGroup { 275 private buildMobileInfoForm(mobileInfo?: OAuth2MobileInfo): FormGroup {
276 return this.fb.group({ 276 return this.fb.group({
277 pkgName: [mobileInfo?.pkgName, [Validators.required]], 277 pkgName: [mobileInfo?.pkgName, [Validators.required]],
278 - callbackUrlScheme: [mobileInfo?.callbackUrlScheme, [Validators.required]], 278 + appSecret: [mobileInfo?.appSecret, [Validators.required, Validators.minLength(16), Validators.maxLength(2048),
  279 + Validators.pattern(/^[A-Za-z0-9]+$/)]],
279 }, {validators: this.uniquePkgNameValidator}); 280 }, {validators: this.uniquePkgNameValidator});
280 } 281 }
281 282
@@ -529,7 +530,7 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha @@ -529,7 +530,7 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha
529 addMobileInfo(control: AbstractControl): void { 530 addMobileInfo(control: AbstractControl): void {
530 this.mobileInfos(control).push(this.buildMobileInfoForm({ 531 this.mobileInfos(control).push(this.buildMobileInfoForm({
531 pkgName: '', 532 pkgName: '',
532 - callbackUrlScheme: '' 533 + appSecret: randomAlphanumeric(24)
533 })); 534 }));
534 } 535 }
535 536
@@ -16,11 +16,16 @@ @@ -16,11 +16,16 @@
16 16
17 --> 17 -->
18 <button mat-icon-button 18 <button mat-icon-button
  19 + type="button"
  20 + [color]="color"
19 [disabled]="disabled" 21 [disabled]="disabled"
20 [matTooltip]="matTooltipText" 22 [matTooltip]="matTooltipText"
21 [matTooltipPosition]="matTooltipPosition" 23 [matTooltipPosition]="matTooltipPosition"
22 (click)="copy($event)"> 24 (click)="copy($event)">
23 - <mat-icon [svgIcon]="mdiIconSymbol" [ngStyle]="style" [ngClass]="{'copied': copied}">  
24 - {{ iconSymbol }} 25 + <mat-icon [svgIcon]="mdiIcon" [ngStyle]="style" *ngIf="!copied; else copiedTemplate">
  26 + {{ icon }}
25 </mat-icon> 27 </mat-icon>
  28 + <ng-template #copiedTemplate>
  29 + <mat-icon [ngStyle]="style" class="copied">done</mat-icon>
  30 + </ng-template>
26 </button> 31 </button>
@@ -26,7 +26,6 @@ import { TranslateService } from '@ngx-translate/core'; @@ -26,7 +26,6 @@ import { TranslateService } from '@ngx-translate/core';
26 }) 26 })
27 export class CopyButtonComponent { 27 export class CopyButtonComponent {
28 28
29 - private copedIcon = '';  
30 private timer; 29 private timer;
31 30
32 copied = false; 31 copied = false;
@@ -52,6 +51,9 @@ export class CopyButtonComponent { @@ -52,6 +51,9 @@ export class CopyButtonComponent {
52 @Input() 51 @Input()
53 style: {[key: string]: any} = {}; 52 style: {[key: string]: any} = {};
54 53
  54 + @Input()
  55 + color: string;
  56 +
55 @Output() 57 @Output()
56 successCopied = new EventEmitter<string>(); 58 successCopied = new EventEmitter<string>();
57 59
@@ -67,23 +69,13 @@ export class CopyButtonComponent { @@ -67,23 +69,13 @@ export class CopyButtonComponent {
67 } 69 }
68 this.clipboardService.copy(this.copyText); 70 this.clipboardService.copy(this.copyText);
69 this.successCopied.emit(this.copyText); 71 this.successCopied.emit(this.copyText);
70 - this.copedIcon = 'done';  
71 this.copied = true; 72 this.copied = true;
72 this.timer = setTimeout(() => { 73 this.timer = setTimeout(() => {
73 - this.copedIcon = null;  
74 this.copied = false; 74 this.copied = false;
75 this.cd.detectChanges(); 75 this.cd.detectChanges();
76 }, 1500); 76 }, 1500);
77 } 77 }
78 78
79 - get iconSymbol(): string {  
80 - return this.copedIcon || this.icon;  
81 - }  
82 -  
83 - get mdiIconSymbol(): string {  
84 - return this.copedIcon ? '' : this.mdiIcon;  
85 - }  
86 -  
87 get matTooltipText(): string { 79 get matTooltipText(): string {
88 return this.copied ? this.translate.instant('ota-update.copied') : this.tooltipText; 80 return this.copied ? this.translate.instant('ota-update.copied') : this.tooltipText;
89 } 81 }
@@ -34,7 +34,7 @@ export interface OAuth2DomainInfo { @@ -34,7 +34,7 @@ export interface OAuth2DomainInfo {
34 34
35 export interface OAuth2MobileInfo { 35 export interface OAuth2MobileInfo {
36 pkgName: string; 36 pkgName: string;
37 - callbackUrlScheme: string; 37 + appSecret: string;
38 } 38 }
39 39
40 export enum DomainSchema{ 40 export enum DomainSchema{
@@ -224,8 +224,12 @@ @@ -224,8 +224,12 @@
224 "mobile-apps": "Mobile applications", 224 "mobile-apps": "Mobile applications",
225 "no-mobile-apps": "No applications configured", 225 "no-mobile-apps": "No applications configured",
226 "mobile-package": "Application package", 226 "mobile-package": "Application package",
  227 + "mobile-package-placeholder": "Ex.: my.example.app",
  228 + "mobile-package-hint": "For Android: your own unique Application ID. For iOS: Product bundle identifier.",
227 "mobile-package-unique": "Application package must be unique.", 229 "mobile-package-unique": "Application package must be unique.",
228 - "mobile-callback-url-scheme": "Callback URL scheme", 230 + "mobile-app-secret": "Application secret",
  231 + "invalid-mobile-app-secret": "Application secret must contain only alphanumeric characters and must be between 16 and 2048 characters long.",
  232 + "copy-mobile-app-secret": "Copy application secret",
229 "add-mobile-app": "Add application", 233 "add-mobile-app": "Add application",
230 "delete-mobile-app": "Delete application info", 234 "delete-mobile-app": "Delete application info",
231 "providers": "Providers", 235 "providers": "Providers",