Browse Source

Merge branch 'lts-4.3' into lts-4.4

pull/16104/head
Viacheslav Klimov 6 days ago
parent
commit
354dbe5011
  1. 27
      application/src/main/java/org/thingsboard/server/controller/AdminController.java
  2. 66
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java
  3. 5
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
  4. 5
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java
  5. 44
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java
  6. 66
      application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java
  7. 6
      application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java
  8. 81
      application/src/test/java/org/thingsboard/server/controller/AdminControllerMailOAuth2Test.java
  9. 33
      application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java
  10. 81
      application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java
  11. 69
      application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java
  12. 103
      application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandlerTest.java
  13. 186
      application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java
  14. 74
      application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidatorTest.java
  15. 72
      application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java

27
application/src/main/java/org/thingsboard/server/controller/AdminController.java

@ -76,6 +76,7 @@ import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService;
import org.thingsboard.server.service.security.auth.oauth2.CookieUtils; import org.thingsboard.server.service.security.auth.oauth2.CookieUtils;
import org.thingsboard.server.service.security.auth.oauth2.PrevUriValidator;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Operation;
@ -92,6 +93,8 @@ import java.util.Optional;
import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_PARAMETER;
@RestController @RestController
@TbCoreComponent @TbCoreComponent
@ -100,8 +103,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminController extends BaseController { public class AdminController extends BaseController {
private static final String PREV_URI_PATH_PARAMETER = "prevUri"; private static final String DEFAULT_PREV_URI = "/settings/outgoing-mail";
private static final String PREV_URI_COOKIE_NAME = "prev_uri";
private static final String STATE_COOKIE_NAME = "state"; private static final String STATE_COOKIE_NAME = "state";
private static final String MAIL_SETTINGS_KEY = "mail"; private static final String MAIL_SETTINGS_KEY = "mail";
@ -419,8 +421,9 @@ public class AdminController extends BaseController {
@GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text")
public String getMailOAuth2AuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { public String getMailOAuth2AuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException {
String state = StringUtils.generateSafeToken(); String state = StringUtils.generateSafeToken();
if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { String prevUriParam = request.getParameter(PREV_URI_PARAMETER);
CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PATH_PARAMETER), 180); if (PrevUriValidator.isValid(prevUriParam)) {
CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, prevUriParam, 180);
} }
CookieUtils.addCookie(response, STATE_COOKIE_NAME, state, 180); CookieUtils.addCookie(response, STATE_COOKIE_NAME, state, 180);
@ -445,12 +448,9 @@ public class AdminController extends BaseController {
public void handleMailOAuth2Callback( public void handleMailOAuth2Callback(
@RequestParam(value = "code") String code, @RequestParam(value = "state") String state, @RequestParam(value = "code") String code, @RequestParam(value = "state") String state,
HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException {
Optional<Cookie> prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); String redirectUrl = getMailOAuth2RedirectUrl(request);
Optional<Cookie> cookieState = CookieUtils.getCookie(request, STATE_COOKIE_NAME); Optional<Cookie> cookieState = CookieUtils.getCookie(request, STATE_COOKIE_NAME);
String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request);
String prevUri = baseUrl + (prevUrlOpt.isPresent() ? prevUrlOpt.get().getValue() : "/settings/outgoing-mail");
if (cookieState.isEmpty() || !cookieState.get().getValue().equals(state)) { if (cookieState.isEmpty() || !cookieState.get().getValue().equals(state)) {
CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME); CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME);
throw new ThingsboardException("Refresh token was not generated, invalid state param", ThingsboardErrorCode.BAD_REQUEST_PARAMS); throw new ThingsboardException("Refresh token was not generated, invalid state param", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
@ -480,7 +480,16 @@ public class AdminController extends BaseController {
((ObjectNode) jsonValue).put("tokenGenerated", true); ((ObjectNode) jsonValue).put("tokenGenerated", true);
adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings); adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings);
response.sendRedirect(prevUri); response.sendRedirect(redirectUrl);
}
String getMailOAuth2RedirectUrl(HttpServletRequest request) {
String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request);
String prevUri = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME)
.map(Cookie::getValue)
.filter(PrevUriValidator::isValid)
.orElse(DEFAULT_PREV_URI);
return baseUrl + prevUri;
} }
} }

66
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidator.java

