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 136 oauth2_params_id uuid NOT NULL,
137 137 created_time bigint NOT NULL,
138 138 pkg_name varchar(255),
139   - callback_url_scheme varchar(255),
  139 + app_secret varchar(2048),
140 140 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
141 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 39 import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
40 40 import org.thingsboard.server.dao.oauth2.OAuth2Service;
41 41 import org.thingsboard.server.service.security.auth.oauth2.TbOAuth2ParameterNames;
  42 +import org.thingsboard.server.service.security.model.token.OAuth2AppTokenFactory;
42 43 import org.thingsboard.server.utils.MiscUtils;
43 44
44 45 import javax.servlet.http.HttpServletRequest;
... ... @@ -69,6 +70,9 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
69 70 @Autowired
70 71 private OAuth2Service oAuth2Service;
71 72
  73 + @Autowired
  74 + private OAuth2AppTokenFactory oAuth2AppTokenFactory;
  75 +
72 76 @Autowired(required = false)
73 77 private OAuth2Configuration oauth2Configuration;
74 78
... ... @@ -78,7 +82,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
78 82 String registrationId = this.resolveRegistrationId(request);
79 83 String redirectUriAction = getAction(request, "login");
80 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 89 @Override
... ... @@ -88,7 +93,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
88 93 }
89 94 String redirectUriAction = getAction(request, "authorize");
90 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 100 private String getAction(HttpServletRequest request, String defaultAction) {
... ... @@ -103,8 +109,12 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
103 109 return request.getParameter("pkg");
104 110 }
105 111
  112 + private String getAppToken(HttpServletRequest request) {
  113 + return request.getParameter("appToken");
  114 + }
  115 +
106 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 118 if (registrationId == null) {
109 119 return null;
110 120 }
... ... @@ -117,10 +127,14 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
117 127 Map<String, Object> attributes = new HashMap<>();
118 128 attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId());
119 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 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 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 42
43 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 31
32 32 private OAuth2ParamsId oauth2ParamsId;
33 33 private String pkgName;
34   - private String callbackUrlScheme;
  34 + private String appSecret;
