Introduce mobile app oauth2 request authentication using application token signed by application secret.
This commit is contained in:
parent
dbc3ef95ce
commit
1ed624d30b
@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile (
|
|||||||
oauth2_params_id uuid NOT NULL,
|
oauth2_params_id uuid NOT NULL,
|
||||||
created_time bigint NOT NULL,
|
created_time bigint NOT NULL,
|
||||||
pkg_name varchar(255),
|
pkg_name varchar(255),
|
||||||
callback_url_scheme varchar(255),
|
app_secret varchar(2048),
|
||||||
CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
|
CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
|
CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import org.springframework.web.util.UriComponentsBuilder;
|
|||||||
import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
|
import org.thingsboard.server.dao.oauth2.OAuth2Configuration;
|
||||||
import org.thingsboard.server.dao.oauth2.OAuth2Service;
|
import org.thingsboard.server.dao.oauth2.OAuth2Service;
|
||||||
import org.thingsboard.server.service.security.auth.oauth2.TbOAuth2ParameterNames;
|
import org.thingsboard.server.service.security.auth.oauth2.TbOAuth2ParameterNames;
|
||||||
|
import org.thingsboard.server.service.security.model.token.OAuth2AppTokenFactory;
|
||||||
import org.thingsboard.server.utils.MiscUtils;
|
import org.thingsboard.server.utils.MiscUtils;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
@ -69,6 +70,9 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
|
|||||||
@Autowired
|
@Autowired
|
||||||
private OAuth2Service oAuth2Service;
|
private OAuth2Service oAuth2Service;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private OAuth2AppTokenFactory oAuth2AppTokenFactory;
|
||||||
|
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private OAuth2Configuration oauth2Configuration;
|
private OAuth2Configuration oauth2Configuration;
|
||||||
|
|
||||||
@ -78,7 +82,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
|
|||||||
String registrationId = this.resolveRegistrationId(request);
|
String registrationId = this.resolveRegistrationId(request);
|
||||||
String redirectUriAction = getAction(request, "login");
|
String redirectUriAction = getAction(request, "login");
|
||||||
String appPackage = getAppPackage(request);
|
String appPackage = getAppPackage(request);
|
||||||
return resolve(request, registrationId, redirectUriAction, appPackage);
|
String appToken = getAppToken(request);
|
||||||
|
return resolve(request, registrationId, redirectUriAction, appPackage, appToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -88,7 +93,8 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
|
|||||||
}
|
}
|
||||||
String redirectUriAction = getAction(request, "authorize");
|
String redirectUriAction = getAction(request, "authorize");
|
||||||
String appPackage = getAppPackage(request);
|
String appPackage = getAppPackage(request);
|
||||||
return resolve(request, registrationId, redirectUriAction, appPackage);
|
String appToken = getAppToken(request);
|
||||||
|
return resolve(request, registrationId, redirectUriAction, appPackage, appToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getAction(HttpServletRequest request, String defaultAction) {
|
private String getAction(HttpServletRequest request, String defaultAction) {
|
||||||
@ -103,8 +109,12 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
|
|||||||
return request.getParameter("pkg");
|
return request.getParameter("pkg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getAppToken(HttpServletRequest request) {
|
||||||
|
return request.getParameter("appToken");
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
@SuppressWarnings("deprecation")
|
||||||
private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction, String appPackage) {
|
private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction, String appPackage, String appToken) {
|
||||||
if (registrationId == null) {
|
if (registrationId == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -117,10 +127,14 @@ public class CustomOAuth2AuthorizationRequestResolver implements OAuth2Authoriza
|
|||||||
Map<String, Object> attributes = new HashMap<>();
|
Map<String, Object> attributes = new HashMap<>();
|
||||||
attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId());
|
attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId());
|
||||||
if (!StringUtils.isEmpty(appPackage)) {
|
if (!StringUtils.isEmpty(appPackage)) {
|
||||||
String callbackUrlScheme = this.oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), appPackage);
|
if (StringUtils.isEmpty(appToken)) {
|
||||||
if (StringUtils.isEmpty(callbackUrlScheme)) {
|
throw new IllegalArgumentException("Invalid application token.");
|
||||||
throw new IllegalArgumentException("Invalid package: " + appPackage + ". No package info found for Client Registration.");
|
|
||||||
} else {
|
} else {
|
||||||
|
String appSecret = this.oAuth2Service.findAppSecret(UUID.fromString(registrationId), appPackage);
|
||||||
|
if (StringUtils.isEmpty(appSecret)) {
|
||||||
|
throw new IllegalArgumentException("Invalid package: " + appPackage + ". No application secret found for Client Registration with given application package.");
|
||||||
|
}
|
||||||
|
String callbackUrlScheme = this.oAuth2AppTokenFactory.validateTokenAndGetCallbackUrlScheme(appPackage, appToken, appSecret);
|
||||||
attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme);
|
attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Copyright © 2016-2021 The Thingsboard Authors
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package org.thingsboard.server.service.security.model.token;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
|
import io.jsonwebtoken.Jws;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.MalformedJwtException;
|
||||||
|
import io.jsonwebtoken.SignatureException;
|
||||||
|
import io.jsonwebtoken.UnsupportedJwtException;
|
||||||
|
import io.micrometer.core.instrument.util.StringUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class OAuth2AppTokenFactory {
|
||||||
|
|
||||||
|
private static final String CALLBACK_URL_SCHEME = "callbackUrlScheme";
|
||||||
|
|
||||||
|
private static final long MAX_EXPIRATION_TIME_DIFF_MS = TimeUnit.MINUTES.toMillis(5);
|
||||||
|
|
||||||
|
public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) {
|
||||||
|
Jws<Claims> jwsClaims;
|
||||||
|
try {
|
||||||
|
jwsClaims = Jwts.parser().setSigningKey(appSecret).parseClaimsJws(appToken);
|
||||||
|
}
|
||||||
|
catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) {
|
||||||
|
throw new IllegalArgumentException("Invalid Application token: ", ex);
|
||||||
|
} catch (ExpiredJwtException expiredEx) {
|
||||||
|
throw new IllegalArgumentException("Application token expired", expiredEx);
|
||||||
|
}
|
||||||
|
Claims claims = jwsClaims.getBody();
|
||||||
|
Date expiration = claims.getExpiration();
|
||||||
|
if (expiration == null) {
|
||||||
|
throw new IllegalArgumentException("Application token must have expiration date");
|
||||||
|
}
|
||||||
|
long timeDiff = expiration.getTime() - System.currentTimeMillis();
|
||||||
|
if (timeDiff > MAX_EXPIRATION_TIME_DIFF_MS) {
|
||||||
|
throw new IllegalArgumentException("Application token expiration time can't be longer than 5 minutes");
|
||||||
|
}
|
||||||
|
if (!claims.getIssuer().equals(appPackage)) {
|
||||||
|
throw new IllegalArgumentException("Application token issuer doesn't match application package");
|
||||||
|
}
|
||||||
|
String callbackUrlScheme = claims.get(CALLBACK_URL_SCHEME, String.class);
|
||||||
|
if (StringUtils.isEmpty(callbackUrlScheme)) {
|
||||||
|
throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme");
|
||||||
|
}
|
||||||
|
return callbackUrlScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -42,5 +42,5 @@ public interface OAuth2Service {
|
|||||||
|
|
||||||
List<OAuth2Registration> findAllRegistrations();
|
List<OAuth2Registration> findAllRegistrations();
|
||||||
|
|
||||||
String findCallbackUrlScheme(UUID registrationId, String pkgName);
|
String findAppSecret(UUID registrationId, String pkgName);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,12 +31,12 @@ public class OAuth2Mobile extends BaseData<OAuth2MobileId> {
|
|||||||
|
|
||||||
private OAuth2ParamsId oauth2ParamsId;
|
private OAuth2ParamsId oauth2ParamsId;
|
||||||
private String pkgName;
|
private String pkgName;
|
||||||
private String callbackUrlScheme;
|
private String appSecret;
|
||||||
|
|
||||||
public OAuth2Mobile(OAuth2Mobile mobile) {
|
public OAuth2Mobile(OAuth2Mobile mobile) {
|
||||||
super(mobile);
|
super(mobile);
|
||||||
this.oauth2ParamsId = mobile.oauth2ParamsId;
|
this.oauth2ParamsId = mobile.oauth2ParamsId;
|
||||||
this.pkgName = mobile.pkgName;
|
this.pkgName = mobile.pkgName;
|
||||||
this.callbackUrlScheme = mobile.callbackUrlScheme;
|
this.appSecret = mobile.appSecret;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,5 +30,5 @@ import lombok.ToString;
|
|||||||
@Builder
|
@Builder
|
||||||
public class OAuth2MobileInfo {
|
public class OAuth2MobileInfo {
|
||||||
private String pkgName;
|
private String pkgName;
|
||||||
private String callbackUrlScheme;
|
private String appSecret;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -418,7 +418,7 @@ public class ModelConstants {
|
|||||||
public static final String OAUTH2_MOBILE_COLUMN_FAMILY_NAME = "oauth2_mobile";
|
public static final String OAUTH2_MOBILE_COLUMN_FAMILY_NAME = "oauth2_mobile";
|
||||||
public static final String OAUTH2_PARAMS_ID_PROPERTY = "oauth2_params_id";
|
public static final String OAUTH2_PARAMS_ID_PROPERTY = "oauth2_params_id";
|
||||||
public static final String OAUTH2_PKG_NAME_PROPERTY = "pkg_name";
|
public static final String OAUTH2_PKG_NAME_PROPERTY = "pkg_name";
|
||||||
public static final String OAUTH2_CALLBACK_URL_SCHEME_PROPERTY = "callback_url_scheme";
|
public static final String OAUTH2_APP_SECRET_PROPERTY = "app_secret";
|
||||||
|
|
||||||
public static final String OAUTH2_CLIENT_REGISTRATION_INFO_COLUMN_FAMILY_NAME = "oauth2_client_registration_info";
|
public static final String OAUTH2_CLIENT_REGISTRATION_INFO_COLUMN_FAMILY_NAME = "oauth2_client_registration_info";
|
||||||
public static final String OAUTH2_CLIENT_REGISTRATION_COLUMN_FAMILY_NAME = "oauth2_client_registration";
|
public static final String OAUTH2_CLIENT_REGISTRATION_COLUMN_FAMILY_NAME = "oauth2_client_registration";
|
||||||
|
|||||||
@ -40,8 +40,8 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
|
|||||||
@Column(name = ModelConstants.OAUTH2_PKG_NAME_PROPERTY)
|
@Column(name = ModelConstants.OAUTH2_PKG_NAME_PROPERTY)
|
||||||
private String pkgName;
|
private String pkgName;
|
||||||
|
|
||||||
@Column(name = ModelConstants.OAUTH2_CALLBACK_URL_SCHEME_PROPERTY)
|
@Column(name = ModelConstants.OAUTH2_APP_SECRET_PROPERTY)
|
||||||
private String callbackUrlScheme;
|
private String appSecret;
|
||||||
|
|
||||||
public OAuth2MobileEntity() {
|
public OAuth2MobileEntity() {
|
||||||
super();
|
super();
|
||||||
@ -56,7 +56,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
|
|||||||
this.oauth2ParamsId = mobile.getOauth2ParamsId().getId();
|
this.oauth2ParamsId = mobile.getOauth2ParamsId().getId();
|
||||||
}
|
}
|
||||||
this.pkgName = mobile.getPkgName();
|
this.pkgName = mobile.getPkgName();
|
||||||
this.callbackUrlScheme = mobile.getCallbackUrlScheme();
|
this.appSecret = mobile.getAppSecret();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -66,7 +66,7 @@ public class OAuth2MobileEntity extends BaseSqlEntity<OAuth2Mobile> {
|
|||||||
mobile.setCreatedTime(createdTime);
|
mobile.setCreatedTime(createdTime);
|
||||||
mobile.setOauth2ParamsId(new OAuth2ParamsId(oauth2ParamsId));
|
mobile.setOauth2ParamsId(new OAuth2ParamsId(oauth2ParamsId));
|
||||||
mobile.setPkgName(pkgName);
|
mobile.setPkgName(pkgName);
|
||||||
mobile.setCallbackUrlScheme(callbackUrlScheme);
|
mobile.setAppSecret(appSecret);
|
||||||
return mobile;
|
return mobile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,6 @@ public interface OAuth2RegistrationDao extends Dao<OAuth2Registration> {
|
|||||||
|
|
||||||
List<OAuth2Registration> findByOAuth2ParamsId(UUID oauth2ParamsId);
|
List<OAuth2Registration> findByOAuth2ParamsId(UUID oauth2ParamsId);
|
||||||
|
|
||||||
String findCallbackUrlScheme(UUID id, String pkgName);
|
String findAppSecret(UUID id, String pkgName);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,23 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.thingsboard.server.common.data.BaseData;
|
import org.thingsboard.server.common.data.BaseData;
|
||||||
import org.thingsboard.server.common.data.id.TenantId;
|
import org.thingsboard.server.common.data.id.TenantId;
|
||||||
import org.thingsboard.server.common.data.oauth2.*;
|
import org.thingsboard.server.common.data.oauth2.MapperType;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2BasicMapperConfig;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2Domain;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2DomainInfo;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2Info;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2Mobile;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2MobileInfo;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2Params;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2ParamsInfo;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2Registration;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.OAuth2RegistrationInfo;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.PlatformType;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.SchemeType;
|
||||||
|
import org.thingsboard.server.common.data.oauth2.TenantNameStrategyType;
|
||||||
import org.thingsboard.server.common.data.oauth2.deprecated.ClientRegistrationDto;
|
import org.thingsboard.server.common.data.oauth2.deprecated.ClientRegistrationDto;
|
||||||
import org.thingsboard.server.common.data.oauth2.deprecated.DomainInfo;
|
import org.thingsboard.server.common.data.oauth2.deprecated.DomainInfo;
|
||||||
import org.thingsboard.server.common.data.oauth2.deprecated.ExtendedOAuth2ClientRegistrationInfo;
|
import org.thingsboard.server.common.data.oauth2.deprecated.ExtendedOAuth2ClientRegistrationInfo;
|
||||||
@ -36,7 +52,11 @@ import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationDao;
|
|||||||
import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationInfoDao;
|
import org.thingsboard.server.dao.oauth2.deprecated.OAuth2ClientRegistrationInfoDao;
|
||||||
|
|
||||||
import javax.transaction.Transactional;
|
import javax.transaction.Transactional;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ -164,11 +184,11 @@ public class OAuth2ServiceImpl extends AbstractEntityService implements OAuth2Se
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String findCallbackUrlScheme(UUID id, String pkgName) {
|
public String findAppSecret(UUID id, String pkgName) {
|
||||||
log.trace("Executing findCallbackUrlScheme [{}][{}]", id, pkgName);
|
log.trace("Executing findAppSecret [{}][{}]", id, pkgName);
|
||||||
validateId(id, INCORRECT_CLIENT_REGISTRATION_ID + id);
|
validateId(id, INCORRECT_CLIENT_REGISTRATION_ID + id);
|
||||||
validateString(pkgName, "Incorrect package name");
|
validateString(pkgName, "Incorrect package name");
|
||||||
return oauth2RegistrationDao.findCallbackUrlScheme(id, pkgName);
|
return oauth2RegistrationDao.findAppSecret(id, pkgName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -323,8 +343,11 @@ public class OAuth2ServiceImpl extends AbstractEntityService implements OAuth2Se
|
|||||||
if (StringUtils.isEmpty(mobileInfo.getPkgName())) {
|
if (StringUtils.isEmpty(mobileInfo.getPkgName())) {
|
||||||
throw new DataValidationException("Package should be specified!");
|
throw new DataValidationException("Package should be specified!");
|
||||||
}
|
}
|
||||||
if (StringUtils.isEmpty(mobileInfo.getCallbackUrlScheme())) {
|
if (StringUtils.isEmpty(mobileInfo.getAppSecret())) {
|
||||||
throw new DataValidationException("Callback URL scheme should be specified!");
|
throw new DataValidationException("Application secret should be specified!");
|
||||||
|
}
|
||||||
|
if (mobileInfo.getAppSecret().length() < 16) {
|
||||||
|
throw new DataValidationException("Application secret should be at least 16 characters!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
oauth2Params.getMobileInfos().stream()
|
oauth2Params.getMobileInfos().stream()
|
||||||
|
|||||||
@ -148,7 +148,7 @@ public class OAuth2Utils {
|
|||||||
public static OAuth2MobileInfo toOAuth2MobileInfo(OAuth2Mobile mobile) {
|
public static OAuth2MobileInfo toOAuth2MobileInfo(OAuth2Mobile mobile) {
|
||||||
return OAuth2MobileInfo.builder()
|
return OAuth2MobileInfo.builder()
|
||||||
.pkgName(mobile.getPkgName())
|
.pkgName(mobile.getPkgName())
|
||||||
.callbackUrlScheme(mobile.getCallbackUrlScheme())
|
.appSecret(mobile.getAppSecret())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,7 +191,7 @@ public class OAuth2Utils {
|
|||||||
OAuth2Mobile mobile = new OAuth2Mobile();
|
OAuth2Mobile mobile = new OAuth2Mobile();
|
||||||
mobile.setOauth2ParamsId(oauth2ParamsId);
|
mobile.setOauth2ParamsId(oauth2ParamsId);
|
||||||
mobile.setPkgName(mobileInfo.getPkgName());
|
mobile.setPkgName(mobileInfo.getPkgName());
|
||||||
mobile.setCallbackUrlScheme(mobileInfo.getCallbackUrlScheme());
|
mobile.setAppSecret(mobileInfo.getAppSecret());
|
||||||
return mobile;
|
return mobile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,8 +57,8 @@ public class JpaOAuth2RegistrationDao extends JpaAbstractDao<OAuth2RegistrationE
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String findCallbackUrlScheme(UUID id, String pkgName) {
|
public String findAppSecret(UUID id, String pkgName) {
|
||||||
return repository.findCallbackUrlScheme(id, pkgName);
|
return repository.findAppSecret(id, pkgName);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,12 +42,12 @@ public interface OAuth2RegistrationRepository extends CrudRepository<OAuth2Regis
|
|||||||
|
|
||||||
List<OAuth2RegistrationEntity> findByOauth2ParamsId(UUID oauth2ParamsId);
|
List<OAuth2RegistrationEntity> findByOauth2ParamsId(UUID oauth2ParamsId);
|
||||||
|
|
||||||
@Query("SELECT mobile.callbackUrlScheme " +
|
@Query("SELECT mobile.appSecret " +
|
||||||
"FROM OAuth2MobileEntity mobile " +
|
"FROM OAuth2MobileEntity mobile " +
|
||||||
"LEFT JOIN OAuth2RegistrationEntity reg on mobile.oauth2ParamsId = reg.oauth2ParamsId " +
|
"LEFT JOIN OAuth2RegistrationEntity reg on mobile.oauth2ParamsId = reg.oauth2ParamsId " +
|
||||||
"WHERE reg.id = :registrationId " +
|
"WHERE reg.id = :registrationId " +
|
||||||
"AND mobile.pkgName = :pkgName")
|
"AND mobile.pkgName = :pkgName")
|
||||||
String findCallbackUrlScheme(@Param("registrationId") UUID id,
|
String findAppSecret(@Param("registrationId") UUID id,
|
||||||
@Param("pkgName") String pkgName);
|
@Param("pkgName") String pkgName);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -431,7 +431,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile (
|
|||||||
oauth2_params_id uuid NOT NULL,
|
oauth2_params_id uuid NOT NULL,
|
||||||
created_time bigint NOT NULL,
|
created_time bigint NOT NULL,
|
||||||
pkg_name varchar(255),
|
pkg_name varchar(255),
|
||||||
callback_url_scheme varchar(255),
|
app_secret varchar(2048),
|
||||||
CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
|
CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
|
CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -468,7 +468,7 @@ CREATE TABLE IF NOT EXISTS oauth2_mobile (
|
|||||||
oauth2_params_id uuid NOT NULL,
|
oauth2_params_id uuid NOT NULL,
|
||||||
created_time bigint NOT NULL,
|
created_time bigint NOT NULL,
|
||||||
pkg_name varchar(255),
|
pkg_name varchar(255),
|
||||||
callback_url_scheme varchar(255),
|
app_secret varchar(2048),
|
||||||
CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
|
CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
|
CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,8 +15,8 @@
|
|||||||
*/
|
*/
|
||||||
package org.thingsboard.server.dao.service;
|
package org.thingsboard.server.dao.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
import org.junit.After;
|
import org.junit.After;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Before;
|
import org.junit.Before;
|
||||||
@ -487,7 +487,7 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testFindCallbackUrlScheme() {
|
public void testFindAppSecret() {
|
||||||
OAuth2Info oAuth2Info = new OAuth2Info(true, Lists.newArrayList(
|
OAuth2Info oAuth2Info = new OAuth2Info(true, Lists.newArrayList(
|
||||||
OAuth2ParamsInfo.builder()
|
OAuth2ParamsInfo.builder()
|
||||||
.domainInfos(Lists.newArrayList(
|
.domainInfos(Lists.newArrayList(
|
||||||
@ -496,8 +496,8 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
|
|||||||
OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
|
OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
|
||||||
))
|
))
|
||||||
.mobileInfos(Lists.newArrayList(
|
.mobileInfos(Lists.newArrayList(
|
||||||
OAuth2MobileInfo.builder().pkgName("com.test.pkg1").callbackUrlScheme("testPkg1Callback").build(),
|
validMobileInfo("com.test.pkg1", "testPkg1AppSecret"),
|
||||||
OAuth2MobileInfo.builder().pkgName("com.test.pkg2").callbackUrlScheme("testPkg2Callback").build()
|
validMobileInfo("com.test.pkg2", "testPkg2AppSecret")
|
||||||
))
|
))
|
||||||
.clientRegistrations(Lists.newArrayList(
|
.clientRegistrations(Lists.newArrayList(
|
||||||
validRegistrationInfo(),
|
validRegistrationInfo(),
|
||||||
@ -527,14 +527,14 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
|
|||||||
for (OAuth2ClientInfo clientInfo : firstDomainHttpClients) {
|
for (OAuth2ClientInfo clientInfo : firstDomainHttpClients) {
|
||||||
String[] segments = clientInfo.getUrl().split("/");
|
String[] segments = clientInfo.getUrl().split("/");
|
||||||
String registrationId = segments[segments.length-1];
|
String registrationId = segments[segments.length-1];
|
||||||
String callbackUrlScheme = oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), "com.test.pkg1");
|
String appSecret = oAuth2Service.findAppSecret(UUID.fromString(registrationId), "com.test.pkg1");
|
||||||
Assert.assertNotNull(callbackUrlScheme);
|
Assert.assertNotNull(appSecret);
|
||||||
Assert.assertEquals("testPkg1Callback", callbackUrlScheme);
|
Assert.assertEquals("testPkg1AppSecret", appSecret);
|
||||||
callbackUrlScheme = oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), "com.test.pkg2");
|
appSecret = oAuth2Service.findAppSecret(UUID.fromString(registrationId), "com.test.pkg2");
|
||||||
Assert.assertNotNull(callbackUrlScheme);
|
Assert.assertNotNull(appSecret);
|
||||||
Assert.assertEquals("testPkg2Callback", callbackUrlScheme);
|
Assert.assertEquals("testPkg2AppSecret", appSecret);
|
||||||
callbackUrlScheme = oAuth2Service.findCallbackUrlScheme(UUID.fromString(registrationId), "com.test.pkg3");
|
appSecret = oAuth2Service.findAppSecret(UUID.fromString(registrationId), "com.test.pkg3");
|
||||||
Assert.assertNull(callbackUrlScheme);
|
Assert.assertNull(appSecret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -548,8 +548,8 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
|
|||||||
OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
|
OAuth2DomainInfo.builder().name("third-domain").scheme(SchemeType.HTTPS).build()
|
||||||
))
|
))
|
||||||
.mobileInfos(Lists.newArrayList(
|
.mobileInfos(Lists.newArrayList(
|
||||||
OAuth2MobileInfo.builder().pkgName("com.test.pkg1").callbackUrlScheme("testPkg1Callback").build(),
|
validMobileInfo("com.test.pkg1", "testPkg1Callback"),
|
||||||
OAuth2MobileInfo.builder().pkgName("com.test.pkg2").callbackUrlScheme("testPkg2Callback").build()
|
validMobileInfo("com.test.pkg2", "testPkg2Callback")
|
||||||
))
|
))
|
||||||
.clientRegistrations(Lists.newArrayList(
|
.clientRegistrations(Lists.newArrayList(
|
||||||
validRegistrationInfo("Google", Arrays.asList(PlatformType.WEB, PlatformType.ANDROID)),
|
validRegistrationInfo("Google", Arrays.asList(PlatformType.WEB, PlatformType.ANDROID)),
|
||||||
@ -651,4 +651,10 @@ public class BaseOAuth2ServiceTest extends AbstractServiceTest {
|
|||||||
)
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private OAuth2MobileInfo validMobileInfo(String pkgName, String appSecret) {
|
||||||
|
return OAuth2MobileInfo.builder().pkgName(pkgName)
|
||||||
|
.appSecret(appSecret != null ? appSecret : RandomStringUtils.randomAlphanumeric(24))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -445,3 +445,14 @@ export function validateEntityId(entityId: EntityId | null): boolean {
|
|||||||
export function isMobileApp(): boolean {
|
export function isMobileApp(): boolean {
|
||||||
return isDefined((window as any).flutter_inappwebview);
|
return isDefined((window as any).flutter_inappwebview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alphanumericCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const alphanumericCharactersLength = alphanumericCharacters.length;
|
||||||
|
|
||||||
|
export function randomAlphanumeric(length: number): string {
|
||||||
|
let result = '';
|
||||||
|
for ( let i = 0; i < length; i++ ) {
|
||||||
|
result += alphanumericCharacters.charAt(Math.floor(Math.random() * alphanumericCharactersLength));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@ -90,23 +90,26 @@
|
|||||||
<mat-form-field fxFlex class="mat-block">
|
<mat-form-field fxFlex class="mat-block">
|
||||||
<mat-label translate>admin.oauth2.redirect-uri-template</mat-label>
|
<mat-label translate>admin.oauth2.redirect-uri-template</mat-label>
|
||||||
<input matInput [value]="redirectURI(domainInfo)" readonly>
|
<input matInput [value]="redirectURI(domainInfo)" readonly>
|
||||||
<button mat-icon-button color="primary" matSuffix type="button"
|
<tb-copy-button
|
||||||
ngxClipboard cbContent="{{ redirectURI(domainInfo) }}"
|
matSuffix
|
||||||
matTooltip="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
|
color="primary"
|
||||||
matTooltipPosition="above">
|
[copyText]="redirectURI(domainInfo)"
|
||||||
<mat-icon class="material-icons" svgIcon="mdi:clipboard-arrow-left"></mat-icon>
|
tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
|
||||||
</button>
|
tooltipPosition="above"
|
||||||
|
mdiIcon="mdi:clipboard-arrow-left">
|
||||||
|
</tb-copy-button>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field fxFlex *ngIf="domainInfo.get('scheme').value === 'MIXED'" class="mat-block">
|
<mat-form-field fxFlex *ngIf="domainInfo.get('scheme').value === 'MIXED'" class="mat-block">
|
||||||
<mat-label></mat-label>
|
<mat-label></mat-label>
|
||||||
<input matInput [value]="redirectURIMixed(domainInfo)" readonly>
|
<input matInput [value]="redirectURIMixed(domainInfo)" readonly>
|
||||||
<button mat-icon-button color="primary" matSuffix type="button"
|
<tb-copy-button
|
||||||
ngxClipboard cbContent="{{ redirectURIMixed(domainInfo) }}"
|
matSuffix
|
||||||
matTooltip="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
|
color="primary"
|
||||||
matTooltipPosition="above">
|
[copyText]="redirectURIMixed(domainInfo)"
|
||||||
<mat-icon class="material-icons" svgIcon="mdi:clipboard-arrow-left"></mat-icon>
|
tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
|
||||||
</button>
|
tooltipPosition="above"
|
||||||
|
mdiIcon="mdi:clipboard-arrow-left">
|
||||||
|
</tb-copy-button>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -144,18 +147,32 @@
|
|||||||
<div [formGroupName]="n" fxLayout="row" fxLayoutGap="8px">
|
<div [formGroupName]="n" fxLayout="row" fxLayoutGap="8px">
|
||||||
<div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px">
|
<div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px">
|
||||||
<div fxFlex fxLayout="column">
|
<div fxFlex fxLayout="column">
|
||||||
<mat-form-field fxFlex class="mat-block">
|
<mat-form-field fxFlex class="mat-block" floatLabel="always">
|
||||||
<mat-label translate>admin.oauth2.mobile-package</mat-label>
|
<mat-label translate>admin.oauth2.mobile-package</mat-label>
|
||||||
<input matInput formControlName="pkgName" required>
|
<input matInput formControlName="pkgName" placeholder="{{ 'admin.oauth2.mobile-package-placeholder' | translate }}" required>
|
||||||
|
<mat-hint translate>admin.oauth2.mobile-package-hint</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-error *ngIf="mobileInfo.hasError('unique')">
|
<mat-error *ngIf="mobileInfo.hasError('unique')">
|
||||||
{{ 'admin.oauth2.mobile-package-unique' | translate }}
|
{{ 'admin.oauth2.mobile-package-unique' | translate }}
|
||||||
</mat-error>
|
</mat-error>
|
||||||
</div>
|
</div>
|
||||||
<mat-form-field fxFlex class="mat-block">
|
<div fxFlex fxLayout="row">
|
||||||
<mat-label translate>admin.oauth2.mobile-callback-url-scheme</mat-label>
|
<mat-form-field fxFlex class="mat-block">
|
||||||
<input matInput formControlName="callbackUrlScheme" required>
|
<mat-label translate>admin.oauth2.mobile-app-secret</mat-label>
|
||||||
</mat-form-field>
|
<textarea matInput formControlName="appSecret" rows="1" required></textarea>
|
||||||
|
<tb-copy-button
|
||||||
|
matSuffix
|
||||||
|
color="primary"
|
||||||
|
[copyText]="mobileInfo.get('appSecret').value"
|
||||||
|
tooltipText="{{ 'admin.oauth2.copy-mobile-app-secret' | translate }}"
|
||||||
|
tooltipPosition="above"
|
||||||
|
mdiIcon="mdi:clipboard-arrow-left">
|
||||||
|
</tb-copy-button>
|
||||||
|
<mat-error *ngIf="mobileInfo.get('appSecret').invalid">
|
||||||
|
{{ 'admin.oauth2.invalid-mobile-app-secret' | translate }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div fxLayout="column" fxLayoutAlign="center start">
|
<div fxLayout="column" fxLayoutAlign="center start">
|
||||||
<button type="button" mat-icon-button color="primary"
|
<button type="button" mat-icon-button color="primary"
|
||||||
|
|||||||
@ -42,7 +42,7 @@ import { WINDOW } from '@core/services/window.service';
|
|||||||
import { forkJoin, Subscription } from 'rxjs';
|
import { forkJoin, Subscription } from 'rxjs';
|
||||||
import { DialogService } from '@core/services/dialog.service';
|
import { DialogService } from '@core/services/dialog.service';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { isDefined, isDefinedAndNotNull } from '@core/utils';
|
import { isDefined, isDefinedAndNotNull, randomAlphanumeric } from '@core/utils';
|
||||||
import { OAuth2Service } from '@core/http/oauth2.service';
|
import { OAuth2Service } from '@core/http/oauth2.service';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
@ -275,7 +275,8 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha
|
|||||||
private buildMobileInfoForm(mobileInfo?: OAuth2MobileInfo): FormGroup {
|
private buildMobileInfoForm(mobileInfo?: OAuth2MobileInfo): FormGroup {
|
||||||
return this.fb.group({
|
return this.fb.group({
|
||||||
pkgName: [mobileInfo?.pkgName, [Validators.required]],
|
pkgName: [mobileInfo?.pkgName, [Validators.required]],
|
||||||
callbackUrlScheme: [mobileInfo?.callbackUrlScheme, [Validators.required]],
|
appSecret: [mobileInfo?.appSecret, [Validators.required, Validators.minLength(16), Validators.maxLength(2048),
|
||||||
|
Validators.pattern(/^[A-Za-z0-9]+$/)]],
|
||||||
}, {validators: this.uniquePkgNameValidator});
|
}, {validators: this.uniquePkgNameValidator});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -529,7 +530,7 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha
|
|||||||
addMobileInfo(control: AbstractControl): void {
|
addMobileInfo(control: AbstractControl): void {
|
||||||
this.mobileInfos(control).push(this.buildMobileInfoForm({
|
this.mobileInfos(control).push(this.buildMobileInfoForm({
|
||||||
pkgName: '',
|
pkgName: '',
|
||||||
callbackUrlScheme: ''
|
appSecret: randomAlphanumeric(24)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,11 +16,16 @@
|
|||||||
|
|
||||||
-->
|
-->
|
||||||
<button mat-icon-button
|
<button mat-icon-button
|
||||||
|
type="button"
|
||||||
|
[color]="color"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[matTooltip]="matTooltipText"
|
[matTooltip]="matTooltipText"
|
||||||
[matTooltipPosition]="matTooltipPosition"
|
[matTooltipPosition]="matTooltipPosition"
|
||||||
(click)="copy($event)">
|
(click)="copy($event)">
|
||||||
<mat-icon [svgIcon]="mdiIconSymbol" [ngStyle]="style" [ngClass]="{'copied': copied}">
|
<mat-icon [svgIcon]="mdiIcon" [ngStyle]="style" *ngIf="!copied; else copiedTemplate">
|
||||||
{{ iconSymbol }}
|
{{ icon }}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
|
<ng-template #copiedTemplate>
|
||||||
|
<mat-icon [ngStyle]="style" class="copied">done</mat-icon>
|
||||||
|
</ng-template>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
})
|
})
|
||||||
export class CopyButtonComponent {
|
export class CopyButtonComponent {
|
||||||
|
|
||||||
private copedIcon = '';
|
|
||||||
private timer;
|
private timer;
|
||||||
|
|
||||||
copied = false;
|
copied = false;
|
||||||
@ -52,6 +51,9 @@ export class CopyButtonComponent {
|
|||||||
@Input()
|
@Input()
|
||||||
style: {[key: string]: any} = {};
|
style: {[key: string]: any} = {};
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
color: string;
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
successCopied = new EventEmitter<string>();
|
successCopied = new EventEmitter<string>();
|
||||||
|
|
||||||
@ -67,23 +69,13 @@ export class CopyButtonComponent {
|
|||||||
}
|
}
|
||||||
this.clipboardService.copy(this.copyText);
|
this.clipboardService.copy(this.copyText);
|
||||||
this.successCopied.emit(this.copyText);
|
this.successCopied.emit(this.copyText);
|
||||||
this.copedIcon = 'done';
|
|
||||||
this.copied = true;
|
this.copied = true;
|
||||||
this.timer = setTimeout(() => {
|
this.timer = setTimeout(() => {
|
||||||
this.copedIcon = null;
|
|
||||||
this.copied = false;
|
this.copied = false;
|
||||||
this.cd.detectChanges();
|
this.cd.detectChanges();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
get iconSymbol(): string {
|
|
||||||
return this.copedIcon || this.icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
get mdiIconSymbol(): string {
|
|
||||||
return this.copedIcon ? '' : this.mdiIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
get matTooltipText(): string {
|
get matTooltipText(): string {
|
||||||
return this.copied ? this.translate.instant('ota-update.copied') : this.tooltipText;
|
return this.copied ? this.translate.instant('ota-update.copied') : this.tooltipText;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export interface OAuth2DomainInfo {
|
|||||||
|
|
||||||
export interface OAuth2MobileInfo {
|
export interface OAuth2MobileInfo {
|
||||||
pkgName: string;
|
pkgName: string;
|
||||||
callbackUrlScheme: string;
|
appSecret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DomainSchema{
|
export enum DomainSchema{
|
||||||
|
|||||||
@ -224,8 +224,12 @@
|
|||||||
"mobile-apps": "Mobile applications",
|
"mobile-apps": "Mobile applications",
|
||||||
"no-mobile-apps": "No applications configured",
|
"no-mobile-apps": "No applications configured",
|
||||||
"mobile-package": "Application package",
|
"mobile-package": "Application package",
|
||||||
|
"mobile-package-placeholder": "Ex.: my.example.app",
|
||||||
|
"mobile-package-hint": "For Android: your own unique Application ID. For iOS: Product bundle identifier.",
|
||||||
"mobile-package-unique": "Application package must be unique.",
|
"mobile-package-unique": "Application package must be unique.",
|
||||||
"mobile-callback-url-scheme": "Callback URL scheme",
|
"mobile-app-secret": "Application secret",
|
||||||
|
"invalid-mobile-app-secret": "Application secret must contain only alphanumeric characters and must be between 16 and 2048 characters long.",
|
||||||
|
"copy-mobile-app-secret": "Copy application secret",
|
||||||
"add-mobile-app": "Add application",
|
"add-mobile-app": "Add application",
|
||||||
"delete-mobile-app": "Delete application info",
|
"delete-mobile-app": "Delete application info",
|
||||||
"providers": "Providers",
|
"providers": "Providers",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user