@ -0,0 +1,66 @@
/**
* Copyright © 2016-2026 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.auth.oauth2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.thingsboard.server.common.data.StringUtils;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
@Slf4j
public class CallbackUrlSchemeValidator {
// RFC 3986 scheme grammar, plus '_': mobile apps derive the scheme from their package name, which may contain one
private static final Pattern SCHEME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.\\-_]*");
private static final Set<String> FORBIDDEN_SCHEMES = Set.of("http", "https", "javascript", "data", "file", "vbscript");
private static final int MAX_LOGGED_LENGTH = 128;
/**
* The redirect carrying the access token is built as callbackUrlScheme + ':', so only a mobile app scheme may
* pass: a web scheme would send the token to whatever host follows it.
*/
public static boolean isValid(String callbackUrlScheme) {
return !StringUtils.isEmpty(callbackUrlScheme)
&& SCHEME_PATTERN.matcher(callbackUrlScheme).matches()
&& !FORBIDDEN_SCHEMES.contains(callbackUrlScheme.toLowerCase(Locale.ROOT));
}
/**
* The attribute is restored from the oauth2_auth_request cookie, which the client can replace, so the scheme is
* checked again on read and not only when the authorization request is built.
*/
public static String getCallbackUrlScheme(OAuth2AuthorizationRequest authorizationRequest) {
String callbackUrlScheme = authorizationRequest != null ?
authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME) : null;
if (StringUtils.isEmpty(callbackUrlScheme)) {
return null;
}
if (!isValid(callbackUrlScheme)) {
log.warn("Ignoring invalid callback url scheme: [{}]", forLog(callbackUrlScheme));
return null;
}
return callbackUrlScheme;
}
// a rejected value is attacker-controlled: it must not be able to forge log lines
private static String forLog(String value) {
return value.substring(0, Math.min(value.length(), MAX_LOGGED_LENGTH)).replaceAll("[^\\x20-\\x7E]", "?");
}
}

5
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java

@ -43,8 +43,9 @@ public class HttpCookieOAuth2AuthorizationRequestRepository implements Authoriza
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
return; return;
} }
if (request.getParameter(PREV_URI_PARAMETER) != null) { String prevUri = request.getParameter(PREV_URI_PARAMETER);
CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PARAMETER), cookieExpireSeconds); if (PrevUriValidator.isValid(prevUri)) {
CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, prevUri, cookieExpireSeconds);
} }
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds); CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
} }

5
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java

