I want to implement a feature that user connects his account with external applications (similar feature is in Facebook). User has to log in to external application and grant permission to access data by my application.
Once user connected an external app, data will be exchanged in background using access and refresh tokens.
Application architecture is:
SPA front-end (Angular)
REST API (Spring), multiple nodes
ScyllaDB
Envoy proxy (with JWT verification)
The first idea is to use Spring OAuth2 Client. However, some changes need to be made:
there is no Principal because JWT is verified by Envoy proxy and X-USER-ID header is added
REST API is stateless and we shouldn't store authorization code in session
even with sessions, there are multiple nodes and we need to share authorization code between nodes
custom URL, e.g. /app_name/connect instead of /oauth2/authorization/app_name
redirect URL may be invalid (but it's verified by Spring's filter)
How this could work:
user click "Connect with app" in SPA
SPA redirects user to /oauth2/authorization/app_name (or custom URL)
Spring redirects user to external app's authentication server
user authenticates and grants permissions
external app redirects user back to Spring (or straight to SPA?)
Spring redirects user back to SPA (or SPA sends access token to REST API?)
Despite Spring Security components can be replaced, many of them are coupled and you need to rewrite OAuth2 Client flow almost from scratch. Maybe I'm doing something wrong and it can be achieved easier.
What I already did:
http
.cors().and()
.csrf().disable()
.authorizeRequests().anyRequest().permitAll().and()
.oauth2Client(); // get rid of these two filters?
#Configuration
#RequiredArgsConstructor
public class OAuth2ClientConfig {
private final CassandraTemplate cassandraTemplate;
// overriding original client service - we need to store tokens in database
#Bean
public OAuth2AuthorizedClientService authorizedClientService(
CassandraTemplate cassandraTemplate,
ClientRegistrationRepository clientRegistrationRepository) {
return new ScyllaOAuth2AuthorizedClientService(cassandraTemplate, clientRegistrationRepository);
}
// configure client provider to use authorization code with refresh token
#Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
var authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
var authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
// the specs recommend to use WebClient for exchanging data instead of RestTemplate
#Bean
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
return WebClient.builder()
.apply(oauth2Client.oauth2Configuration())
.build();
}
// override request repository - and I'm stuck there
#Bean
public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() {
return new ScyllaOAuth2AuthorizationRequestRepository(cassandraTemplate);
}
}
Because there are multiple nodes of REST API, we can't use sessions. We need to store request somewhere, e.g. ScyllaDB, Redis, Hazelcast, etc. I decided to store it as JSON in ScyllaDB but I ran into trouble.
#Slf4j
#RequiredArgsConstructor
public final class ScyllaOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
private final CassandraTemplate cassandraTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
#Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
Assert.notNull(request, "request cannot be null");
var stateParameter = this.getStateParameter(request);
if (stateParameter == null) {
return null;
}
return this.getAuthorizationRequest(request, stateParameter);
}
#Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request,
HttpServletResponse response) {
Assert.notNull(request, "request cannot be null");
Assert.notNull(response, "response cannot be null");
if (authorizationRequest == null) {
this.removeAuthorizationRequest(request, response);
return;
}
var state = authorizationRequest.getState();
var userId = UUID.fromString(request.getHeader(Constants.USER_ID));
Assert.hasText(state, "authorizationRequest.state cannot be empty");
try {
// serialization of Auth2AuthorizationRequest to JSON works
cassandraTemplate.getCqlOperations().execute("insert into oauth2_requests (user_id,state,data) values (?,?,?)",
userId, state, objectMapper.writeValueAsString(authorizationRequest));
} catch (JsonProcessingException e) {
log.warn("Unable to save authorization request", e);
}
}
#Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
Assert.notNull(request, "request cannot be null");
var stateParameter = this.getStateParameter(request);
if (stateParameter == null) {
return null;
}
var userId = UUID.fromString(request.getHeader(Constants.USER_ID));
var originalRequest = this.getAuthorizationRequest(request, stateParameter);
cassandraTemplate.getCqlOperations().execute("delete from oauth2_requests where user_id=? and state=?",
userId, stateParameter);
return originalRequest;
}
private String getStateParameter(HttpServletRequest request) {
return request.getParameter(OAuth2ParameterNames.STATE);
}
private UUID getUserId(HttpServletRequest request) {
return UUID.fromString(request.getHeader(Constants.USER_ID));
}
private OAuth2AuthorizationRequest getAuthorizationRequest(HttpServletRequest request, String state) {
var userId = getUserId(request);
var jsonRequest = cassandraTemplate.getCqlOperations().queryForObject(
"select data from oauth2_requests where user_id=? and state=?", String.class, userId, state);
if (StringUtils.isNotBlank(jsonRequest)) {
try {
// trying to mess with OAuth2ClientJackson2Module
var objectMapper = new Jackson2ObjectMapperBuilder().autoDetectFields(true)
.autoDetectGettersSetters(true)
.modules(new OAuth2ClientJackson2Module())
.visibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY)
.build();
return objectMapper.readValue(jsonRequest, OAuth2AuthorizationRequest.class);
} catch (JsonProcessingException e) {
log.warn("Error decoding authentication request", e);
}
}
return null;
}
}
I get error when trying to deserialize JSON to OAuth2AuthorizationRequest:
Missing type id when trying to resolve subtype of [simple type, class org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest]: missing type id property '#class'
Without adding OAuth2ClientJackson2Module there is another error:
Cannot construct instance of `org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
By the way, OAuth2ClientJackson2Module seems never used in original filters.
Maybe it's better to serialize this object Java way and store it as BLOB or do not store request in database but somewhere other.
Another part is the controller action:
// it had to be /apps/app_name/connect but in Spring OAuth2 Client it's hardcoded to append provider name at the end
#GetMapping("/apps/connect/app_name")
public void connect(HttpServletRequest request, HttpServletResponse response) throws IOException {
userAppService.authorize(request, response, "app_name");
}
To get rid of filters which verify redirect URL and have many things hardcoded:
#Service
#RequiredArgsConstructor
public class UserAppService {
private final HttpSecurity httpSecurity;
private final AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
private final AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository;
private final ClientRegistrationRepository clientRegistrationRepository;
private final OAuth2AuthorizedClientManager authorizedClientManager;
private final OAuth2AuthorizedClientRepository authorizedClientRepository;
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
public void authorize(HttpServletRequest request, HttpServletResponse response, String appName) throws IOException {
var userId = UUID.fromString(request.getHeader(Constants.USER_ID));
var authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId(appName)
.principal(UUIDPrincipal.fromUserId(userId))
.build();
if (isAuthorizationResponse(request)) {
var authorizationRequest = this.authorizationRequestRepository.loadAuthorizationRequest(request);
if (authorizationRequest != null) {
processAuthorizationRequest(request, response);
}
} else {
try {
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (authorizedClient != null) {
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
System.out.println(accessToken);
}
} catch (ClientAuthorizationException e) {
// in this URL provider name is appended at the end and no way to change this behavior
var authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository,
"/apps/connect");
var authorizationRequest = authorizationRequestResolver.resolve(request);
this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
this.redirectStrategy.sendRedirect(request, response, authorizationRequest.getAuthorizationRequestUri());
}
}
}
private void processAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) throws IOException {
var authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest(request, response);
var registrationId = (String) authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
var clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
var params = toMultiMap(request.getParameterMap());
var redirectUri = UrlUtils.buildFullRequestUrl(request);
var authorizationResponse = convert(params, redirectUri);
var authenticationRequest = new OAuth2AuthorizationCodeAuthenticationToken(
clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
OAuth2AuthorizationCodeAuthenticationToken authenticationResult;
try {
var authenticationManager = httpSecurity.getSharedObject(AuthenticationManager.class);
authenticationResult = (OAuth2AuthorizationCodeAuthenticationToken) authenticationManager
.authenticate(authenticationRequest);
} catch (OAuth2AuthorizationException ex) {
OAuth2Error error = ex.getError();
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(authorizationRequest.getRedirectUri())
.queryParam(OAuth2ParameterNames.ERROR, error.getErrorCode());
if (!StringUtils.hasText(error.getDescription())) {
uriBuilder.queryParam(OAuth2ParameterNames.ERROR_DESCRIPTION, error.getDescription());
}
if (!StringUtils.hasText(error.getUri())) {
uriBuilder.queryParam(OAuth2ParameterNames.ERROR_URI, error.getUri());
}
this.redirectStrategy.sendRedirect(request, response, uriBuilder.build().encode().toString());
return;
}
// just copy-paste of original filter - trying to understand what's happening there
Authentication currentAuthentication = SecurityContextHolder.getContext().getAuthentication();
String principalName = (currentAuthentication != null) ? currentAuthentication.getName() : "anonymousUser";
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
authenticationResult.getClientRegistration(), principalName, authenticationResult.getAccessToken(),
authenticationResult.getRefreshToken());
this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, currentAuthentication, request,
response);
String redirectUrl = authorizationRequest.getRedirectUri();
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
}
private static boolean isAuthorizationResponse(HttpServletRequest request) {
return isAuthorizationResponseSuccess(request) || isAuthorizationResponseError(request);
}
private static boolean isAuthorizationResponseSuccess(HttpServletRequest request) {
return StringUtils.hasText(request.getParameter(OAuth2ParameterNames.CODE))
&& StringUtils.hasText(request.getParameter(OAuth2ParameterNames.STATE));
}
private static boolean isAuthorizationResponseError(HttpServletRequest request) {
return StringUtils.hasText(request.getParameter(OAuth2ParameterNames.ERROR))
&& StringUtils.hasText(request.getParameter(OAuth2ParameterNames.STATE));
}
// copy paste - not tested this code yet
static MultiValueMap<String, String> toMultiMap(Map<String, String[]> map) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>(map.size());
map.forEach((key, values) -> {
if (values.length > 0) {
for (String value : values) {
params.add(key, value);
}
}
});
return params;
}
static OAuth2AuthorizationResponse convert(MultiValueMap<String, String> request, String redirectUri) {
String code = request.getFirst(OAuth2ParameterNames.CODE);
String errorCode = request.getFirst(OAuth2ParameterNames.ERROR);
String state = request.getFirst(OAuth2ParameterNames.STATE);
if (StringUtils.hasText(code)) {
return OAuth2AuthorizationResponse.success(code).redirectUri(redirectUri).state(state).build();
}
String errorDescription = request.getFirst(OAuth2ParameterNames.ERROR_DESCRIPTION);
String errorUri = request.getFirst(OAuth2ParameterNames.ERROR_URI);
return OAuth2AuthorizationResponse.error(errorCode)
.redirectUri(redirectUri)
.errorDescription(errorDescription)
.errorUri(errorUri)
.state(state)
.build();
}
}
Client service to stored authorized clients in database:
#RequiredArgsConstructor
public class ScyllaOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService {
private final CassandraTemplate cassandraTemplate;
private final ClientRegistrationRepository clientRegistrationRepository;
#Override
#SuppressWarnings("unchecked")
public OAuth2AuthorizedClient loadAuthorizedClient(String clientRegistrationId, String principal) {
var id = BasicMapId.id("userId", principal).with("appCode", clientRegistrationId);
var userApp = cassandraTemplate.selectOneById(id, UserApp.class);
if (userApp != null) {
var clientRegistration = getClientRegistration(clientRegistrationId);
var accessToken = getAccessToken(userApp);
var refreshToken = getRefreshToken(userApp);
return new OAuth2AuthorizedClient(clientRegistration, principal, accessToken, refreshToken);
} else {
return null;
}
}
#Override
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
Assert.notNull(authorizedClient, "authorizedClient cannot be null");
Assert.notNull(principal, "principal cannot be null");
var userApp = new UserApp();
userApp.setUserId((UUID) principal.getPrincipal());
userApp.setAppCode(authorizedClient.getClientRegistration().getClientId());
if (authorizedClient.getAccessToken() != null) {
userApp.setAccessToken(authorizedClient.getAccessToken().getTokenValue());
userApp.setAccessTokenType(OAuth2AccessToken.TokenType.BEARER.getValue());
userApp.setAccessTokenScopes(authorizedClient.getAccessToken().getScopes());
userApp.setAccessTokenIssuedAt(authorizedClient.getAccessToken().getIssuedAt());
userApp.setAccessTokenExpiresAt(authorizedClient.getAccessToken().getExpiresAt());
}
if (authorizedClient.getRefreshToken() != null) {
userApp.setRefreshToken(authorizedClient.getRefreshToken().getTokenValue());
userApp.setRefreshTokenIssuedAt(authorizedClient.getRefreshToken().getIssuedAt());
userApp.setRefreshTokenExpiresAt(authorizedClient.getRefreshToken().getExpiresAt());
}
cassandraTemplate.insert(userApp);
}
#Override
public void removeAuthorizedClient(String clientRegistrationId, String principal) {
var id = BasicMapId.id("userId", principal).with("appCode", clientRegistrationId);
cassandraTemplate.deleteById(id, UserApp.class);
}
private ClientRegistration getClientRegistration(String clientRegistrationId) {
var clientRegistration = this.clientRegistrationRepository.findByRegistrationId(clientRegistrationId);
if (clientRegistration == null) {
throw new DataRetrievalFailureException(
"The ClientRegistration with id '" + clientRegistrationId + "' exists in the data source, "
+ "however, it was not found in the ClientRegistrationRepository.");
}
return clientRegistration;
}
private OAuth2AccessToken getAccessToken(UserApp userApp) {
return new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
userApp.getAccessToken(),
userApp.getAccessTokenIssuedAt(),
userApp.getAccessTokenExpiresAt(),
userApp.getAccessTokenScopes());
}
private OAuth2RefreshToken getRefreshToken(UserApp userApp) {
return new OAuth2RefreshToken(userApp.getRefreshToken(), userApp.getRefreshTokenIssuedAt());
}
}
Too much code overwrite. I need to make it as simple as possible.
Currently I'm struggling with storing authorize request in database.
How to do it Spring way but to keep the app architecture given at the beginning of this question?
Any way to configure OAuth2 Client without hardcoded URL like /oauth2/authorization/provider_name?
Maybe it's better to do the whole OAuth2 flow client-side (within SPA) and the SPA should send access and request token to REST API (to store the tokens in order to be able to exchange data with external app)?
In OAuth2 wording, REST APIs are resource-servers, not clients.
What you can do is have
your proxy be transparent to OAuth2 (forward requests with their JWT access-token authorization header and responses status code)
configure each REST API as resource-server. Tutorials there: https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials.
add an OAuth2 client library to your Angular app to handle tokens and authorize requests. My favorite is angular-auth-oidc-client
probably use an intermediate authorization-server for identity federation (Google, Facebook, etc., but also internal DB, LDAP, or whatever is needed), roles management, MFA,... Keycloak is a famous "on premise" solution, but you can search for "OIDC authorization-server" in your favorite search engine and have plenty of alternate choices, including SaaS like Auth0 or Amazon Cognito.
This is fully compatible with distributed architectures and micro-services (session-less is the default configuration for resource-servers in the tutorials I linked).
Two cases for a micro-service delegating some of its processing to another resource-server:
the "child" request is made on behalf of the user who initiated the request => retrieve original access-token from Authentication instance in security-context and forward it (set it as Bearer authorization header for the sub-request)
the "child" request is not made on behalf of a user => client-credentials must be used (the micro-services acquires a new access-token in its own name to authorize the sub request). Refer to spring-boot-oauth2-client and your preferred REST client docs for details (WebClient, #FeignClient, RestTemplate).
I have 2 Spring Boot microservices. Microservice (B) calls a reactive api exposed by Microservice (A).
Microservice (A) RestController code :
#RestController
#RequestMapping(value = "/documents")
public class ElasticDocumentController {
private static final Logger LOG = LoggerFactory.getLogger(ElasticDocumentController.class);
private final ElasticQueryService elasticQueryService;
public ElasticDocumentController(ElasticQueryService queryService) {
this.elasticQueryService = queryService;
}
#GetMapping(value = "/", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ElasticQueryServiceResponseModel> getAllDocuments() {
Flux<ElasticQueryServiceResponseModel> response = elasticQueryService.getAllDocuments();
response = response.log();
LOG.info("Returning from query reactive service for all documents");
return response;
}
}
When I call the getAllDocuments() api from Postman, I can see the documents scrolling in the output cosole. So Microservice (A) is correct.
But when I call the api from Microservice (B), I cannot retrieve any documents. Microservice (B) cannot communicate with Microservice (A).
Microservice (B) Service code :
#Service
public class TwitterElasticQueryWebClient implements ElasticQueryWebClient {
private static final Logger LOG = LoggerFactory.getLogger(TwitterElasticQueryWebClient.class);
private final WebClient.Builder webClientBuilder;
private final ElasticQueryWebClientConfigData elasticQueryWebClientConfigData;
public TwitterElasticQueryWebClient(
#Qualifier("webClientBuilder") WebClient.Builder clientBuilder,
ElasticQueryWebClientConfigData configData
) {
this.webClientBuilder = clientBuilder;
this.elasticQueryWebClientConfigData = configData;
}
#Override
public Flux<ElasticQueryWebClientResponseModel> getAllData() {
LOG.info("Querying all data");
return webClientBuilder
.build()
.get()
.uri("/")
.accept(MediaType.valueOf(elasticQueryWebClientConfigData.getQuery().getAccept()))
.retrieve()
.bodyToFlux(ElasticQueryWebClientResponseModel.class);
}
}
Microservice (B) config code :
#Configuration
public class WebClientConfig {
private final ElasticQueryWebClientConfigData.WebClient webClientConfig;
public WebClientConfig(ElasticQueryWebClientConfigData webClientConfigData) {
this.webClientConfig = webClientConfigData.getWebClient();
}
#Bean("webClientBuilder")
WebClient.Builder webClientBuilder() {
return WebClient.builder()
.baseUrl(webClientConfig.getBaseUrl())
.defaultHeader(HttpHeaders.CONTENT_TYPE, webClientConfig.getContentType())
.defaultHeader(HttpHeaders.ACCEPT, webClientConfig.getAcceptType())
.clientConnector(new ReactorClientHttpConnector(HttpClient.from(getTcpClient())))
.codecs(configurer -> configurer.defaultCodecs()
.maxInMemorySize(webClientConfig.getMaxInMemorySize()));
}
private TcpClient getTcpClient() {
return TcpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, webClientConfig.getConnectTimeoutMs())
.doOnConnected(connection -> {
connection.addHandlerLast(new ReadTimeoutHandler(webClientConfig.getReadTimeoutMs(), TimeUnit.MILLISECONDS));
connection.addHandlerLast(new WriteTimeoutHandler(webClientConfig.getWriteTimeoutMs(), TimeUnit.MILLISECONDS));
});
}
}
Microservice (B) application.yml :
elastic-query-web-client:
webclient:
connect-timeout-ms: 10000
read-timeout-ms: 10000
write-timeout-ms: 10000
max-in-memory-size: 10485760 # 10MB
content-type: 'application/json'
accept-type: 'text/event-stream'
base-url: 'http://localhost:8183/reactive-elastic-query-service/documents'
query:
method: POST
uri: "/get-doc-by-text"
accept: ${elastic-query-web-client.webclient.accept-type}
server:
port: 8184
spring:
webflux:
base-path: /reactive-elastic-query-web-client
thymeleaf:
cache: false
reactive:
max-chunk-size: 8192
codec:
max-in-memory-size: 25MB
Microservice (B) controller :
#Controller
public class QueryController {
private static final Logger LOG = LoggerFactory.getLogger(QueryController.class);
private final ElasticQueryWebClient elasticQueryWebClient;
public QueryController(ElasticQueryWebClient webClient) {
this.elasticQueryWebClient = webClient;
}
#GetMapping("/all")
public String queryAll(Model model) {
Flux<ElasticQueryWebClientResponseModel> responseModels = elasticQueryWebClient.getAllData();
responseModels = responseModels.log();
IReactiveDataDriverContextVariable reactiveData = new ReactiveDataDriverContextVariable(responseModels, 1);
model.addAttribute("elasticQueryWebClientResponseModels", reactiveData);
model.addAttribute("searchText", "");
model.addAttribute("elasticQueryWebClientRequestModel", ElasticQueryWebClientRequestModel.builder().build());
LOG.info("Returning from reactive client controller for all data");
return "home";
}
}
There are no exceptions in the output consoles.
I don't see what I am missing here.
I have to migrate an existing OAuth2RestTemplate based OAuth2 microservice to Spring Security / WebClient because OAuth2RestTemplate is deprecated. However, the new implementation is unable to access the OAuth2 server through a proxy server. I've read various articles on this topic, including Spring WebClient is not using Proxy for the latest version of spring-security-oauth2-client-5.3.4.RELEASE, and tried to include the logic described in these articles in my implementation. Without success unfortunately. My implementation configures the underlying ClientHttpConnector used by WebClient with the appropriate proxy settings. See the code snippets below from WebClientConfig (the complete codebase including WebClientConfigand unit tests can be found here: github):
#Bean
public ReactiveClientRegistrationRepository clientRegistrationRepository() {
ClientRegistration clientRegistration = ClientRegistration
.withRegistrationId(REGISTRATION_ID)
.authorizationGrantType(AuthorizationGrantType.PASSWORD)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.clientAuthenticationMethod(ClientAuthenticationMethod.POST)
.userInfoAuthenticationMethod(AuthenticationMethod.FORM)
.tokenUri(restServiceProperties.getOauth2Url())
.build();
return new InMemoryReactiveClientRegistrationRepository(clientRegistration);
}
#Bean
public ReactiveOAuth2AuthorizedClientService authorizedClientService(ReactiveClientRegistrationRepository clientRegistrationRepository) {
return new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistrationRepository);
}
#Bean
public ClientHttpConnector clientHttpConnector() {
HttpAsyncClientBuilder clientBuilder = HttpAsyncClients.custom();
if (Strings.isNotEmpty(restServiceProperties.getProxyUrl())) {
try {
HttpHost proxy = HttpHost.create(restServiceProperties.getProxyUrl());
clientBuilder.setRoutePlanner(new DefaultProxyRoutePlanner(proxy));
} catch (URISyntaxException e) {
log.error("Error: {}", e.getMessage());
throw new RuntimeException(e);
}
}
CloseableHttpAsyncClient client = clientBuilder.build();
return new HttpComponentsClientHttpConnector(client);
}
#Bean
public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(ReactiveClientRegistrationRepository clientRegistrationRepository,
ReactiveOAuth2AuthorizedClientService authorizedClientService, ClientHttpConnector clientHttpConnector, WebClient.Builder webClientBuilder) {
WebClient webClient = webClientBuilder
.clientConnector(clientHttpConnector)
.filter(logRequest())
.build();
AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientService);
authorizedClientManager.setAuthorizedClientProvider(createAuthorizedClientProvider(webClient));
authorizedClientManager.setContextAttributesMapper(request -> {
Map<String, Object> contextAttributes = new HashMap<>();
contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, USER);
contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, PASSWORD);
return Mono.just(contextAttributes);
});
return authorizedClientManager;
}
#Bean
public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager,
ClientHttpConnector clientHttpConnector, WebClient.Builder webClientBuilder) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2FilterFunction = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2FilterFunction.setDefaultClientRegistrationId(REGISTRATION_ID);
return webClientBuilder
.clientConnector(clientHttpConnector)
.filters(exchangeFilterFunctions -> {
exchangeFilterFunctions.add(oauth2FilterFunction);
exchangeFilterFunctions.add(logRequest());
})
.build();
}
private ReactiveOAuth2AuthorizedClientProvider createAuthorizedClientProvider(WebClient webClient) {
WebClientReactiveClientCredentialsTokenResponseClient clientCredentialsTokenResponseClient = new WebClientReactiveClientCredentialsTokenResponseClient();
clientCredentialsTokenResponseClient.setWebClient(webClient);
return ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials(builder -> builder.accessTokenResponseClient(clientCredentialsTokenResponseClient))
.password()
.refreshToken()
.build();
}
private ExchangeFilterFunction logRequest() {
return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
log.info("Request: method: {} URL: {}", clientRequest.method(), clientRequest.url());
clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.info("Request header: name: {} value: {}", name, value)));
return Mono.just(clientRequest);
});
}
I also tried clientBuilder.setProxy(proxy) rather than clientBuilder.setRoutePlanner(new DefaultProxyRoutePlanner(proxy)), but that didn't solve the issue.
Regardig the unit tests: NoProxyServerTest shows that the OAuth2 server and downstream server can be accessed when no proxy server is configured. ProxyServerTest shows that the OAuth2 server cannot be accessed when a proxy server is configured. DirectProxyServerInvocationTest shows that the MockProxyServer is actually working. And DownstreamServerProxyServerInvocationTest shows that the downstream server can accessed through a proxy server. WebClient is configured in WebClientConfig. This is potentially the best configuration so far, but I've tried many different configurations to solve this proxy server issue (including the use of https.proxyPort, https.proxyHost, http.proxyPortand http.proxyHost).
A similar test approached is being used to test the deprecated OAuth2RestTemplate based implementation. And all four tests pass against that implementation.
I am using spring-boot version 2.0.0.M6.
I need to make async HTTP calls from spring-boot app say APP1 to another app (play framework) say APP2.
So if I need to make 20 distinct async calls from APP1 to APP2, APP2 receives 20 requests out of which few are duplicates, which means these duplicates replaced few distinct requests.
Expected:
api/v1/call/1
api/v1/call/2
api/v1/call/3
api/v1/call/4
Actual:
api/v1/call/1
api/v1/call/2
api/v1/call/4
api/v1/call/4
I am using spring reactive WebClient.
Below is the spring boot version in build.gradle
buildscript {
ext {
springBootVersion = '2.0.0.M6'
//springBootVersion = '2.0.0.BUILD-SNAPSHOT'
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
maven {url "https://plugins.gradle.org/m2/"}
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
classpath("se.transmode.gradle:gradle-docker:1.2")
}
}
My WebClient init snippet
private WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector((HttpClientOptions.Builder builder) -> builder.disablePool()))
.build();
My POST method
public <T> Mono<JsonNode> postClient(String url, T postData) {
return Mono.subscriberContext().flatMap(ctx -> {
String cookieString = ctx.getOrDefault(Constants.SubscriberContextConstnats.COOKIES, StringUtils.EMPTY);
URI uri = URI.create(url);
return webClient.post().uri(uri).body(BodyInserters.fromObject(postData)).header(HttpHeaders.COOKIE, cookieString)
.exchange().flatMap(clientResponse ->
{
return clientResponse.bodyToMono(JsonNode.class);
})
.onErrorMap(err -> new TurtleException(err.getMessage(), err))
.doOnSuccess(jsonData -> {
});
});
}
The code from where this postClient method is invoked
private void getResultByKey(PremiumRequestHandler request, String key, BrokerConfig brokerConfig) {
/* Live calls for the insurers */
LOG.info("[PREMIUM SERVICE] LIVE CALLLLL MADE FOR: " + key + " AND REQUEST ID: " + request.getRequestId());
String uri = brokerConfig.getHostUrl() + verticalResolver.determineResultUrl(request.getVertical()) + key;
LOG.info("[PREMIUM SERVICE] LIVE CALL WITH URI : " + uri + " FOR REQUEST ID: " + request.getRequestId());
Mono<PremiumResponse> premiumResponse = reactiveWebClient.postClient(uri, request.getPremiumRequest())
.map(json -> PlatformUtils.mapToClass(json, PremiumResponse.class));
premiumResponse.subscribe(resp -> {
resp.getPremiumResults().forEach(result -> {
LOG.info("Key " + result.getKey());
repository.getResultRepoRawType(request.getVertical())
.save(result).subscribe();
saveResult.subscriberContext(ctx -> {
MultiBrokerMongoDBFactory.setDatabaseNameForCurrentThread(brokerConfig.getBroker());
return ctx;
}).subscribe();
});
}, error -> {
LOG.info("[PREMIUM SERVICE] ERROR RECIEVED FOR " + key + " AND REQUEST ID" + request.getRequestId() + " > " + error.getMessage());
});
}
Had put logs at the end-point in the client code, can not see multiple requests at that point.
Probably it's a bug in WebClient where URI is getting swapped in multithreaded environment.
Tried mutating WebClient, still the URI is getting swapped
Please help.
Git Repo added github.com/praveenk007/ps-demo
I happend to have experienced similar problem:
When calling the same service (here marked as ExternalService) in parallel (webflux) sometimes the same request was being sent and the problem indeed seems to reside in Webclient.
The solution was in changing the way Webclient is being created.
Before:
Here ExternalCall is configured client which calls ExternalService. So the parallel execution here was of internalCall method. Note we pass WebClient.RequestBodySpec class to ExternalCall class
#Bean
ExternalCall externalCall(WebClient.Builder webClientBuilder) {
var exchangeStrategies = getExchangeStrategies();
var endpoint = "v1/data";
var timeout = 10000;
var uri = "https://externalService.com/";
var requestBodySpec = webClientBuilder.clone()
.clientConnector(new ReactorClientHttpConnector(HttpClient.create()))
.exchangeStrategies(exchangeStrategies)
.build()
.post()
.uri(endpoint)
.accept(TEXT_XML)
.contentType(TEXT_XML);
return new ExternalCall(requestBodySpec, uri, timeout);
}
Then within ExternalCall class I had had
private Mono<String> internalCall(String rq) {
return requestBodySpec.bodyValue(rq)
.retrieve()
.bodyToMono(String.class)
.timeout(timeout, Mono.error(() -> new TimeoutException(String.format("%s - timeout after %s seconds", "ExternalService", timeout.getSeconds()))));
}
After:
We pass WebClient class to ExternalCall.
#Bean
ExternalCall externalCall(WebClient.Builder webClientBuilder) {
var exchangeStrategies = getExchangeStrategies();
var timeout = 10000;
var uri = "https://externalService.com/";
var webClient = webClientBuilder.clone()
.clientConnector(new ReactorClientHttpConnector(HttpClient.create()))
.exchangeStrategies(exchangeStrategies)
.build();
return new ExternalCall(webClient, uri, timeout);
}
Now we specify RequestBodySpec within ExternalCall class:
private Mono<String> internalCall(String rq) {
return webClient
.post()
.uri(endpoint)
.accept(TEXT_XML)
.contentType(TEXT_XML)
.bodyValue(rq)
.retrieve()
.bodyToMono(String.class)
.timeout(timeout, Mono.error(() -> new TimeoutException(String.format("%s - timeout after %s seconds", "ExternalService", timeout.getSeconds()))));
}
Conclusions: So apparently the moment you create WebClient.RequestBodySpec instance matters. Hope that helps somebody
Adding my few observations:
webClient.get() or webClient.post() always returns new DefaultRequestBodyUriSpec on every call on which URI being called and I think it does not look like URI getting swapped.
class DefaultWebClient implements WebClient {
..
#Override
public RequestHeadersUriSpec<?> get() {
return methodInternal(HttpMethod.GET);
}
#Override
public RequestBodyUriSpec post() {
return methodInternal(HttpMethod.POST);
}
..
#Override
public Mono<ClientResponse> exchange() {
ClientRequest request = (this.inserter != null ?
initRequestBuilder().body(this.inserter).build() :
initRequestBuilder().build());
return exchangeFunction.exchange(request).switchIfEmpty(NO_HTTP_CLIENT_RESPONSE_ERROR);
}
private ClientRequest.Builder initRequestBuilder() {
URI uri = (this.uri != null ? this.uri : uriBuilderFactory.expand(""));
return ClientRequest.create(this.httpMethod, uri)
.headers(headers -> headers.addAll(initHeaders()))
.cookies(cookies -> cookies.addAll(initCookies()))
.attributes(attributes -> attributes.putAll(this.attributes));
}
..
}
and methodInternal method looks like below
#SuppressWarnings("unchecked")
private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) {
return new DefaultRequestBodyUriSpec(httpMethod);
}
In addition while making actual request also, new ClientRequest is created.
Class source
https://github.com/spring-projects/spring-framework/blob/master/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java
I am using Retrofit 2.3.0 to talk to an API that uses JWT for authentication from a Spring Boot application.
To make it work, I created an Interceptor implementation:
private static class JwtAuthenticationInterceptor implements Interceptor {
private Supplier<String> jwtTokenSupplier;
private JwtAuthenticationInterceptor(Supplier<String> jwtTokenSupplier) {
this.jwtTokenSupplier = jwtTokenSupplier;
}
#Override
public Response intercept(Chain chain) throws IOException {
Request original = chain.request();
Request.Builder builder = original.newBuilder()
.header("Authorization",
String.format("Bearer %s", jwtTokenSupplier.get()));
Request request = builder.build();
return chain.proceed(request);
}
}
In my Spring service, I let Retrofit create an instance of the API interface in the constructor:
public MySringServiceImpl() {
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(createLoggingInterceptor())
.addInterceptor(new JwtAuthenticationInterceptor(this::createJwtToken))
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("http://my.remoteapi.com/api/")
.addConverterFactory(JacksonConverterFactory.create())
.client(client)
.build();
api = retrofit.create(MyRemoteApi.class);
}
So in the actual methods of my service, I use something like this:
public List<Stuff> getStuffFromApi() {
try {
List<Stuff> response = api.getStuff().execute().body();
if (response != null) {
return response;
} else {
return new ArrayList<>();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
The createJwtToken method create JWT token (Using the Java JWT library)
private String createJwtToken() {
return Jwts.builder()
.setIssuer("http://my.remoteapi.com/api/")
.setId("my-test-id")
.setIssuedAt(new Date())
.setExpiration(new Date(ZonedDateTime.now().plusSeconds(60).toEpochSecond() * 1000))
.claim("uid", "123")
.signWith(SignatureAlgorithm.HS512,
"my-very-secret-key"
.getBytes())
.compact();
}
The actual problem:
The uid claim needs to contain the id of the current user (instead of being hardcoded like it is now). I am well aware on how to get the Spring principal in the RestController and than pass that down to the service, but how would I instruct the interceptor to use the id of that principal for the call that is happening? Should I create a new Retrofit instance for each call or are there better ways to handle this?