Skip to content
11 changes: 8 additions & 3 deletions src/main/java/com/truelayer/java/TrueLayerClientBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import com.truelayer.java.http.OkHttpClientFactory;
import com.truelayer.java.http.RetrofitFactory;
import com.truelayer.java.http.auth.cache.ICredentialsCache;
import com.truelayer.java.http.auth.cache.SimpleCredentialsCache;
import com.truelayer.java.http.auth.cache.InMemoryCredentialsCache;
import com.truelayer.java.http.interceptors.logging.DefaultLogConsumer;
import com.truelayer.java.mandates.IMandatesApi;
import com.truelayer.java.mandates.IMandatesHandler;
Expand Down Expand Up @@ -72,6 +72,9 @@ public class TrueLayerClientBuilder {

private Consumer<String> logMessageConsumer;

/**
* Holder for the cache implementation. Null if caching is disabled
*/
private ICredentialsCache credentialsCache;

private ProxyConfiguration proxyConfiguration;
Expand Down Expand Up @@ -182,12 +185,13 @@ public TrueLayerClientBuilder withHttpLogs(Consumer<String> logConsumer) {
* @return the instance of the client builder used
*/
public TrueLayerClientBuilder withCredentialsCaching() {
this.credentialsCache = new SimpleCredentialsCache(Clock.systemUTC());
this.credentialsCache = new InMemoryCredentialsCache(Clock.systemUTC());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we anyway need a breaking change for the new cache interface (that aligns to what PHP and .NET libs do), I took the chance to rename this to be more explicit

return this;
}

/**
* Utility to enable a custom cache for Oauth credentials.
* @param credentialsCache the custom cache implementation
* @return the instance of the client builder used
*/
public TrueLayerClientBuilder withCredentialsCaching(ICredentialsCache credentialsCache) {
Expand Down Expand Up @@ -228,6 +232,7 @@ public TrueLayerClient build() {
.httpClient(RetrofitFactory.build(authServerApiHttpClient, environment.getAuthApiUri()))
.build();

// TODO: shall we get rid of this now?
IHostedPaymentPageLinkBuilder hppLinkBuilder = com.truelayer.java.hpp.HostedPaymentPageLinkBuilder.New()
.uri(environment.getHppUri())
.build();
Expand All @@ -241,7 +246,7 @@ public TrueLayerClient build() {
// We're building a client which has the authentication handler and the options to cache the token.
// this one represents the baseline for the client used for Signup+ and Payments
OkHttpClient authenticatedApiClient = httpClientFactory.buildAuthenticatedApiClient(
authServerApiHttpClient, authenticationHandler, credentialsCache);
clientCredentials.clientId, authServerApiHttpClient, authenticationHandler, credentialsCache);
ISignupPlusApi signupPlusApi = RetrofitFactory.build(authenticatedApiClient, environment.getPaymentsApiUri())
.create(ISignupPlusApi.class);
SignupPlusHandler.SignupPlusHandlerBuilder signupPlusHandlerBuilder =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public OkHttpClient buildAuthServerApiClient(OkHttpClient baseHttpClient, Client
}

public OkHttpClient buildAuthenticatedApiClient(
String clientId,
OkHttpClient authServerApiClient,
IAuthenticationHandler authenticationHandler,
ICredentialsCache credentialsCache) {
Expand All @@ -118,7 +119,7 @@ public OkHttpClient buildAuthenticatedApiClient(
OkHttpClient.Builder authenticatedApiClientBuilder = authServerApiClient.newBuilder();

AccessTokenManager.AccessTokenManagerBuilder accessTokenManagerBuilder =
AccessTokenManager.builder().authenticationHandler(authenticationHandler);
AccessTokenManager.builder().clientId(clientId).authenticationHandler(authenticationHandler);

// setup credentials caching if required
if (isNotEmpty(credentialsCache)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.truelayer.java.auth.IAuthenticationHandler;
import com.truelayer.java.auth.entities.AccessToken;
import com.truelayer.java.entities.RequestScopes;
import com.truelayer.java.http.auth.cache.CredentialsCacheHelper;
import com.truelayer.java.http.auth.cache.ICredentialsCache;
import com.truelayer.java.http.entities.ApiResponse;
import java.util.Optional;
Expand All @@ -13,6 +14,8 @@
@Builder
public class AccessTokenManager implements IAccessTokenManager {

private final String clientId;

private final IAuthenticationHandler authenticationHandler;

private final ICredentialsCache credentialsCache;
Expand All @@ -23,10 +26,12 @@ private Optional<ICredentialsCache> getCredentialsCache() {

@Override
public AccessToken getToken(RequestScopes scopes) {
String cacheKey = CredentialsCacheHelper.buildKey(clientId, scopes);

if (getCredentialsCache().isPresent()) {
return getCredentialsCache().get().getToken(scopes).orElseGet(() -> {
return getCredentialsCache().get().getToken(cacheKey).orElseGet(() -> {
AccessToken token = tryGetToken(scopes);
credentialsCache.storeToken(scopes, token);
credentialsCache.storeToken(cacheKey, token);
return token;
});
}
Expand All @@ -37,7 +42,8 @@ public AccessToken getToken(RequestScopes scopes) {
@Override
@Synchronized
public void invalidateToken(RequestScopes scopes) {
getCredentialsCache().ifPresent(iCredentialsCache -> iCredentialsCache.clearToken(scopes));
String cacheKey = CredentialsCacheHelper.buildKey(clientId, scopes);
getCredentialsCache().ifPresent(iCredentialsCache -> iCredentialsCache.clearToken(cacheKey));
}

private AccessToken tryGetToken(RequestScopes scopes) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.truelayer.java.http.auth.cache;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.lang3.ObjectUtils.isEmpty;

import com.truelayer.java.TrueLayerException;
import com.truelayer.java.entities.RequestScopes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class CredentialsCacheHelper {
private static final String CACHE_KEY_PREFIX = "tl-auth-token";
private static final String SCOPES_DELIMITER = ",";

public static String buildKey(String clientId, RequestScopes requestScopes) {
if (isEmpty(clientId) || isEmpty(requestScopes) || isEmpty(requestScopes.getScopes())) {
throw new TrueLayerException("Invalid client id or request scopes provided");
}

List<String> scopes = new ArrayList<>(requestScopes.getScopes());

// Use natural ordering to make ordering not significant
Collections.sort(scopes);

byte[] md5InBytes = digest(String.join(SCOPES_DELIMITER, scopes).getBytes(UTF_8));
return MessageFormat.format("{0}:{1}:{2}", CACHE_KEY_PREFIX, clientId, bytesToHex(md5InBytes));
}

private static byte[] digest(byte[] input) {
Copy link
Contributor Author

@dili91 dili91 Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using a numeric hashcode I'm using MD5. We don't really need this to be bullet-proof, so I think it's fine.

MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new TrueLayerException("Hashing algorithm is not available", e);
}
return md.digest(input);
}

private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
package com.truelayer.java.http.auth.cache;

import com.truelayer.java.auth.entities.AccessToken;
import com.truelayer.java.entities.RequestScopes;
import java.util.Optional;

public interface ICredentialsCache {

/**
* Gets the cached access token for the given request scopes.
* @param scopes the requested scopes
* @return an optional access token. If the token is expired an empty optional is returned
*/
Optional<AccessToken> getToken(RequestScopes scopes);
Optional<AccessToken> getToken(String key);
Comment on lines -14 to +8
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main change: users of a custom cache won't have to bother computing hash keys. They will receive what the client builds, and they have the option to customise/override those if needed


/**
* Stores an access token in cache for the given request scopes.
* @param token the new token to store
* @param scopes the requested scopes
*/
void storeToken(RequestScopes scopes, AccessToken token);
void storeToken(String key, AccessToken token);

/**
* Remove the entry in the cache for the given request scopes.
* @param scopes the requested scopes
*/
void clearToken(RequestScopes scopes);
void clearToken(String key);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.truelayer.java.http.auth.cache;

import com.truelayer.java.auth.entities.AccessToken;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class InMemoryCredentialsCache implements ICredentialsCache {

private final Clock clock;

/**
* internal state
*/
private final Map<String, AccessTokenRecord> tokenRecords = new ConcurrentHashMap<>();

@Override
public Optional<AccessToken> getToken(String key) {
AccessTokenRecord tokenRecord = tokenRecords.get(key);
if (tokenRecord == null || !LocalDateTime.now(clock).isBefore(tokenRecord.expiresAt)) {
return Optional.empty();
}

return Optional.of(tokenRecord.token);
}

@Override
public void storeToken(String key, AccessToken token) {
AccessTokenRecord tokenRecord =
new AccessTokenRecord(token, LocalDateTime.now(clock).plusSeconds(token.getExpiresIn()));

tokenRecords.put(key, tokenRecord);
}

@Override
public void clearToken(String key) {
tokenRecords.remove(key);
}

@RequiredArgsConstructor
public static class AccessTokenRecord {
private final AccessToken token;
private final LocalDateTime expiresAt;
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ public void itShouldBuildAClientWithCustomLoggingAndCaching() {
.clientCredentials(getClientCredentials())
.signingOptions(getSigningOptions())
.withHttpLogs(System.out::println)
.withCredentialsCaching(mock(ICredentialsCache.class))
.withCredentialsCaching();

.withCredentialsCaching(mock(ICredentialsCache.class));
assertDoesNotThrow(sut::build);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.net.Proxy;
import java.net.URI;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
Expand Down Expand Up @@ -208,9 +209,10 @@ public void shouldCreateAnAuthenticatedApiClient() {
.clientCredentials(getClientCredentials())
.httpClient(RetrofitFactory.build(baseHttpClient, URI.create("http://localhost")))
.build();
String clientId = UUID.randomUUID().toString();

OkHttpClient authenticatedApiClient =
getOkHttpClientFactory().buildAuthenticatedApiClient(authServerApiClient, authenticationHandler, null);
OkHttpClient authenticatedApiClient = getOkHttpClientFactory()
.buildAuthenticatedApiClient(clientId, authServerApiClient, authenticationHandler, null);

assertNotNull(authenticatedApiClient);
assertTrue(
Expand All @@ -236,10 +238,11 @@ public void shouldCreateAPaymentsApiClient() {
.clientCredentials(getClientCredentials())
.httpClient(RetrofitFactory.build(baseHttpClient, URI.create("http://localhost")))
.build();
String clientId = UUID.randomUUID().toString();
OkHttpClient authApiClient =
getOkHttpClientFactory().buildAuthServerApiClient(baseHttpClient, getClientCredentials());
OkHttpClient authenticatedApiClient =
getOkHttpClientFactory().buildAuthenticatedApiClient(authApiClient, authenticationHandler, null);
OkHttpClient authenticatedApiClient = getOkHttpClientFactory()
.buildAuthenticatedApiClient(clientId, authApiClient, authenticationHandler, null);

OkHttpClient paymentClient =
getOkHttpClientFactory().buildPaymentsApiClient(authenticatedApiClient, TestUtils.getSigningOptions());
Expand Down
Loading
Loading