@ -54,11 +54,8 @@ public class Oauth2AuthenticationFailureHandler extends SimpleUrlAuthenticationF
throws IOException, ServletException { throws IOException, ServletException {
String baseUrl; String baseUrl;
String errorPrefix; String errorPrefix;
String callbackUrlScheme = null;
OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request); OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request);
if (authorizationRequest != null) { String callbackUrlScheme = CallbackUrlSchemeValidator.getCallbackUrlScheme(authorizationRequest);
callbackUrlScheme = authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME);
}
if (!StringUtils.isEmpty(callbackUrlScheme)) { if (!StringUtils.isEmpty(callbackUrlScheme)) {
baseUrl = callbackUrlScheme + ":"; baseUrl = callbackUrlScheme + ":";
errorPrefix = "/?error="; errorPrefix = "/?error=";

44
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java

@ -82,18 +82,9 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS
HttpServletResponse response, HttpServletResponse response,
Authentication authentication) throws IOException { Authentication authentication) throws IOException {
OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request); OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request);
String callbackUrlScheme = authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME); String callbackUrlScheme = CallbackUrlSchemeValidator.getCallbackUrlScheme(authorizationRequest);
String baseUrl; String baseUrl = getBaseUrl(request, callbackUrlScheme);
if (!StringUtils.isEmpty(callbackUrlScheme)) { String prevUri = getPrevUri(request, response, callbackUrlScheme);
baseUrl = callbackUrlScheme + ":";
} else {
baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request);
Optional<Cookie> prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME);
if (prevUrlOpt.isPresent()) {
baseUrl += prevUrlOpt.get().getValue();
CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME);
}
}
try { try {
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication;
@ -108,7 +99,7 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS
clearAuthenticationAttributes(request, response); clearAuthenticationAttributes(request, response);
JwtPair tokenPair = tokenFactory.createTokenPair(securityUser); JwtPair tokenPair = tokenFactory.createTokenPair(securityUser);
getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(baseUrl, tokenPair)); getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(baseUrl + prevUri, tokenPair));
systemSecurityService.logLoginAction(securityUser, new RestAuthenticationDetails(request), ActionType.LOGIN, oauth2Client.getName(), null); systemSecurityService.logLoginAction(securityUser, new RestAuthenticationDetails(request), ActionType.LOGIN, oauth2Client.getName(), null);
} catch (Exception e) { } catch (Exception e) {
log.debug("Error occurred during processing authentication success result. " + log.debug("Error occurred during processing authentication success result. " +
@ -125,6 +116,31 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS
} }
} }
String getBaseUrl(HttpServletRequest request, String callbackUrlScheme) {
if (!StringUtils.isEmpty(callbackUrlScheme)) {
return callbackUrlScheme + ":";
}
return this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request);
}
/**
* The in-app path the user was on before the login, or an empty string. A present cookie is dropped whether or
* not its value passes validation - it is only meant to survive a single login round trip. The path is kept out
* of the base URL so that the error redirect, which appends its own path, stays routable.
*/
String getPrevUri(HttpServletRequest request, HttpServletResponse response, String callbackUrlScheme) {
if (!StringUtils.isEmpty(callbackUrlScheme)) {
return "";
}
Optional<Cookie> prevUriOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME);
if (prevUriOpt.isEmpty()) {
return "";
}
String prevUri = prevUriOpt.get().getValue();
CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME);
return PrevUriValidator.isValid(prevUri) ? prevUri : "";
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request); super.clearAuthenticationAttributes(request);
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response); httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
@ -133,6 +149,8 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS
String getRedirectUrl(String baseUrl, JwtPair tokenPair) { String getRedirectUrl(String baseUrl, JwtPair tokenPair) {
if (baseUrl.indexOf("?") > 0) { if (baseUrl.indexOf("?") > 0) {
baseUrl += "&"; baseUrl += "&";
} else if (baseUrl.endsWith("/")) {
baseUrl += "?";
} else { } else {
baseUrl += "/?"; baseUrl += "/?";
} }

66
application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidator.java

@ -0,0 +1,66 @@
/**
* Copyright © 2016-2026 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.auth.oauth2;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.StringUtils;
import java.util.Locale;
@Slf4j
public class PrevUriValidator {
private static final int MAX_LENGTH = 2048;
private static final int MAX_LOGGED_LENGTH = 128;
public static boolean isValid(String prevUri) {
if (StringUtils.isEmpty(prevUri)) {
return false;
}
if (!isInAppPath(prevUri)) {
log.debug("Ignoring prevUri that is not an in-app path: [{}]", forLog(prevUri));
return false;
}
return true;
}
/**
* prevUri is appended to the platform base URL, which ends right after the authority, so the single leading '/'
* is what keeps the redirect on this host - it closes the authority before any of the value is read. The rest
* keeps an accepted value usable: it has to survive the cookie round trip (RFC 6265 allows neither control
* characters nor '"', ',', ';', '\' or non-ASCII) and to pass StrictHttpFirewall, which rejects '//', '%2f'
* and '%5c' in the path; a fragment would swallow the access token.
*/
private static boolean isInAppPath(String prevUri) {
if (prevUri.length() > MAX_LENGTH || prevUri.charAt(0) != '/') {
return false;
}
for (int i = 0; i < prevUri.length(); i++) {
char c = prevUri.charAt(i);
if (c <= ' ' || c >= 127 || c == '"' || c == ',' || c == ';' || c == '\\' || c == '#') {
return false;
}
}
String path = StringUtils.substringBefore(prevUri, "?").toLowerCase(Locale.ROOT);
return !path.contains("//") && !path.contains("%2f") && !path.contains("%5c");
}
// a rejected value is attacker-controlled: it must not be able to forge log lines
private static String forLog(String prevUri) {
return prevUri.substring(0, Math.min(prevUri.length(), MAX_LOGGED_LENGTH)).replaceAll("[^\\x20-\\x7E]", "?");
}
}

6
application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java

@ -26,6 +26,7 @@ import io.jsonwebtoken.security.SignatureException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.service.security.auth.oauth2.CallbackUrlSchemeValidator;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
@ -57,13 +58,16 @@ public class OAuth2AppTokenFactory {
if (timeDiff > MAX_EXPIRATION_TIME_DIFF_MS) { if (timeDiff > MAX_EXPIRATION_TIME_DIFF_MS) {
throw new IllegalArgumentException("Application token expiration time can't be longer than 5 minutes"); throw new IllegalArgumentException("Application token expiration time can't be longer than 5 minutes");
} }
if (!claims.getIssuer().equals(appPackage)) { if (!appPackage.equals(claims.getIssuer())) {
throw new IllegalArgumentException("Application token issuer doesn't match application package"); throw new IllegalArgumentException("Application token issuer doesn't match application package");
} }
String callbackUrlScheme = claims.get(CALLBACK_URL_SCHEME, String.class); String callbackUrlScheme = claims.get(CALLBACK_URL_SCHEME, String.class);
if (StringUtils.isEmpty(callbackUrlScheme)) { if (StringUtils.isEmpty(callbackUrlScheme)) {
throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme"); throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme");
} }
if (!CallbackUrlSchemeValidator.isValid(callbackUrlScheme)) {
throw new IllegalArgumentException("Application token has invalid callbackUrlScheme");
}
return callbackUrlScheme; return callbackUrlScheme;
} }