35 35
36 36 public OAuth2Mobile(OAuth2Mobile mobile) {
37 37 super(mobile);
38 38 this.oauth2ParamsId = mobile.oauth2ParamsId;
39 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 30 @Builder
31 31 public class OAuth2MobileInfo {
32 32 private String pkgName;
33   - private String callbackUrlScheme;
  33 + private String appSecret;
34 34 }
... ...
... ... @@ -418,7 +418,7 @@ public class ModelConstants {
418 418 public static final String OAUTH2_MOBILE_COLUMN_FAMILY_NAME = "oauth2_mobile";
419 419 public static final String OAUTH2_PARAMS_ID_PROPERTY = "oauth2_params_id";
420 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 423 public static final String OAUTH2_CLIENT_REGISTRATION_INFO_COLUMN_FAMILY_NAME = "oauth2_client_registration_info";
424 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 40 @Column(name = ModelConstants.OAUTH2_PKG_NAME_PROPERTY)
41 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 46 public OAuth2MobileEntity() {
47 47 super();
... ... @@ -56,7 +56,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
56 56 this.oauth2ParamsId = mobile.getOauth2ParamsId().getId();
57 57 }
58 58 this.pkgName = mobile.getPkgName();
59   - this.callbackUrlScheme = mobile.getCallbackUrlScheme();
  59 + this.appSecret = mobile.getAppSecret();
60 60 }
61 61
62 62 @Override
... ... @@ -66,7 +66,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
66 66 mobile.setCreatedTime(createdTime);
67 67 mobile.setOauth2ParamsId(new OAuth2ParamsId(oauth2ParamsId));
68 68 mobile.setPkgName(pkgName);
69   - mobile.setCallbackUrlScheme(callbackUrlScheme);
  69 + mobile.setAppSecret(appSecret);
70 70 return mobile;
71 71 }
72 72 }
... ...
... ... @@ -29,6 +29,6 @@ public interface OAuth2RegistrationDao extends Dao<OAuth2Registration> {
29 29
30 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 21 import org.springframework.util.StringUtils;
22 22 import org.thingsboard.server.common.data.BaseData;
23 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 41 import org.thingsboard.server.common.data.oauth2.deprecated.ClientRegistrationDto;
26 42 import org.thingsboard.server.common.data.oauth2.deprecated.DomainInfo;
27 43 import org.thingsboard.server.common.data.oauth2.deprecated.ExtendedOAuth2ClientRegistrationInfo;
... ... @@ -36,7 +52,11 @@ import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationDao;
36 52 import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationInfoDao;
37 53
38 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 60 import java.util.function.Consumer;
41 61 import java.util.stream.Collectors;
42 62
... ... @@ -164,11 +184,11 @@ public class OAuth2ServiceImpl extends AbstractEntityService implements OAuth2Se
164 184 }
165 185
166 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 189 validateId(id, INCORRECT_CLIENT_REGISTRATION_ID + id);
170 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 343 if (StringUtils.isEmpty(mobileInfo.getPkgName())) {
324 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 353 oauth2Params.getMobileInfos().stream()
... ...
... ... @@ -148,7 +148,7 @@ public class OAuth2Utils {
148 148 public static OAuth2MobileInfo toOAuth2MobileInfo(OAuth2Mobile mobile) {
149 149 return OAuth2MobileInfo.builder()
150 150 .pkgName(mobile.getPkgName())
151   - .callbackUrlScheme(mobile.getCallbackUrlScheme())
  151 + .appSecret(mobile.getAppSecret())
152 152 .build();
153 153 }
154 154
... ... @@ -191,7 +191,7 @@ public class OAuth2Utils {
191 191 OAuth2Mobile mobile = new OAuth2Mobile();
192 192 mobile.setOauth2ParamsId(oauth2ParamsId);
193 193 mobile.setPkgName(mobileInfo.getPkgName());
194   - mobile.setCallbackUrlScheme(mobileInfo.getCallbackUrlScheme());
  194 + mobile.setAppSecret(mobileInfo.getAppSecret());
195 195 return mobile;
196 196 }
197 197
... ...
... ... @@ -57,8 +57,8 @@ public class JpaOAuth2RegistrationDao extends JpaAbstractDao<OAuth2RegistrationE
57 57 }
58 58
59 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 42
43 43 List<OAuth2RegistrationEntity> findByOauth2ParamsId(UUID oauth2ParamsId);
44 44
45   - @Query("SELECT mobile.callbackUrlScheme " +
  45 + @Query("SELECT mobile.appSecret " +
46 46 "FROM OAuth2MobileEntity mobile " +
47 47 "LEFT JOIN OAuth2RegistrationEntity reg on mobile.oauth2ParamsId = reg.oauth2ParamsId " +
48 48 "WHERE reg.id = :registrationId " +
49 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 431 oauth2_params_id uuid NOT NULL,
432 432 created_time bigint NOT NULL,
433 433 pkg_name varchar(255),
434   - callback_url_scheme varchar(255),
  434 + app_secret varchar(2048),
435 435 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
436 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 468 oauth2_params_id uuid NOT NULL,
469 469 created_time bigint NOT NULL,
470 470 pkg_name varchar(255),
471   - callback_url_scheme varchar(255),
  471 + app_secret varchar(2048),
472 472 CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
473 473 CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
474 474 );
... ...
... ... @@ -15,8 +15,8 @@
15 15 */
16 16 package org.thingsboard.server.dao.service;
17 17
18   -import com.fasterxml.jackson.databind.node.ObjectNode;
19 18 import com.google.common.collect.Lists;
  19 +import org.apache.commons.lang3.RandomStringUtils;
20 20 import org.junit.After;
21 21 import org.junit.Assert;
22 22 import org.junit.Before;
... ... @@ -487,7 +487,7 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
487 487 }
488 488
489 489 @Test
490   - public void testFindCallbackUrlScheme() {
  490 + public void testFindAppSecret() {
491 491 OAuth2Info oAuth2Info = new OAuth2Info(true, Lists.newArrayList(
492 492 OAuth2ParamsInfo.builder()
493 493 .domainInfos(Lists.newArrayList(
... ... @@ -496,8 +496,8 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
496 496 OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
497 497 ))
498 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 502 .clientRegistrations(Lists.newArrayList(
503 503 validRegistrationInfo(),
... ... @@ -527,14 +527,14 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
527 527 for (OAuth2ClientInfo clientInfo : firstDomainHttpClients) {
528 528 String[] segments = clientInfo.getUrl().split("/");
529 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 548 OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
549 549 ))
550 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 554 .clientRegistrations(Lists.newArrayList(
555 555 validRegistrationInfo("Google", Arrays.asList(PlatformType.WEB, PlatformType.ANDROID)),
... ... @@ -651,4 +651,10 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
651 651 )
652 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 445 export function isMobileApp(): boolean {
446 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 90 <mat-form-field fxFlex class="mat-block">
91 91 <mat-label translate>admin.oauth2.redirect-uri-template</mat-label>
92 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 101 </mat-form-field>
100   -
101 102 <mat-form-field fxFlex *ngIf="domainInfo.get('scheme').value === 'MIXED'" class="mat-block">
102 103 <mat-label></mat-label>
103 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 113 </mat-form-field>
111 114 </div>
112 115 </div>
... ... @@ -144,18 +147,32 @@
144 147 <div [formGroupName]="n" fxLayout="row" fxLayoutGap="8px">
145 148 <div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px">
146 149 <div fxFlex fxLayout="column">
147   - <mat-form-field fxFlex class="mat-block">
  150 + <mat-form-field fxFlex class="mat-block" floatLabel="always">
148 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 154 </mat-form-field>
151 155 <mat-error *ngIf="mobileInfo.hasError('unique')">
152 156 {{ 'admin.oauth2.mobile-package-unique' | translate }}
153 157 </mat-error>
154 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 176 </div>
160 177 <div fxLayout="column" fxLayoutAlign="center start">
161 178 <button type="button" mat-icon-button color="primary"
... ...
... ... @@ -42,7 +42,7 @@ import { WINDOW } from '@core/services/window.service';
42 42 import { forkJoin, Subscription } from 'rxjs';
43 43 import { DialogService } from '@core/services/dialog.service';
44 44 import { TranslateService } from '@ngx-translate/core';
45   -import { isDefined, isDefinedAndNotNull } from '@core/utils';
  45 +import { isDefined, isDefinedAndNotNull, randomAlphanumeric } from '@core/utils';
46 46 import { OAuth2Service } from '@core/http/oauth2.service';
47 47 import { ActivatedRoute } from '@angular/router';
48 48
... ... @@ -275,7 +275,8 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha
275 275 private buildMobileInfoForm(mobileInfo?: OAuth2MobileInfo): FormGroup {
276 276 return this.fb.group({
277 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 280 }, {validators: this.uniquePkgNameValidator});
280 281 }
281 282
... ... @@ -529,7 +530,7 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha
529 530 addMobileInfo(control: AbstractControl): void {
530 531 this.mobileInfos(control).push(this.buildMobileInfoForm({
531 532 pkgName: '',
532   - callbackUrlScheme: ''
  533 + appSecret: randomAlphanumeric(24)
533 534 }));
534 535 }
535 536
... ...
... ... @@ -16,11 +16,16 @@
16 16
17 17 -->
18 18 <button mat-icon-button
  19 + type="button"
  20 + [color]="color"
19 21 [disabled]="disabled"
20 22 [matTooltip]="matTooltipText"
21 23 [matTooltipPosition]="matTooltipPosition"
22 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 27 </mat-icon>
  28 + <ng-template #copiedTemplate>
  29 + <mat-icon [ngStyle]="style" class="copied">done</mat-icon>
  30 + </ng-template>
26 31 </button>
... ...
... ... @@ -26,7 +26,6 @@ import { TranslateService } from '@ngx-translate/core';
26 26 })
27 27 export class CopyButtonComponent {
28 28
29   - private copedIcon = '';
30 29 private timer;
31 30
32 31 copied = false;
... ... @@ -52,6 +51,9 @@ export class CopyButtonComponent {
52 51 @Input()
53 52 style: {[key: string]: any} = {};
54 53
  54 + @Input()
  55 + color: string;
  56 +
55 57 @Output()
56 58 successCopied = new EventEmitter<string>();
57 59
... ... @@ -67,23 +69,13 @@ export class CopyButtonComponent {
67 69 }
68 70 this.clipboardService.copy(this.copyText);
69 71 this.successCopied.emit(this.copyText);
70   - this.copedIcon = 'done';
71 72 this.copied = true;
72 73 this.timer = setTimeout(() => {
73   - this.copedIcon = null;
74 74 this.copied = false;
75 75 this.cd.detectChanges();
76 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 79 get matTooltipText(): string {
88 80 return this.copied ? this.translate.instant('ota-update.copied') : this.tooltipText;
89 81 }
... ...
... ... @@ -34,7 +34,7 @@ export interface OAuth2DomainInfo {
34 34
35 35 export interface OAuth2MobileInfo {
36 36 pkgName: string;
37   - callbackUrlScheme: string;
  37 + appSecret: string;
38 38 }
39 39
40 40 export enum DomainSchema{
... ...
... ... @@ -224,8 +224,12 @@
224 224 "mobile-apps": "Mobile applications",
225 225 "no-mobile-apps": "No applications configured",
226 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 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 233 "add-mobile-app": "Add application",
230 234 "delete-mobile-app": "Delete application info",
231 235 "providers": "Providers",
... ...