com.dnastack.oauth.client.OAuthClientFactory Maven / Gradle / Ivy
package com.dnastack.oauth.client;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import feign.*;
import feign.Logger.Level;
import feign.form.FormEncoder;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import feign.slf4j.Slf4jLogger;
import lombok.extern.slf4j.Slf4j;
import java.time.Instant;
import java.util.Date;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static feign.FeignException.errorStatus;
public class OAuthClientFactory {
private final OAuthClientConfiguration defaultConfig;
private final Client httpClient;
private static final ObjectMapper DEFAULT_MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
public OAuthClientFactory(OAuthClientConfiguration defaultConfig, Client httpClient) {
this.defaultConfig = defaultConfig;
this.httpClient = httpClient;
}
public Feign.Builder builder(OAuthClientConfiguration clientConfig) {
OAuthClientConfiguration actualConfig = defaultConfig.update(clientConfig);
return new OAuthClientProxy(httpClient, actualConfig).getBuilder();
}
public Feign.Builder builderWithCommonSetup(OAuthClientConfiguration clientConfig) {
return builder(clientConfig)
.encoder(new JacksonEncoder(DEFAULT_MAPPER))
.decoder(new JacksonDecoder(DEFAULT_MAPPER));
}
@Slf4j
public static class OAuthClientProxy {
private final OAuthClientConfiguration config;
private final OAuthClient tokenExchangeClient;
private static final ObjectMapper MAPPER_FOR_TOKEN_EXCHANGE = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final Client httpClient;
private Long tokenRetrievedAt = 0L;
private AccessToken accessToken = null;
public OAuthClientProxy(Client httpClient, OAuthClientConfiguration config) {
this.config = config;
this.httpClient = httpClient;
tokenExchangeClient = Feign.builder()
.client(httpClient)
.logger(config.getName() == null ? new Slf4jLogger() : new Slf4jLogger(config.getName()))
.logLevel(Level.valueOf(Optional.ofNullable(config.getFeignLoggerLevel()).orElse("BASIC").toUpperCase()))
.encoder(new FormEncoder(new JacksonEncoder(MAPPER_FOR_TOKEN_EXCHANGE)))
.decoder(new JacksonDecoder(MAPPER_FOR_TOKEN_EXCHANGE))
.target(OAuthClient.class, config.getTokenUri());
}
private OAuthRequest getOAuthRequest() {
final var builder = OAuthRequest.builder()
.grantType("client_credentials")
.clientId(config.getClientId())
.clientSecret(config.getClientSecret())
.scope(config.getScope());
if (config.getResource() != null) {
builder.resource(config.getResource());
} else if (config.getAudience() != null) {
log.warn("The use of 'audience' IS NOT recommended. Please use 'resource' instead.");
builder.audience(config.getAudience());
} else {
throw new IllegalArgumentException("Either resource (preferable) or audience must be defined");
}
return builder.build();
}
public RequestInterceptor getRequestInterceptor() {
return (template) -> {
if (accessToken == null || accessToken.isValid(tokenRetrievedAt)) {
log.debug("accessToken is expired or null. Refreshing access token.");
final OAuthRequest request = getOAuthRequest();
log.info("{}: requesting a new access token", request.getShortName());
try {
accessToken = tokenExchangeClient.getToken(request);
} catch (FeignException.BadRequest e) {
log.error("{}: failed to obtain a new access token", request.getShortName(), e);
throw new TokenExchangeException(String.format("Failed to get access token for %s", request.getShortName()), e);
}
log.warn("{}: obtained a new access token", request.getShortName());
tokenRetrievedAt = Instant.now().getEpochSecond();
} else {
log.debug("Using existing accessToken for connector as it should still be valid and un-expired.");
}
// Only pass authenticated requests for valid request URL
if (config.getAllowAuthenticationForUrl() == null || template.url().matches(config.getAllowAuthenticationForUrl())) {
final String tokenBody = accessToken.getToken().split("\\.")[1];
log.debug("{}: Bearer Token (body part only): {}", getOAuthRequest().getShortName(), tokenBody);
template.removeHeader("Authorization");
template.header("Authorization", "Bearer " + accessToken.getToken());
} else {
log.info("The connector request does not match {}. Sending request without authentication",
config.getAllowAuthenticationForUrl());
}
};
}
public Feign.Builder getBuilder() {
return Feign.builder()
.client(httpClient)
.errorDecoder((methodKey, response) -> {
FeignException exception = errorStatus(methodKey, response);
if (response.status() == 401 || response.status() == 403) {
accessToken = null;
String wwwAuthenticateHeader = response.headers().containsKey("www-authenticate")
? String.join("; ", response.headers().get("www-authenticate"))
: null;
return new RetryableException(
response.status(),
String.format("%s, WWW-Authenticate: [%s]", exception.getMessage(), wwwAuthenticateHeader),
response.request().httpMethod(),
exception,
Date.from(Instant.now()),
response.request());
}
return exception;
})
.retryer(new Retryer.Default(100L, TimeUnit.SECONDS.toMillis(1L), 2))
.requestInterceptor(getRequestInterceptor());
}
}
}