81
application/src/test/java/org/thingsboard/server/controller/AdminControllerMailOAuth2Test.java

@ -0,0 +1,81 @@
/**
* Copyright © 2016-2026 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.controller;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.service.security.system.SystemSecurityService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME;
@ExtendWith(MockitoExtension.class)
public class AdminControllerMailOAuth2Test {
private static final String BASE_URL = "https://thingsboard.example.com";
private static final String DEFAULT_PREV_URI = "/settings/outgoing-mail";
@Mock
private SystemSecurityService systemSecurityService;
@InjectMocks
private AdminController adminController;
private HttpServletRequest request;
@BeforeEach
public void before() {
request = mock(HttpServletRequest.class);
when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL);
}
@Test
public void testInAppPathIsTakenFromPrevUriCookie() {
givenPrevUriCookie("/settings/notifications?tab=1");
assertThat(adminController.getMailOAuth2RedirectUrl(request)).isEqualTo(BASE_URL + "/settings/notifications?tab=1");
}
@ParameterizedTest
@ValueSource(strings = {"@evil.com/", "//evil.com", "https://evil.com", "/\\evil.com", "/settings#fragment"})
public void testForgedPrevUriCookieIsIgnored(String prevUri) {
givenPrevUriCookie(prevUri);
assertThat(adminController.getMailOAuth2RedirectUrl(request)).isEqualTo(BASE_URL + DEFAULT_PREV_URI);
}
@Test
public void testRedirectUrlWithoutPrevUriCookie() {
when(request.getCookies()).thenReturn(null);
assertThat(adminController.getMailOAuth2RedirectUrl(request)).isEqualTo(BASE_URL + DEFAULT_PREV_URI);
}
private void givenPrevUriCookie(String prevUri) {
when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(PREV_URI_COOKIE_NAME, prevUri)});
}
}

33
application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java

@ -17,6 +17,7 @@ package org.thingsboard.server.controller;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.http.Cookie;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomStringUtils;
import org.junit.Test; import org.junit.Test;
@ -38,6 +39,8 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_PARAMETER;
@Slf4j @Slf4j
@DaoSqlTest @DaoSqlTest
@ -111,6 +114,36 @@ public class AdminControllerTest extends AbstractControllerTest {
.andExpect(statusReason(containsString("is prohibited"))); .andExpect(statusReason(containsString("is prohibited")));
} }
@Test
public void testMailOAuth2AuthorizationStoresOnlyInAppPrevUri() throws Exception {
loginSysAdmin();
AdminSettings mailSettings = doGet("/api/admin/settings/mail", AdminSettings.class);
JsonNode originalJsonValue = mailSettings.getJsonValue();
try {
ObjectNode jsonValue = JacksonUtil.fromString(originalJsonValue.toString(), ObjectNode.class);
jsonValue.put("clientId", "clientId");
jsonValue.put("authUri", "https://accounts.google.com/o/oauth2/v2/auth");
jsonValue.put("redirectUri", "https://thingsboard.io/api/admin/mail/oauth2/code");
jsonValue.set("scope", JacksonUtil.newArrayNode().add("https://mail.google.com/"));
mailSettings.setJsonValue(jsonValue);
doPost("/api/admin/settings", mailSettings, AdminSettings.class);
Cookie prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?" + PREV_URI_PARAMETER + "=@evil.com/")
.andExpect(status().isOk()).andReturn().getResponse().getCookie(PREV_URI_COOKIE_NAME);
assertThat(prevUriCookie).isNull();
prevUriCookie = doGet("/api/admin/mail/oauth2/authorize?" + PREV_URI_PARAMETER + "=/settings/outgoing-mail")
.andExpect(status().isOk()).andReturn().getResponse().getCookie(PREV_URI_COOKIE_NAME);
assertThat(prevUriCookie).isNotNull();
assertThat(prevUriCookie.getValue()).isEqualTo("/settings/outgoing-mail");
} finally {
// the mail settings are shared by the whole test context
AdminSettings currentSettings = doGet("/api/admin/settings/mail", AdminSettings.class);
currentSettings.setJsonValue(originalJsonValue);
doPost("/api/admin/settings", currentSettings, AdminSettings.class);
}
}
@Test @Test
public void testSendTestMail() throws Exception { public void testSendTestMail() throws Exception {
Mockito.doNothing().when(mailService).sendTestMail(any(), anyString()); Mockito.doNothing().when(mailService).sendTestMail(any(), anyString());

81
application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CallbackUrlSchemeValidatorTest.java

@ -0,0 +1,81 @@
/**
* Copyright © 2016-2026 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.auth.oauth2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import static org.assertj.core.api.Assertions.assertThat;
public class CallbackUrlSchemeValidatorTest {
@ParameterizedTest
@ValueSource(strings = {"tbmobile", "tb-mobile.app1", "TbMobile+1", "org.mycompany.myapp.auth", "com.my_company.app.auth"})
public void testMobileAppSchemeIsValid(String callbackUrlScheme) {
assertThat(CallbackUrlSchemeValidator.isValid(callbackUrlScheme)).isTrue();
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {
"https://evil.com",
"http://evil.com",
"https",
"HTTPS",
"javascript",
"data",
"file",
"vbscript",
"//evil.com",
"tbmobile/evil.com",
"tbmobile:evil.com",
"tbmobile evil",
"1tbmobile",
"tbmobile@evil.com"
})
public void testInvalidSchemeIsRejected(String callbackUrlScheme) {
assertThat(CallbackUrlSchemeValidator.isValid(callbackUrlScheme)).isFalse();
}
@Test
public void testValidSchemeIsTakenFromAuthorizationRequest() {
assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(givenAuthorizationRequest("tbmobile"))).isEqualTo("tbmobile");
}
@Test
public void testForgedSchemeFromAuthorizationRequestIsIgnored() {
assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(givenAuthorizationRequest("https://evil.com"))).isNull();
}
@Test
public void testAuthorizationRequestWithoutScheme() {
assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(givenAuthorizationRequest(null))).isNull();
assertThat(CallbackUrlSchemeValidator.getCallbackUrlScheme(null)).isNull();
}
private OAuth2AuthorizationRequest givenAuthorizationRequest(String callbackUrlScheme) {
OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri("testUri").clientId("testId");
if (callbackUrlScheme != null) {
builder.attributes(attributes -> attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme));
}
return builder.build();
}
}

69
application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepositoryTest.java

@ -0,0 +1,69 @@
/**
* Copyright © 2016-2026 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.auth.oauth2;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_PARAMETER;
public class HttpCookieOAuth2AuthorizationRequestRepositoryTest {
private final HttpCookieOAuth2AuthorizationRequestRepository repository = new HttpCookieOAuth2AuthorizationRequestRepository();
@Test
public void testPrevUriSavedForInAppPath() {
assertThat(savePrevUri("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState"))
.isEqualTo("/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"@evil.com/"})
public void testPrevUriNotSavedForInvalidValue(String prevUri) {
assertThat(savePrevUri(prevUri)).isNull();
}
private String savePrevUri(String prevUri) {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
when(request.getParameter(PREV_URI_PARAMETER)).thenReturn(prevUri);
repository.saveAuthorizationRequest(OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri("testUri").clientId("testId").build(), request, response);
ArgumentCaptor<Cookie> cookieCaptor = ArgumentCaptor.forClass(Cookie.class);
verify(response, atLeastOnce()).addCookie(cookieCaptor.capture());
return cookieCaptor.getAllValues().stream()
.filter(cookie -> PREV_URI_COOKIE_NAME.equals(cookie.getName()))
.map(Cookie::getValue)
.findAny().orElse(null);
}
}

103
application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandlerTest.java

@ -0,0 +1,103 @@
/**
* Copyright © 2016-2026 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.auth.oauth2;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.service.security.system.SystemSecurityService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class Oauth2AuthenticationFailureHandlerTest {
private static final String BASE_URL = "https://thingsboard.example.com";
private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository =
mock(HttpCookieOAuth2AuthorizationRequestRepository.class);
private final SystemSecurityService systemSecurityService = mock(SystemSecurityService.class);
private final Oauth2AuthenticationFailureHandler failureHandler =
new Oauth2AuthenticationFailureHandler(authorizationRequestRepository, systemSecurityService);
private HttpServletRequest request;
private HttpServletResponse response;
@BeforeEach
public void before() {
request = mock(HttpServletRequest.class);
response = mock(HttpServletResponse.class);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL);
}
@Test
public void testErrorIsSentToMobileAppScheme() throws Exception {
givenCallbackUrlScheme("tbmobile");
assertThat(sendFailure()).isEqualTo("tbmobile:/?error=someError");
}
/**
* The scheme is restored from the oauth2_auth_request cookie, so a forged one must not turn the error redirect
* into a link to another host.
*/
@ParameterizedTest
@ValueSource(strings = {"https://evil.com", "javascript"})
public void testForgedCallbackUrlSchemeFallsBackToLoginPage(String callbackUrlScheme) throws Exception {
givenCallbackUrlScheme(callbackUrlScheme);
assertThat(sendFailure()).isEqualTo(BASE_URL + "/login?loginError=someError");
}
@Test
public void testErrorIsSentToLoginPageWithoutCallbackUrlScheme() throws Exception {
givenCallbackUrlScheme(null);
assertThat(sendFailure()).isEqualTo(BASE_URL + "/login?loginError=someError");
}
@Test
public void testErrorIsSentToLoginPageWithoutAuthorizationRequest() throws Exception {
assertThat(sendFailure()).isEqualTo(BASE_URL + "/login?loginError=someError");
}
private void givenCallbackUrlScheme(String callbackUrlScheme) {
when(authorizationRequestRepository.loadAuthorizationRequest(request)).thenReturn(
OAuth2AuthorizationRequest.authorizationCode().authorizationUri("testUri").clientId("testId")
.attributes(attributes -> attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme))
.build());
}
private String sendFailure() throws Exception {
failureHandler.onAuthenticationFailure(request, response, new AuthenticationServiceException("someError"));
ArgumentCaptor<String> redirectCaptor = ArgumentCaptor.forClass(String.class);
verify(response).sendRedirect(redirectCaptor.capture());
return redirectCaptor.getValue();
}
}

186
application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java

@ -15,54 +15,178 @@
*/ */
package org.thingsboard.server.service.security.auth.oauth2; package org.thingsboard.server.service.security.auth.oauth2;
import org.junit.Before; import jakarta.servlet.http.Cookie;
import org.junit.Test; import jakarta.servlet.http.HttpServletRequest;
import org.mockito.Mock; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired; import org.junit.jupiter.api.BeforeEach;
import org.thingsboard.server.common.data.id.UserId; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.oauth2.MapperType;
import org.thingsboard.server.common.data.oauth2.OAuth2Client;
import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig;
import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.oauth2.OAuth2ClientService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.system.SystemSecurityService;
import java.time.Instant;
import java.util.UUID; import java.util.UUID;
import static org.junit.Assert.assertEquals; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME;
@DaoSqlTest public class Oauth2AuthenticationSuccessHandlerTest {
public class Oauth2AuthenticationSuccessHandlerTest extends AbstractControllerTest {
@Autowired private static final String BASE_URL = "https://thingsboard.example.com";
private Oauth2AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler; private static final String PREV_URI = "/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e";
private static final JwtPair TOKEN_PAIR = new JwtPair("testAccessToken", "testRefreshToken");
@Mock private final JwtTokenFactory tokenFactory = mock(JwtTokenFactory.class);
private JwtTokenFactory jwtTokenFactory; private final OAuth2ClientMapperProvider oauth2ClientMapperProvider = mock(OAuth2ClientMapperProvider.class);
private final OAuth2ClientService oAuth2ClientService = mock(OAuth2ClientService.class);
private final OAuth2AuthorizedClientService oAuth2AuthorizedClientService = mock(OAuth2AuthorizedClientService.class);
private final SystemSecurityService systemSecurityService = mock(SystemSecurityService.class);
private final Oauth2AuthenticationSuccessHandler successHandler = new Oauth2AuthenticationSuccessHandler(
tokenFactory, oauth2ClientMapperProvider, oAuth2ClientService, oAuth2AuthorizedClientService,
mock(HttpCookieOAuth2AuthorizationRequestRepository.class), systemSecurityService);
private SecurityUser securityUser; private HttpServletRequest request;
private HttpServletResponse response;
@Before @BeforeEach
public void before() { public void before() {
UserId userId = new UserId(UUID.randomUUID()); request = mock(HttpServletRequest.class);
securityUser = new SecurityUser(userId); response = mock(HttpServletResponse.class);
when(jwtTokenFactory.createTokenPair(eq(securityUser))).thenReturn(new JwtPair("testAccessToken", "testRefreshToken")); when(systemSecurityService.getBaseUrl(any(TenantId.class), any(CustomerId.class), any(HttpServletRequest.class))).thenReturn(BASE_URL);
when(response.encodeRedirectURL(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
} }
@Test @Test
public void testGetRedirectUrl() { public void testInAppPathIsTakenFromPrevUriCookie() {
JwtPair jwtPair = jwtTokenFactory.createTokenPair(securityUser); givenPrevUriCookie(PREV_URI + "?state=someState");
assertThat(successHandler.getBaseUrl(request, null)).isEqualTo(BASE_URL);
assertThat(successHandler.getPrevUri(request, response, null)).isEqualTo(PREV_URI + "?state=someState");
}
String urlWithoutParams = "http://localhost:8080/dashboardGroups/3fa13530-6597-11ed-bd76-8bd591f0ec3e"; @ParameterizedTest
String urlWithParams = "http://localhost:8080/dashboardGroups/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1"; @ValueSource(strings = {"@evil.com/", "//evil.com", "https://evil.com", "/\\evil.com", "/dashboards#fragment"})
public void testForgedPrevUriCookieIsIgnored(String prevUri) {
givenPrevUriCookie(prevUri);
assertThat(successHandler.getPrevUri(request, response, null)).isEmpty();
}
String redirectUrl = oauth2AuthenticationSuccessHandler.getRedirectUrl(urlWithoutParams, jwtPair); @Test
String expectedUrl = urlWithoutParams + "/?accessToken=" + jwtPair.getToken() + "&refreshToken=" + jwtPair.getRefreshToken(); public void testForgedPrevUriCookieIsDeleted() {
assertEquals(expectedUrl, redirectUrl); givenPrevUriCookie("@evil.com/");
redirectUrl = oauth2AuthenticationSuccessHandler.getRedirectUrl(urlWithParams, jwtPair); successHandler.getPrevUri(request, response, null);
expectedUrl = urlWithParams + "&accessToken=" + jwtPair.getToken() + "&refreshToken=" + jwtPair.getRefreshToken();
assertEquals(expectedUrl, redirectUrl); ArgumentCaptor<Cookie> cookieCaptor = ArgumentCaptor.forClass(Cookie.class);
verify(response).addCookie(cookieCaptor.capture());
assertThat(cookieCaptor.getValue().getName()).isEqualTo(PREV_URI_COOKIE_NAME);
assertThat(cookieCaptor.getValue().getMaxAge()).isZero();
} }
}
@Test
public void testBaseUrlWithoutPrevUriCookie() {
when(request.getCookies()).thenReturn(null);
assertThat(successHandler.getBaseUrl(request, null)).isEqualTo(BASE_URL);
assertThat(successHandler.getPrevUri(request, response, null)).isEmpty();
}
@Test
public void testCallbackUrlSchemeIgnoresPrevUri() {
givenPrevUriCookie(PREV_URI);
assertThat(successHandler.getBaseUrl(request, "tbmobile")).isEqualTo("tbmobile:");
assertThat(successHandler.getPrevUri(request, response, "tbmobile")).isEmpty();
}
@Test
public void testSuccessRedirectCarriesTokensToPrevUri() throws Exception {
givenPrevUriCookie(PREV_URI);
givenSuccessfulLogin();
successHandler.onAuthenticationSuccess(request, response, givenAuthentication());
assertThat(captureRedirect()).isEqualTo(BASE_URL + PREV_URI +
"/?accessToken=testAccessToken&refreshToken=testRefreshToken");
}
/**
* The error redirect appends its own path, so it must be built from the base URL alone - with prevUri in it the
* result would be an unroutable https://host/dashboards/x/login?loginError=...
*/
@Test
public void testErrorRedirectDropsPrevUri() throws Exception {
givenPrevUriCookie(PREV_URI);
when(oAuth2ClientService.findOAuth2ClientById(any(), any())).thenThrow(new RuntimeException("someError"));
successHandler.onAuthenticationSuccess(request, response, givenAuthentication());
assertThat(captureRedirect()).isEqualTo(BASE_URL + "/login?loginError=someError");
}
@ParameterizedTest
@CsvSource({
"https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e, https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e/?",
"https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1, https://thingsboard.example.com/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1&",
"https://thingsboard.example.com/, https://thingsboard.example.com/?"
})
public void testGetRedirectUrl(String baseUrl, String expectedPrefix) {
assertThat(successHandler.getRedirectUrl(baseUrl, TOKEN_PAIR))
.isEqualTo(expectedPrefix + "accessToken=testAccessToken&refreshToken=testRefreshToken");
}
private void givenPrevUriCookie(String prevUri) {
when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(PREV_URI_COOKIE_NAME, prevUri)});
}
private OAuth2AuthenticationToken givenAuthentication() {
OAuth2User principal = mock(OAuth2User.class);
when(principal.getName()).thenReturn("testUser");
OAuth2AuthenticationToken token = mock(OAuth2AuthenticationToken.class);
when(token.getAuthorizedClientRegistrationId()).thenReturn(UUID.randomUUID().toString());
when(token.getPrincipal()).thenReturn(principal);
return token;
}
private void givenSuccessfulLogin() {
OAuth2Client oauth2Client = new OAuth2Client();
oauth2Client.setMapperConfig(OAuth2MapperConfig.builder().type(MapperType.BASIC).build());
when(oAuth2ClientService.findOAuth2ClientById(any(), any())).thenReturn(oauth2Client);
OAuth2AuthorizedClient authorizedClient = mock(OAuth2AuthorizedClient.class);
when(authorizedClient.getAccessToken()).thenReturn(new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
"testProviderAccessToken", Instant.now(), Instant.now().plusSeconds(60)));
when(oAuth2AuthorizedClientService.loadAuthorizedClient(anyString(), anyString())).thenReturn(authorizedClient);
SecurityUser securityUser = mock(SecurityUser.class);
OAuth2ClientMapper mapper = mock(OAuth2ClientMapper.class);
when(mapper.getOrCreateUserByClientPrincipal(any(), any(), anyString(), any())).thenReturn(securityUser);
when(oauth2ClientMapperProvider.getOAuth2ClientMapperByType(any())).thenReturn(mapper);
when(tokenFactory.createTokenPair(securityUser)).thenReturn(TOKEN_PAIR);
}
private String captureRedirect() throws Exception {
ArgumentCaptor<String> redirectCaptor = ArgumentCaptor.forClass(String.class);
verify(response).sendRedirect(redirectCaptor.capture());
return redirectCaptor.getValue();
}
}

74
application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/PrevUriValidatorTest.java

@ -0,0 +1,74 @@
/**
* Copyright © 2016-2026 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.auth.oauth2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
public class PrevUriValidatorTest {
@ParameterizedTest
@ValueSource(strings = {
"/",
"/login",
"/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e",
"/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=someState&page=1",
"/settings/outgoing-mail",
"/some%20path?q=a+b",
"/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e?state=W3siaWQiOiJhL2IifV0%3D"
})
public void testValidPrevUri(String prevUri) {
assertThat(PrevUriValidator.isValid(prevUri)).isTrue();
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {
"@evil.com/",
"evil.com",
"https://evil.com",
"//evil.com",
"/\\evil.com",
"/\tevil.com",
"/ evil.com",
"/dashboards\\..\\evil.com",
"/login\nLocation: https://evil.com",
"/login\r\nSet-Cookie: a=b",
"/dashboards//evil.com",
"/dashboards%2Fevil.com",
"/dashboards%5cevil.com",
"/dashboards;jsessionid=1",
"/dashboards?title=a,b",
"/dashboards,list",
"/dashboards\"list",
"/dashboards/3fa13530-6597-11ed-bd76-8bd591f0ec3e#fragment",
"/панель"
})
public void testInvalidPrevUri(String prevUri) {
assertThat(PrevUriValidator.isValid(prevUri)).isFalse();
}
@Test
public void testPrevUriLengthLimit() {
assertThat(PrevUriValidator.isValid("/" + "a".repeat(2047))).isTrue();
assertThat(PrevUriValidator.isValid("/" + "a".repeat(2048))).isFalse();
}
}

72
application/src/test/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactoryTest.java

@ -0,0 +1,72 @@
/**
* Copyright © 2016-2026 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.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class OAuth2AppTokenFactoryTest {
private static final String APP_PACKAGE = "org.thingsboard.demo.app";
private static final byte[] KEY_BYTES = "yjNyylzT1TmiVE2jV3YTnUpZzwLLLdPDJKmhLNyXDPnLtVCLcJIjIGmDPKHNoDMK".getBytes();
private final OAuth2AppTokenFactory tokenFactory = new OAuth2AppTokenFactory();
@Test
public void testMobileAppSchemeIsAccepted() {
assertThat(validate(appToken("tb-mobile.app1", APP_PACKAGE))).isEqualTo("tb-mobile.app1");
}
@Test
public void testInvalidCallbackUrlSchemeIsRejected() {
assertThatThrownBy(() -> validate(appToken("https://evil.com", APP_PACKAGE)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("callbackUrlScheme");
}
@Test
public void testTokenWithoutIssuerIsRejected() {
assertThatThrownBy(() -> validate(appToken("tbmobile", null)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("issuer");
}
private String appToken(String callbackUrlScheme, String issuer) {
JwtBuilder builder = Jwts.builder()
.expiration(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1)))
.claim("callbackUrlScheme", callbackUrlScheme);
if (issuer != null) {
builder.issuer(issuer);
}
SecretKey key = Keys.hmacShaKeyFor(KEY_BYTES);
return builder.signWith(key).compact();
}
private String validate(String appToken) {
return tokenFactory.validateTokenAndGetCallbackUrlScheme(APP_PACKAGE, appToken, Base64.getEncoder().encodeToString(KEY_BYTES));
}
}
Loading…
Cancel
Save