documentBundleProcessors = new ArrayList<>();
private Clock clock = Clock.systemDefaultZone();
+ private JwtAuthConfig jwtAuthConfig;
private Builder(KeyStoreConfig keyStoreConfig) {
@@ -203,6 +220,31 @@ public Builder defaultSender(Sender sender) {
return this;
}
+ /**
+ * Authenticate with Posten signering using an OAuth 2.0 client credentials grant
+ * over a mutually authenticated TLS connection, instead of relying on the organization
+ * certificate alone. Access tokens are acquired from the token endpoint given by the
+ * {@link JwtAuthConfig}, and sent as an {@code Authorization: Bearer} header on all
+ * requests to the API.
+ *
+ * The organization certificate passed to {@link ClientConfiguration#builder(KeyStoreConfig)}
+ * is still required, and is used to authenticate against the token endpoint.
+ *
+ *
Access tokens are acquired as the {@link no.digipost.signature.client.security.BrokerId
+ * broker} of the given configuration, for the entire lifetime of the client. This is
+ * independent of which {@link Sender sender} a signature job is created on behalf of: a
+ * broker permitted to act on behalf of several organizations specifies that per job as
+ * before, and {@link #defaultSender(Sender) defaultSender(..)} remains optional.
+ *
+ * @param jwtAuthConfig the client id and broker id to acquire access tokens with
+ */
+ public Builder jwtAuthentication(JwtAuthConfig jwtAuthConfig) {
+ requireNonNull(jwtAuthConfig, "jwtAuthConfig");
+ this.jwtAuthConfig = jwtAuthConfig;
+ return this;
+ }
+
+
/**
* Customize the {@link HttpHeaders#USER_AGENT User-Agent} header value to include the
* given string.
@@ -354,7 +396,8 @@ public Builder serverCertificateTrustStrategy(CertificateChainValidation certifi
/**
* Allows for overriding which {@link Clock} is used to convert between Java and XML,
- * may be useful for e.g. automated tests.
+ * may be useful for e.g. automated tests. The clock value is also passed on to
+ * access token expiry validation for jwt functionality.
*
* Uses the {@link Clock#systemDefaultZone() system clock with default time zone}
* if not specified.
@@ -367,13 +410,72 @@ public Builder clock(Clock clock) {
public ClientConfiguration build() {
Configurer commonConfig = userAgentConfigurer.andThen(proxyConfigurer);
- return new ClientConfiguration(defaultSender, serviceEnvironment.signatureServiceRootUrl(), keyStoreConfig,
- commonConfig.andThen(defaultHttpClientConfigurer), commonConfig.andThen(httpClientForDocumentDownloadsConfigurer),
- documentBundleProcessors, clock);
+ Configurer apiConfig = commonConfig;
+ if (jwtAuthConfig != null) {
+ // The certificate authenticates this client to the token endpoint only. Requests to the API
+ // authenticate with the access token, and must not present a client certificate. This applies
+ // to both API clients, as they share the same ssl configurer.
+ //
+ // Note that this configures the builder, not the ClientConfiguration being built: the ssl
+ // configurer is applied lazily, when the http clients are created. Any ClientConfiguration
+ // previously built by this builder will therefore also stop presenting the certificate. That is
+ // acceptable, as a builder is expected to be used to build one configuration, and enabling
+ // authentication for some clients but not others is not a meaningful thing to do.
+ sslConfigurer.withoutClientCertificate();
+
+ AccessTokenRequest accessTokenRequest = new AccessTokenRequest(
+ resolveTokenEndpoint(),
+ jwtAuthConfig.clientId,
+ accessTokenScope(),
+ accessTokenResource()
+ );
+
+ // The token endpoint client is deliberately configured with commonConfig only, i.e. before the
+ // bearer token configurer is added below. It must not attempt to authenticate itself with a
+ // bearer token, as acquiring one is exactly what it is used for.
+ MutualTlsTokenProvider tokenProvider = MutualTlsTokenProvider.create(
+ accessTokenRequest, keyStoreConfig, commonConfig, clock);
+ apiConfig = commonConfig.andThen(new ApacheHttpClientBearerTokenConfigurer(tokenProvider));
+ }
+
+ return new ClientConfiguration(defaultSender,
+ serviceEnvironment.signatureServiceRootUrl(),
+ keyStoreConfig,
+ apiConfig.andThen(defaultHttpClientConfigurer),
+ apiConfig.andThen(httpClientForDocumentDownloadsConfigurer),
+ documentBundleProcessors,
+ clock
+ );
}
- }
+ /**
+ * The endpoint to acquire access tokens from, which belongs to the configured
+ * {@link ServiceEnvironment}.
+ */
+ private URI resolveTokenEndpoint() {
+ return serviceEnvironment.tokenEndpoint().orElseThrow(() -> new ConfigurationException(
+ "No token endpoint to acquire access tokens from. The " + serviceEnvironment + " does not have " +
+ "one, which is expected for custom environments. Specify it with " +
+ "serviceEnvironment(env -> env.withTokenEndpoint(..))."));
+ }
+ /**
+ * The resource the access token is requested for, which is the root URL of the API itself.
+ * The identity provider matches this value as an exact string.
+ */
+ private String accessTokenResource() {
+ return serviceEnvironment.signatureServiceRootUrl().toString();
+ }
+
+ /**
+ * The scope to request access tokens for. The format of this string is a contract with the
+ * identity provider issuing the tokens, and is not defined by this library.
+ */
+ private String accessTokenScope() {
+ return ACCESS_TOKEN_SCOPE_PREFIX + jwtAuthConfig.brokerId.value();
+ }
+
+ }
diff --git a/lib/src/main/java/no/digipost/signature/client/ServiceEnvironment.java b/lib/src/main/java/no/digipost/signature/client/ServiceEnvironment.java
index 286ef7ac..744cf484 100644
--- a/lib/src/main/java/no/digipost/signature/client/ServiceEnvironment.java
+++ b/lib/src/main/java/no/digipost/signature/client/ServiceEnvironment.java
@@ -7,6 +7,7 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Stream;
import static java.util.Arrays.asList;
@@ -19,13 +20,16 @@
public final class ServiceEnvironment implements ProvidesCertificateResourcePaths, WithSignatureServiceRootUrl {
public static final ServiceEnvironment PRODUCTION = new ServiceEnvironment(
- "Posten signering Production", URI.create("https://api.signering.posten.no/api"), Certificates.PRODUCTION.certificatePaths);
+ "Posten signering Production", URI.create("https://api.signering.posten.no/api"), Certificates.PRODUCTION.certificatePaths,
+ URI.create("https://midp.digipost.no/oauth2/token"));
public static final ServiceEnvironment DIFITEST = new ServiceEnvironment(
- "Posten signering Difitest", URI.create("https://api.difitest.signering.posten.no/api"), Certificates.TEST.certificatePaths);
+ "Posten signering Difitest", URI.create("https://api.difitest.signering.posten.no/api"), Certificates.TEST.certificatePaths,
+ URI.create("https://midp.difitest.digipost.no/oauth2/token"));
public static final ServiceEnvironment DIFIQA = new ServiceEnvironment(
- "Posten signering Difiqa", URI.create("https://api.difiqa.signering.posten.no/api"), Certificates.TEST.certificatePaths);
+ "Posten signering Difiqa", URI.create("https://api.difiqa.signering.posten.no/api"), Certificates.TEST.certificatePaths,
+ URI.create("https://midp.qa.digipost.no/oauth2/token"));
public static final ServiceEnvironment STAGING = DIFITEST.withDescription("Posten signering Staging");
@@ -33,20 +37,38 @@ public final class ServiceEnvironment implements ProvidesCertificateResourcePath
private final String description;
private final URI serviceRootUrl;
private final List certificatePaths;
+ private final URI tokenEndpointUrl;
public ServiceEnvironment(String description, URI serviceRootUrl, Collection certificatePaths) {
+ this(description, serviceRootUrl, certificatePaths, null);
+ }
+
+ private ServiceEnvironment(String description, URI serviceRootUrl, Collection certificatePaths, URI tokenEndpointUrl) {
this.description = description;
this.serviceRootUrl = serviceRootUrl;
this.certificatePaths = unmodifiableList(new ArrayList<>(certificatePaths));
+ this.tokenEndpointUrl = tokenEndpointUrl;
+ }
+
+ /**
+ * Set the endpoint to acquire access tokens from when authenticating with
+ * {@link ClientConfiguration.Builder#jwtAuthentication(no.digipost.signature.client.security.JwtAuthConfig) JWT/mTLS authentication}.
+ * The predefined environments already know their own token endpoint, so this is only needed for
+ * custom setups, such as testing against your own stubbed implementation.
+ *
+ * @param tokenEndpointUrl the URL of the token endpoint
+ */
+ public ServiceEnvironment withTokenEndpoint(URI tokenEndpointUrl) {
+ return new ServiceEnvironment(this.description, this.serviceRootUrl, this.certificatePaths, tokenEndpointUrl);
}
public ServiceEnvironment withDescription(String description) {
- return new ServiceEnvironment(description, this.serviceRootUrl, this.certificatePaths);
+ return new ServiceEnvironment(description, this.serviceRootUrl, this.certificatePaths, this.tokenEndpointUrl);
}
public ServiceEnvironment withServiceUrl(URI url) {
- return new ServiceEnvironment(this.description, url, this.certificatePaths);
+ return new ServiceEnvironment(this.description, url, this.certificatePaths, this.tokenEndpointUrl);
}
public ServiceEnvironment withAdditionalCertificates(String ... additionalCertificatePaths) {
@@ -64,7 +86,7 @@ public ServiceEnvironment withCertificates(String ... certificatePaths) {
}
public ServiceEnvironment withCertificates(Collection certificatePaths) {
- return new ServiceEnvironment(this.description, this.serviceRootUrl, certificatePaths);
+ return new ServiceEnvironment(this.description, this.serviceRootUrl, certificatePaths, this.tokenEndpointUrl);
}
@Override
@@ -72,6 +94,14 @@ public URI signatureServiceRootUrl() {
return serviceRootUrl;
}
+ /**
+ * The endpoint to acquire access tokens from, if this environment has one. Empty for custom
+ * environments which have not been given one with {@link #withTokenEndpoint(URI)}.
+ */
+ public Optional tokenEndpoint() {
+ return Optional.ofNullable(tokenEndpointUrl);
+ }
+
@Override
public List certificatePaths() {
return certificatePaths;
diff --git a/lib/src/main/java/no/digipost/signature/client/core/exceptions/AccessTokenException.java b/lib/src/main/java/no/digipost/signature/client/core/exceptions/AccessTokenException.java
new file mode 100644
index 00000000..d8ebd2e0
--- /dev/null
+++ b/lib/src/main/java/no/digipost/signature/client/core/exceptions/AccessTokenException.java
@@ -0,0 +1,18 @@
+package no.digipost.signature.client.core.exceptions;
+
+/**
+ * Thrown when an access token could not be acquired from the configured OAuth 2.0 token endpoint,
+ * or when the token endpoint's response could not be understood.
+ *
+ * @see no.digipost.signature.client.security.JwtAuthConfig
+ */
+public class AccessTokenException extends SignatureException {
+
+ public AccessTokenException(final String message) {
+ super(message);
+ }
+
+ public AccessTokenException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/lib/src/main/java/no/digipost/signature/client/core/internal/configuration/ApacheHttpClientBearerTokenConfigurer.java b/lib/src/main/java/no/digipost/signature/client/core/internal/configuration/ApacheHttpClientBearerTokenConfigurer.java
new file mode 100644
index 00000000..190df2f0
--- /dev/null
+++ b/lib/src/main/java/no/digipost/signature/client/core/internal/configuration/ApacheHttpClientBearerTokenConfigurer.java
@@ -0,0 +1,143 @@
+package no.digipost.signature.client.core.internal.configuration;
+
+import no.digipost.signature.client.core.internal.http.MutualTlsTokenProvider;
+import org.apache.hc.client5.http.classic.ExecChain;
+import org.apache.hc.client5.http.classic.ExecChainHandler;
+import org.apache.hc.client5.http.impl.ChainElement;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpRequestInterceptor;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+import java.io.IOException;
+import java.util.logging.Logger;
+
+import static org.apache.hc.core5.http.HttpHeaders.AUTHORIZATION;
+
+/**
+ * Makes the client send an {@code Authorization: Bearer } header on every request, using an
+ * access token acquired from the configured OAuth 2.0 token endpoint.
+ *
+ * Also recovers from a token being rejected. A token can stop working before it is considered
+ * stale by the {@link MutualTlsTokenProvider}, for instance if it is revoked, if the token endpoint
+ * is restarted, or if this host's clock runs ahead of the token endpoint's. Without this, every
+ * subsequent request would keep failing until the cached token expired on its own.
+ *
+ *
A {@code 401} which turns out to signal a permanent authorization problem rather than a
+ * rejected token will therefore cost one extra attempt for safe requests before the failure is
+ * passed on to the caller. That is a deliberate trade-off, and is bounded to a single retry.
+ *
+ *
This class is not part of the public API of this library and may change without notice.
+ */
+public final class ApacheHttpClientBearerTokenConfigurer implements Configurer {
+
+ /**
+ * The {@link HttpContext} attribute holding the access token which was put on the request, so
+ * that it can be identified as the rejected one if the response turns out to be a 401.
+ */
+ static final String APPLIED_ACCESS_TOKEN = "no.digipost.signature.client.applied-access-token";
+
+ private static final String RECOVERY_EXEC_NAME = "bearer-token-recovery";
+
+ private final MutualTlsTokenProvider tokenProvider;
+
+ public ApacheHttpClientBearerTokenConfigurer(MutualTlsTokenProvider tokenProvider) {
+ this.tokenProvider = tokenProvider;
+ }
+
+ @Override
+ public void applyTo(HttpClientBuilder httpClientBuilder) {
+ httpClientBuilder
+ .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider))
+ // Placed outside the protocol chain element, which is what runs the request
+ // interceptors. A retry from here therefore runs the interceptor above again, which
+ // puts a freshly acquired token on the retried request.
+ .addExecInterceptorBefore(
+ ChainElement.PROTOCOL.name(), RECOVERY_EXEC_NAME, new RejectedTokenRecoveryExec(tokenProvider));
+ }
+
+
+ private static final class RequestBearerTokenInterceptor implements HttpRequestInterceptor {
+
+ private final MutualTlsTokenProvider tokenProvider;
+
+ RequestBearerTokenInterceptor(MutualTlsTokenProvider tokenProvider) {
+ this.tokenProvider = tokenProvider;
+ }
+
+ @Override
+ public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) {
+ String accessToken = tokenProvider.getToken();
+ request.setHeader(AUTHORIZATION, "Bearer " + accessToken);
+ context.setAttribute(APPLIED_ACCESS_TOKEN, accessToken);
+ }
+ }
+
+
+ private static final class RejectedTokenRecoveryExec implements ExecChainHandler {
+
+ private static final Logger LOG = Logger.getLogger(RejectedTokenRecoveryExec.class.getName());
+
+ private final MutualTlsTokenProvider tokenProvider;
+
+ RejectedTokenRecoveryExec(MutualTlsTokenProvider tokenProvider) {
+ this.tokenProvider = tokenProvider;
+ }
+
+ @Override
+ public ClassicHttpResponse execute(ClassicHttpRequest request, ExecChain.Scope scope, ExecChain chain)
+ throws IOException, HttpException {
+
+ ClassicHttpResponse response = chain.proceed(request, scope);
+ if (response.getCode() != HttpStatus.SC_UNAUTHORIZED) {
+ return response;
+ }
+
+ Object appliedAccessToken = scope.clientContext.getAttribute(APPLIED_ACCESS_TOKEN);
+ if (appliedAccessToken instanceof String) {
+ tokenProvider.invalidate((String) appliedAccessToken);
+ }
+
+ if (!canBeSafelyRetried(request)) {
+ // The token has still been discarded above, so the next request will acquire a new
+ // one. Only this response is passed on to the caller as the failure it is.
+ return response;
+ }
+
+ // The connection must be released before it can be used for the retry.
+ EntityUtils.consume(response.getEntity());
+ response.close();
+
+ LOG.fine(() -> "Retrying " + request.getMethod() + " " + request.getPath() +
+ " once with a new access token, as the one used was rejected");
+
+ // Retried from a pristine copy of the original request, the same way the http client's own
+ // retry handling does it. The request just attempted has had protocol headers such as
+ // Content-Length or Transfer-Encoding added to it, and those cannot be applied a second time.
+ return chain.proceed(ClassicRequestBuilder.copy(scope.originalRequest).build(), scope);
+ }
+
+ /**
+ * Only requests which are both safe to repeat from the API's point of view, and physically
+ * repeatable, are retried. Notably this excludes creating signature jobs: it is not safe to
+ * send such a request twice, and its multipart body is not repeatable anyway.
+ */
+ private static boolean canBeSafelyRetried(ClassicHttpRequest request) {
+ HttpEntity entity = request.getEntity();
+ if (entity != null && !entity.isRepeatable()) {
+ return false;
+ }
+ return Method.isSafe(request.getMethod());
+ }
+ }
+
+}
diff --git a/lib/src/main/java/no/digipost/signature/client/core/internal/configuration/ApacheHttpClientSslConfigurer.java b/lib/src/main/java/no/digipost/signature/client/core/internal/configuration/ApacheHttpClientSslConfigurer.java
index 01227090..2c42f300 100644
--- a/lib/src/main/java/no/digipost/signature/client/core/internal/configuration/ApacheHttpClientSslConfigurer.java
+++ b/lib/src/main/java/no/digipost/signature/client/core/internal/configuration/ApacheHttpClientSslConfigurer.java
@@ -11,6 +11,7 @@
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy;
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.apache.hc.core5.ssl.SSLContexts;
import javax.net.ssl.SSLContext;
@@ -22,6 +23,7 @@ public class ApacheHttpClientSslConfigurer implements Configurerserver's certificate is unaffected.
+ *
+ * This is used when requests are authenticated with an access token instead of with the
+ * certificate. The certificate is still required, but for other purposes: acquiring the access
+ * token, and signing document bundles.
+ *
+ * @see no.digipost.signature.client.ClientConfiguration.Builder#jwtAuthentication(no.digipost.signature.client.security.JwtAuthConfig)
+ */
+ public ApacheHttpClientSslConfigurer withoutClientCertificate() {
+ this.presentClientCertificate = false;
+ return this;
+ }
+
public ApacheHttpClientSslConfigurer certificatChainValidation(CertificateChainValidation certificateChainValidation) {
this.certificateChainValidation = certificateChainValidation;
return this;
@@ -48,10 +65,12 @@ public void applyTo(PoolingHttpClientConnectionManagerBuilder connectionManager)
private SSLContext sslContext() {
try {
- return SSLContexts.custom()
- .loadKeyMaterial(keyStoreConfig.keyStore, keyStoreConfig.privatekeyPassword.toCharArray(), (aliases, socket) -> keyStoreConfig.alias)
- .loadTrustMaterial(TrustStoreLoader.build(trustedCertificates), new SignatureApiTrustStrategy(certificateChainValidation))
- .build();
+ SSLContextBuilder sslContext = SSLContexts.custom()
+ .loadTrustMaterial(TrustStoreLoader.build(trustedCertificates), new SignatureApiTrustStrategy(certificateChainValidation));
+ if (presentClientCertificate) {
+ sslContext.loadKeyMaterial(keyStoreConfig.keyStore, keyStoreConfig.privatekeyPassword.toCharArray(), (aliases, socket) -> keyStoreConfig.alias);
+ }
+ return sslContext.build();
} catch (Exception e) {
if (e instanceof UnrecoverableKeyException && "Given final block not properly padded".equals(e.getMessage())) {
throw new KeyException(
diff --git a/lib/src/main/java/no/digipost/signature/client/core/internal/http/AccessTokenRequest.java b/lib/src/main/java/no/digipost/signature/client/core/internal/http/AccessTokenRequest.java
new file mode 100644
index 00000000..4b2fdf46
--- /dev/null
+++ b/lib/src/main/java/no/digipost/signature/client/core/internal/http/AccessTokenRequest.java
@@ -0,0 +1,52 @@
+package no.digipost.signature.client.core.internal.http;
+
+import java.net.URI;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * The fully resolved inputs for requesting an access token with the OAuth 2.0 client
+ * credentials grant. Resolving these is the responsibility of
+ * {@link no.digipost.signature.client.ClientConfiguration ClientConfiguration}, which knows both the
+ * {@link no.digipost.signature.client.security.JwtAuthConfig JwtAuthConfig} and the
+ * {@link no.digipost.signature.client.ServiceEnvironment ServiceEnvironment} they are derived from.
+ *
+ *
Exists as a value object with named fields rather than as loose parameters because
+ * {@link #scope} and {@link #resource} are both strings which the token endpoint matches exactly.
+ */
+public final class AccessTokenRequest {
+
+ /**
+ * The endpoint to request the access token from.
+ */
+ public final URI tokenEndpoint;
+
+ /**
+ * Sent as the {@code client_id} parameter.
+ */
+ public final String clientId;
+
+ /**
+ * Sent as the {@code scope} parameter.
+ */
+ public final String scope;
+
+ /**
+ * Sent as the {@code resource} parameter.
+ */
+ public final String resource;
+
+ public AccessTokenRequest(URI tokenEndpoint, String clientId, String scope, String resource) {
+ this.tokenEndpoint = requireNonNull(tokenEndpoint, "token endpoint");
+ this.clientId = requireNonNull(clientId, "client id");
+ this.scope = requireNonNull(scope, "scope");
+ this.resource = requireNonNull(resource, "resource");
+ }
+
+ @Override
+ public String toString() {
+ return "Access token request to " + tokenEndpoint + " for client '" + clientId + "', " +
+ "scope '" + scope + "', resource '" + resource + "'";
+ }
+
+}
diff --git a/lib/src/main/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProvider.java b/lib/src/main/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProvider.java
new file mode 100644
index 00000000..03ea6cc2
--- /dev/null
+++ b/lib/src/main/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProvider.java
@@ -0,0 +1,365 @@
+package no.digipost.signature.client.core.internal.http;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.JsonToken;
+import no.digipost.signature.client.core.exceptions.AccessTokenException;
+import no.digipost.signature.client.core.exceptions.HttpIOException;
+import no.digipost.signature.client.core.exceptions.KeyException;
+import no.digipost.signature.client.core.internal.configuration.ApacheHttpClientBuilderConfigurer;
+import no.digipost.signature.client.core.internal.configuration.Configurer;
+import no.digipost.signature.client.security.KeyStoreConfig;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.ParseException;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
+import org.apache.hc.core5.ssl.SSLContexts;
+
+import javax.net.ssl.SSLContext;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Collections.unmodifiableList;
+import static java.util.Objects.requireNonNull;
+import static no.digipost.signature.client.core.internal.http.StatusCode.Family.SUCCESSFUL;
+import static org.apache.hc.core5.http.ContentType.APPLICATION_JSON;
+import static org.apache.hc.core5.http.HttpHeaders.ACCEPT;
+
+/**
+ * Acquires and caches OAuth 2.0 access tokens using the client credentials grant, where
+ * the client authenticates to the token endpoint by presenting its client certificate during the
+ * TLS handshake.
+ *
+ *
Tokens are cached in memory and refreshed lazily: a token is considered stale
+ * {@value #REFRESH_MARGIN_SECONDS} seconds before its actual expiry, and the next call to
+ * {@link #getToken()} after that point acquires a new one. There is no background refresh thread.
+ */
+public class MutualTlsTokenProvider {
+
+ private static final Logger LOG = Logger.getLogger(MutualTlsTokenProvider.class.getName());
+
+ static final long REFRESH_MARGIN_SECONDS = 30;
+
+ private static final Duration REFRESH_MARGIN = Duration.ofSeconds(REFRESH_MARGIN_SECONDS);
+
+ private static final String ACCESS_TOKEN_FIELD = "access_token";
+ private static final String EXPIRES_IN_FIELD = "expires_in";
+
+ private static final JsonFactory JSON = new JsonFactory();
+
+ /**
+ * Error responses from a token endpoint are included in exception messages to aid debugging,
+ * but truncated so that a misconfigured endpoint returning e.g. an HTML error page does not
+ * produce an unreadable exception.
+ */
+ private static final int MAX_REPORTED_ERROR_BODY_LENGTH = 512;
+
+
+ private final URI tokenEndpointUri;
+ private final List tokenRequestParameters;
+ private final HttpClient tokenClient;
+ private final Clock clock;
+
+ private final Object refreshLock = new Object();
+ private volatile CachedAccessToken cachedToken;
+
+
+ /**
+ * Create a token provider with an HTTP client which authenticates using the client certificate
+ * of the given {@link KeyStoreConfig}.
+ *
+ * The token endpoint belongs to Digipost's own identity provider, mIdP, which is a separate
+ * service from the Posten signering API. The same client certificate can be used to acquire
+ * tokens for several Digipost services, but a token is issued for one
+ * {@link AccessTokenRequest#scope scope} at a time, so a token acquired for Posten signering
+ * cannot also be used against another service.
+ *
+ *
Being a separate service, its certificate is validated the ordinary way: against the JVM's
+ * default trust store, with ordinary hostname verification. It deliberately does not
+ * reuse the trust configuration used for the Posten signering API itself, which instead requires
+ * the server certificate to identify Posten Bring AS, and skips hostname verification on that
+ * basis.
+ *
+ * @param accessTokenRequest the resolved parameters to request an access token with
+ * @param keyStoreConfig the client certificate to authenticate with
+ * @param commonHttpClientConfiguration configuration shared with the API clients, such as
+ * User-Agent and proxy settings
+ * @param clock the clock used to determine token expiry
+ */
+ public static MutualTlsTokenProvider create(
+ AccessTokenRequest accessTokenRequest,
+ KeyStoreConfig keyStoreConfig,
+ Configurer commonHttpClientConfiguration,
+ Clock clock
+ ) {
+ Configurer tokenClientConfiguration = new ApacheHttpClientBuilderConfigurer()
+ .connectionManager(connectionManager -> connectionManager
+ .setTlsSocketStrategy(new DefaultClientTlsStrategy(mutualTlsSslContext(keyStoreConfig))))
+ .socketTimeout(Duration.ofSeconds(5))
+ .connectTimeout(Duration.ofSeconds(5))
+ .connectionRequestTimeout(Duration.ofSeconds(5))
+ .responseArrivalTimeout(Duration.ofSeconds(10));
+
+ HttpClientBuilder tokenClientBuilder = HttpClientBuilder.create();
+ commonHttpClientConfiguration.andThen(tokenClientConfiguration).applyTo(tokenClientBuilder);
+
+ return new MutualTlsTokenProvider(accessTokenRequest, tokenClientBuilder.build(), clock);
+ }
+
+ /**
+ * @param accessTokenRequest the resolved parameters to request an access token with
+ * @param tokenClient the HTTP client used to call the token endpoint. It is the caller's
+ * responsibility that this client presents the appropriate client
+ * certificate, cf. {@link #create(AccessTokenRequest, KeyStoreConfig, Configurer, Clock)}.
+ * @param clock the clock used to determine token expiry
+ */
+ public MutualTlsTokenProvider(AccessTokenRequest accessTokenRequest, HttpClient tokenClient, Clock clock) {
+ this.tokenEndpointUri = requireNonNull(accessTokenRequest, "access token request").tokenEndpoint;
+ this.tokenClient = requireNonNull(tokenClient, "token endpoint HTTP client");
+ this.clock = requireNonNull(clock, "clock");
+ this.tokenRequestParameters = clientCredentialsParameters(accessTokenRequest);
+ }
+
+
+ /**
+ * Get a valid access token, acquiring a new one from the token endpoint if the currently
+ * cached token is absent or about to expire.
+ *
+ * @return the access token, to be used as an opaque bearer token
+ */
+ public String getToken() {
+ CachedAccessToken current = cachedToken;
+ if (current != null && current.isValidAt(Instant.now(clock))) {
+ return current.token;
+ }
+ synchronized (refreshLock) {
+ // Another thread may have refreshed the token while we waited for the lock.
+ current = cachedToken;
+ if (current != null && current.isValidAt(Instant.now(clock))) {
+ return current.token;
+ }
+ CachedAccessToken refreshed = acquireToken();
+ cachedToken = refreshed;
+ return refreshed.token;
+ }
+ }
+
+
+ /**
+ * Discard the cached access token, so that the next call to {@link #getToken()} acquires a new
+ * one. Used when the API has rejected the given token, which can happen before it is considered
+ * stale here, e.g. if it was revoked, or if this host's clock runs ahead of the token endpoint's.
+ *
+ * The token is only discarded if it is still the one currently cached, so that a token another
+ * thread has just acquired is not thrown away.
+ *
+ * @param rejectedToken the access token which was rejected
+ */
+ public void invalidate(String rejectedToken) {
+ synchronized (refreshLock) {
+ CachedAccessToken current = cachedToken;
+ if (current != null && current.token.equals(rejectedToken)) {
+ cachedToken = null;
+ LOG.fine(() -> "Discarded the cached access token from " + tokenEndpointUri + ", as it was rejected");
+ }
+ }
+ }
+
+ private CachedAccessToken acquireToken() {
+ ClassicHttpRequest request = ClassicRequestBuilder
+ .post(tokenEndpointUri)
+ .addHeader(ACCEPT, APPLICATION_JSON.getMimeType())
+ .setEntity(new UrlEncodedFormEntity(tokenRequestParameters, UTF_8))
+ .build();
+
+ try {
+ return tokenClient.execute(request, this::handleTokenResponse);
+ } catch (IOException e) {
+ throw new HttpIOException(request, "Unable to acquire an access token from " + tokenEndpointUri, e);
+ }
+ }
+
+ private CachedAccessToken handleTokenResponse(ClassicHttpResponse response) throws IOException, ParseException {
+ String body = readBody(response);
+
+ StatusCode status = StatusCode.from(response.getCode());
+ if (!status.is(SUCCESSFUL)) {
+ throw new AccessTokenException(
+ "Got " + status + " from the token endpoint " + tokenEndpointUri +
+ ", expected a successful response. The response body was: " + truncate(body));
+ }
+
+ TokenResponse tokenResponse = readTokenResponse(body);
+
+ Instant expiry = Instant.now(clock).plusSeconds(tokenResponse.expiresInSeconds);
+ Instant staleAt = expiry.minus(REFRESH_MARGIN);
+ if (!staleAt.isAfter(Instant.now(clock))) {
+ LOG.warning("The access token acquired from " + tokenEndpointUri + " expires at " + expiry + ", which is " +
+ "already within the " + REFRESH_MARGIN_SECONDS + " second refresh margin. A new token will be " +
+ "acquired for every request, which may put considerable load on the token endpoint.");
+ }
+
+ LOG.fine(() -> "Acquired a new access token from " + tokenEndpointUri + ", expiring at " + expiry);
+ return new CachedAccessToken(tokenResponse.accessToken, staleAt);
+ }
+
+ /**
+ * Read the {@code access_token} and {@code expires_in} fields of the token endpoint's response.
+ * Both are required: without a lifetime there is no basis for caching the token, and a response
+ * lacking it is treated as one this client does not understand rather than something to guess
+ * around.
+ *
+ * Any other fields, including nested ones, are skipped.
+ */
+ private TokenResponse readTokenResponse(String responseBody) throws IOException {
+ String accessToken = null;
+ Long expiresInSeconds = null;
+
+ try (JsonParser json = JSON.createParser(responseBody)) {
+ if (json.nextToken() != JsonToken.START_OBJECT) {
+ throw new AccessTokenException(
+ "Expected the response from the token endpoint " + tokenEndpointUri + " to be a JSON object, " +
+ "but it was: " + truncate(responseBody));
+ }
+ while (json.nextToken() == JsonToken.FIELD_NAME) {
+ // Using getCurrentName() and JsonProcessingException from older versions
+ // in case consumers pin their own Jackson version and get a NoSuchMethodError at runtime
+ String field = json.getCurrentName();
+ JsonToken value = json.nextToken();
+ if (ACCESS_TOKEN_FIELD.equals(field) && value == JsonToken.VALUE_STRING) {
+ accessToken = json.getText();
+ } else if (EXPIRES_IN_FIELD.equals(field) && value == JsonToken.VALUE_NUMBER_INT) {
+ expiresInSeconds = json.getLongValue();
+ } else {
+ // No-op for scalars, and skips past the contents of nested objects and arrays.
+ json.skipChildren();
+ }
+ }
+ } catch (JsonProcessingException e) {
+ throw new AccessTokenException(
+ "Could not parse the response from the token endpoint " + tokenEndpointUri + " as JSON, because " +
+ e.getClass().getSimpleName() + ": '" + e.getOriginalMessage() + "'", e);
+ }
+
+ if (accessToken == null || accessToken.isEmpty()) {
+ throw new AccessTokenException(
+ "The response from the token endpoint " + tokenEndpointUri + " did not contain a non-empty " +
+ "'" + ACCESS_TOKEN_FIELD + "' string field.");
+ }
+ if (expiresInSeconds == null) {
+ throw new AccessTokenException(
+ "The response from the token endpoint " + tokenEndpointUri + " did not contain an " +
+ "'" + EXPIRES_IN_FIELD + "' integer field, so it is not known how long the access token is valid.");
+ }
+
+ // The return expiry value of this method is passed to instant.plusSeconds(expiresInSeconds)
+ // which would overflow on a malformed value close to Long.MAX_VALUE, which would return
+ // ArithmeticException/DateTimeException rather than AccessTokenException. Not likely to
+ // happen, and jwt expiry would never be this long/large, but checking that it's not malformed
+ // Also handles "negative expires_in → new token every request"
+ long theoreticalMaxExpiry = Duration.ofDays(365).getSeconds();
+ if (expiresInSeconds <= 0 || expiresInSeconds > theoreticalMaxExpiry) {
+ throw new AccessTokenException(
+ "The response from the token endpoint " + tokenEndpointUri + " stated a lifetime of " +
+ expiresInSeconds + " seconds for the access token, which is not a usable value. Expected a " +
+ "positive number of seconds, and at most " + theoreticalMaxExpiry + ".");
+ }
+
+ return new TokenResponse(accessToken, expiresInSeconds);
+ }
+
+ private static String readBody(ClassicHttpResponse response) throws IOException, ParseException {
+ HttpEntity entity = response.getEntity();
+ return entity == null ? "" : EntityUtils.toString(entity, UTF_8);
+ }
+
+ private static String truncate(String body) {
+ if (body.isEmpty()) {
+ return "(empty)";
+ }
+ return body.length() <= MAX_REPORTED_ERROR_BODY_LENGTH
+ ? body
+ : body.substring(0, MAX_REPORTED_ERROR_BODY_LENGTH) + "... (truncated)";
+ }
+
+ private static List clientCredentialsParameters(AccessTokenRequest request) {
+ List parameters = new ArrayList<>();
+ parameters.add(new BasicNameValuePair("grant_type", "client_credentials"));
+ parameters.add(new BasicNameValuePair("client_id", request.clientId));
+ parameters.add(new BasicNameValuePair("scope", request.scope));
+ parameters.add(new BasicNameValuePair("resource", request.resource));
+ return unmodifiableList(parameters);
+ }
+
+ /**
+ * Build an {@link SSLContext} which presents the client certificate of the given key store,
+ * and which validates the server using the JVM's default trust store.
+ */
+ private static SSLContext mutualTlsSslContext(KeyStoreConfig keyStoreConfig) {
+ try {
+ return SSLContexts.custom()
+ .loadKeyMaterial(
+ keyStoreConfig.keyStore, keyStoreConfig.privatekeyPassword.toCharArray(),
+ (aliases, socket) -> keyStoreConfig.alias)
+ .build();
+ } catch (Exception e) {
+ throw new KeyException(
+ "Unable to create the SSLContext used to authenticate with the token endpoint, because " +
+ e.getClass().getSimpleName() + ": '" + e.getMessage() + "'", e);
+ }
+ }
+
+
+ /**
+ * The fields of interest from a token endpoint response.
+ */
+ private static final class TokenResponse {
+
+ final String accessToken;
+ final long expiresInSeconds;
+
+ TokenResponse(String accessToken, long expiresInSeconds) {
+ this.accessToken = accessToken;
+ this.expiresInSeconds = expiresInSeconds;
+ }
+ }
+
+
+ /**
+ * An acquired token together with the point in time where it should be replaced. Kept as a
+ * single immutable value in one field, so that a token and its expiry can never be observed
+ * out of sync by a thread reading the cache without holding the refresh lock.
+ */
+ private static final class CachedAccessToken {
+
+ final String token;
+ private final Instant staleAt;
+
+ CachedAccessToken(String token, Instant staleAt) {
+ this.token = token;
+ this.staleAt = staleAt;
+ }
+
+ boolean isValidAt(Instant time) {
+ return time.isBefore(staleAt);
+ }
+ }
+
+}
diff --git a/lib/src/main/java/no/digipost/signature/client/security/BrokerId.java b/lib/src/main/java/no/digipost/signature/client/security/BrokerId.java
new file mode 100644
index 00000000..c330c526
--- /dev/null
+++ b/lib/src/main/java/no/digipost/signature/client/security/BrokerId.java
@@ -0,0 +1,67 @@
+package no.digipost.signature.client.security;
+
+import no.digipost.signature.client.core.Sender;
+import no.digipost.signature.client.core.exceptions.ConfigurationException;
+
+import java.util.Objects;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * The brokerId tied to the JWT client you've configured. It is used as part of the {@code scope} access
+ * tokens are requested for. It is issued together with the {@link JwtAuthConfig#clientId client id},
+ * and there is exactly one broker id for a given client id in the signature-api specifically.
+ *
+ * Note that this is not an organization number, and not an id used anywhere else in this
+ * library. If you only act on behalf of your own organization, it is simply another id for it.
+ *
+ *
The broker id never changes for a client, so access tokens are always acquired as the same
+ * organization. Which {@link Sender sender} a signature job is created for is separate from this,
+ * and a broker acting on behalf of several organizations can set it per job with
+ * {@link no.digipost.signature.client.portal.PortalJob.Builder#withSender(Sender) PortalJob.Builder.withSender(..)}
+ * or {@link no.digipost.signature.client.direct.DirectJob.Builder#withSender(Sender) DirectJob.Builder.withSender(..)}.
+ *
+ * @see JwtAuthConfig
+ */
+public final class BrokerId {
+
+ private final String id;
+
+ /**
+ * @param id the broker id for your client in signature-api
+ */
+ public static BrokerId of(String id) {
+ requireNonNull(id, "broker id");
+ if (id.trim().isEmpty()) {
+ throw new ConfigurationException("The broker id must not be blank");
+ }
+ return new BrokerId(id);
+ }
+
+ private BrokerId(String id) {
+ this.id = id;
+ }
+
+ /**
+ * The broker id as the identity provider knows it.
+ */
+ public String value() {
+ return id;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof BrokerId && Objects.equals(this.id, ((BrokerId) other).id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+ @Override
+ public String toString() {
+ return "broker " + id;
+ }
+
+}
diff --git a/lib/src/main/java/no/digipost/signature/client/security/JwtAuthConfig.java b/lib/src/main/java/no/digipost/signature/client/security/JwtAuthConfig.java
new file mode 100644
index 00000000..59e03ccb
--- /dev/null
+++ b/lib/src/main/java/no/digipost/signature/client/security/JwtAuthConfig.java
@@ -0,0 +1,70 @@
+package no.digipost.signature.client.security;
+
+import no.digipost.signature.client.core.exceptions.ConfigurationException;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Configuration for authenticating with Posten signering using an OAuth 2.0
+ * client credentials grant over a mutually authenticated TLS connection.
+ *
+ * The client authenticates to the token endpoint by presenting its client certificate
+ * during the TLS handshake (together with a {@code client_id} form parameter), and the access
+ * token returned is treated as an opaque bearer token which is sent as an
+ * {@code Authorization: Bearer } header on API requests.
+ *
+ * The client certificate and private key are not part of this
+ * configuration. The {@link KeyStoreConfig} already passed to
+ * {@link no.digipost.signature.client.ClientConfiguration#builder(KeyStoreConfig) ClientConfiguration.builder(..)}
+ * is used for the mTLS handshake against the token endpoint, and for signing document bundles.
+ * The client certificate is not presented when connecting to the API itself:
+ * those requests are authenticated with the acquired access token alone.
+ *
+ *
Neither is the token endpoint part of this configuration. It belongs to the
+ * {@link no.digipost.signature.client.ServiceEnvironment ServiceEnvironment} the client is
+ * configured with, which knows the endpoint for each of the predefined environments.
+ *
+ * @see no.digipost.signature.client.ClientConfiguration.Builder#jwtAuthentication(JwtAuthConfig)
+ */
+public final class JwtAuthConfig {
+
+ /**
+ * The client id identifying this integration to the token endpoint, sent as the
+ * {@code client_id} parameter.
+ */
+ public final String clientId;
+
+ /**
+ * A JWT client is configured for a specific broker in signature-api and is used as part of the scope.
+ * This is not the sender a signature job is created on behalf of.
+ */
+ public final BrokerId brokerId;
+
+ /**
+ * Configure JWT/mTLS authentication for the given client id and broker id, which are issued
+ * together and belong to each other.
+ *
+ * @param clientId the client id registered for your certificate,
+ * identifying this integration to the token endpoint
+ * @param brokerId the {@link BrokerId broker} to acquire access tokens as
+ */
+ public static JwtAuthConfig forClient(String clientId, BrokerId brokerId) {
+ requireNonNull(clientId, "client id");
+ requireNonNull(brokerId, "broker id");
+ if (clientId.trim().isEmpty()) {
+ throw new ConfigurationException("The client id must not be blank");
+ }
+ return new JwtAuthConfig(clientId, brokerId);
+ }
+
+ private JwtAuthConfig(String clientId, BrokerId brokerId) {
+ this.clientId = clientId;
+ this.brokerId = brokerId;
+ }
+
+ @Override
+ public String toString() {
+ return "JWT/mTLS authentication for client '" + clientId + "' as " + brokerId;
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/ClientConfigurationClientCertificateTest.java b/lib/src/test/java/no/digipost/signature/client/ClientConfigurationClientCertificateTest.java
new file mode 100644
index 00000000..5f3c4bb0
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/ClientConfigurationClientCertificateTest.java
@@ -0,0 +1,139 @@
+package no.digipost.signature.client;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import no.digipost.signature.client.core.Sender;
+import no.digipost.signature.client.security.CertificateChainValidation;
+import no.digipost.signature.client.security.BrokerId;
+import no.digipost.signature.client.security.JwtAuthConfig;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
+import org.junit.jupiter.api.Test;
+
+import javax.net.ssl.SSLContext;
+
+import java.io.IOException;
+import java.net.URI;
+import java.security.GeneralSecurityException;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Optional;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.givenThat;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static no.digipost.signature.client.ServiceEnvironment.STAGING;
+import static no.digipost.signature.client.TestKonfigurasjon.CLIENT_KEYSTORE;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static uk.co.probablyfine.matchers.OptionalMatchers.contains;
+import static uk.co.probablyfine.matchers.OptionalMatchers.empty;
+
+/**
+ * Verifies which certificate, if any, is actually presented to the API. This uses a real TLS
+ * handshake against a local server, as no amount of inspecting the client configuration can prove
+ * what ends up on the wire.
+ *
+ * @see no.digipost.signature.client.core.internal.http.MutualTlsTokenProviderClientCertificateTest
+ * for the same kind of assertion on the connection to the token endpoint
+ */
+@WireMockTest
+class ClientConfigurationClientCertificateTest {
+
+ private static final String TOKEN_PATH = "/token";
+
+ private final JwtAuthConfig jwtAuthConfig;
+
+ private final URI tokenEndpoint;
+
+ ClientConfigurationClientCertificateTest(WireMockRuntimeInfo wireMockInfo) {
+ this.jwtAuthConfig = JwtAuthConfig.forClient("my-client-id", BrokerId.of("555444"));
+ this.tokenEndpoint = URI.create(wireMockInfo.getHttpBaseUrl() + TOKEN_PATH);
+ }
+
+
+ @Test
+ void presentsTheClientCertificateWhenAuthenticatingWithTheCertificate() throws Exception {
+ try (TestClientCertificateRecordingServer apiServer = startApiServer()) {
+
+ callApi(configFor(apiServer.baseUri()).build(), apiServer.baseUri());
+
+ List> presented = apiServer.presentedClientCertificates();
+ assertThat(presented, hasSize(1));
+ assertThat(presented.get(0), contains(CLIENT_KEYSTORE.getCertificate()));
+ }
+ }
+
+ @Test
+ void doesNotPresentTheClientCertificateWhenAuthenticatingWithAnAccessToken() throws Exception {
+ stubTokenEndpoint();
+
+ try (TestClientCertificateRecordingServer apiServer = startApiServer()) {
+
+ callApi(configFor(apiServer.baseUri()).jwtAuthentication(jwtAuthConfig).build(), apiServer.baseUri());
+
+ List> presented = apiServer.presentedClientCertificates();
+ assertThat(presented, hasSize(1));
+ assertThat(presented.get(0), empty());
+ }
+ }
+
+ @Test
+ void doesNotPresentTheClientCertificateForDocumentDownloadsEither() throws Exception {
+ stubTokenEndpoint();
+
+ try (TestClientCertificateRecordingServer apiServer = startApiServer()) {
+ ClientConfiguration config = configFor(apiServer.baseUri()).jwtAuthentication(jwtAuthConfig).build();
+
+ call(config.httpClientForDocumentDownloads(), apiServer.baseUri());
+
+ List> presented = apiServer.presentedClientCertificates();
+ assertThat(presented, hasSize(1));
+ assertThat(presented.get(0), empty());
+ }
+ }
+
+
+ /**
+ * The API test server presents the test client certificate. Which certificate it is does not
+ * matter here, as the client is configured below to accept it as-is.
+ */
+ private static TestClientCertificateRecordingServer startApiServer() throws IOException, GeneralSecurityException {
+ SSLContext serverSslContext = TestClientCertificateRecordingServer.sslContextPresenting(
+ CLIENT_KEYSTORE.keyStore, CLIENT_KEYSTORE.privatekeyPassword.toCharArray());
+ return TestClientCertificateRecordingServer.start(serverSslContext, "text/plain", "ok");
+ }
+
+ private static void stubTokenEndpoint() {
+ givenThat(post(urlEqualTo(TOKEN_PATH))
+ .willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":3600}")));
+ }
+
+ /**
+ * The test server presents a self-signed certificate, so the usual validation that the server
+ * identifies itself as Posten Bring AS is replaced with one which accepts it as-is.
+ */
+ private ClientConfiguration.Builder configFor(URI apiBaseUri) {
+ return ClientConfiguration.builder(CLIENT_KEYSTORE)
+ .serviceEnvironment(STAGING.withServiceUrl(apiBaseUri).withTokenEndpoint(tokenEndpoint))
+ .defaultSender(new Sender("123456789"))
+ .serverCertificateTrustStrategy(
+ certificateChain -> CertificateChainValidation.Result.TRUSTED_AND_SKIP_FURTHER_VALIDATION);
+ }
+
+ private static void callApi(ClientConfiguration config, URI apiBaseUri) throws IOException {
+ call(config.defaultHttpClient(), apiBaseUri);
+ }
+
+ private static void call(HttpClient httpClient, URI apiBaseUri) throws IOException {
+ ClassicHttpRequest request = ClassicRequestBuilder.get(apiBaseUri + "/any-resource").build();
+ httpClient.execute(request, response -> {
+ EntityUtils.consume(response.getEntity());
+ return null;
+ });
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/ClientConfigurationJwtAuthTest.java b/lib/src/test/java/no/digipost/signature/client/ClientConfigurationJwtAuthTest.java
new file mode 100644
index 00000000..ebf369a5
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/ClientConfigurationJwtAuthTest.java
@@ -0,0 +1,291 @@
+package no.digipost.signature.client;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import no.digipost.signature.api.xml.XMLPortalSignatureJobResponse;
+import no.digipost.signature.client.core.PAdESReference;
+import no.digipost.signature.client.core.Sender;
+import no.digipost.signature.client.core.exceptions.ConfigurationException;
+import no.digipost.signature.client.portal.PortalClient;
+import no.digipost.signature.client.portal.PortalDocument;
+import no.digipost.signature.client.portal.PortalJob;
+import no.digipost.signature.client.portal.PortalSigner;
+import no.digipost.signature.client.security.BrokerId;
+import no.digipost.signature.client.security.JwtAuthConfig;
+import com.github.tomakehurst.wiremock.verification.LoggedRequest;
+import no.digipost.signature.jaxb.JaxbMarshaller;
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.absent;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.findAll;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.givenThat;
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static no.digipost.signature.client.ServiceEnvironment.STAGING;
+import static no.digipost.signature.client.TestKonfigurasjon.CLIENT_KEYSTORE;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static uk.co.probablyfine.matchers.Java8Matchers.where;
+
+@WireMockTest
+class ClientConfigurationJwtAuthTest {
+
+ private static final JaxbMarshaller responseMarshaller = JaxbMarshaller.ForResponsesOfAllApis.singleton();
+
+ private static final String TOKEN_PATH = "/token";
+
+ /**
+ * Matches the token endpoint whether or not the request is sent through a proxy, as a proxied
+ * request states the whole URI rather than just the path.
+ */
+ private static final String ANY_TOKEN_PATH = ".*" + TOKEN_PATH + "$";
+
+ private static final String JOBS_PATH = ".*/portal/signature-jobs";
+
+ private final ServiceEnvironment unitTestEnv;
+ private final JwtAuthConfig jwtAuthConfig;
+ private final ClientConfiguration.Builder configBuilder;
+ private final URI wireMockBaseUri;
+
+ ClientConfigurationJwtAuthTest(WireMockRuntimeInfo wireMockInfo) {
+ this.wireMockBaseUri = URI.create(wireMockInfo.getHttpBaseUrl());
+ this.unitTestEnv = STAGING
+ .withServiceUrl(URI.create(wireMockInfo.getHttpBaseUrl()))
+ .withTokenEndpoint(URI.create(wireMockInfo.getHttpBaseUrl() + TOKEN_PATH));
+ this.jwtAuthConfig = JwtAuthConfig.forClient("my-client-id", BrokerId.of("555444"));
+ this.configBuilder = ClientConfiguration.builder(CLIENT_KEYSTORE)
+ .serviceEnvironment(unitTestEnv)
+ .defaultSender(new Sender("123456789"));
+ }
+
+
+ @Test
+ void requestsAnAccessTokenForTheScopeOfTheConfiguredBroker() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ new PortalClient(configBuilder.jwtAuthentication(jwtAuthConfig).build()).create(aPortalJob());
+
+ verify(postRequestedFor(urlEqualTo(TOKEN_PATH))
+ .withRequestBody(containing("scope=signering-api%3A555444")));
+ }
+
+ /**
+ * A broker acquires its access tokens as itself, and may act on behalf of several organizations.
+ * Which sender a job is for is stated in the job itself, and must not influence the scope the
+ * token is requested for.
+ */
+ @Test
+ void theScopeIsTheBrokersRegardlessOfWhichSenderAJobIsFor() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ PortalClient client = new PortalClient(configBuilder.jwtAuthentication(jwtAuthConfig).build());
+ client.create(aPortalJobFor(new Sender("999888777")));
+
+ verify(postRequestedFor(urlEqualTo(TOKEN_PATH))
+ .withRequestBody(containing("scope=signering-api%3A555444")));
+ assertThat(tokenRequestParameter("scope"), is("signering-api:555444"));
+ }
+
+ /**
+ * The scope is the broker's, so nothing about acquiring an access token depends on a sender. A
+ * broker specifying the sender per job does not need a default one.
+ */
+ @Test
+ void doesNotRequireADefaultSender() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ ClientConfiguration withoutDefaultSender = ClientConfiguration.builder(CLIENT_KEYSTORE)
+ .serviceEnvironment(unitTestEnv)
+ .jwtAuthentication(jwtAuthConfig)
+ .build();
+
+ new PortalClient(withoutDefaultSender).create(aPortalJobFor(new Sender("999888777")));
+
+ verify(postRequestedFor(urlEqualTo(TOKEN_PATH))
+ .withRequestBody(containing("scope=signering-api%3A555444")));
+ }
+
+ @Test
+ void requestsAnAccessTokenForTheApiOfTheServiceEnvironment() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ new PortalClient(configBuilder.jwtAuthentication(jwtAuthConfig).build()).create(aPortalJob());
+
+ assertThat(tokenRequestParameter("resource"), is(unitTestEnv.signatureServiceRootUrl().toString()));
+ }
+
+ @Test
+ void requiresTheServiceEnvironmentToKnowATokenEndpoint() {
+ ClientConfiguration.Builder customEnvironmentWithoutTokenEndpoint = ClientConfiguration.builder(CLIENT_KEYSTORE)
+ .serviceEnvironment(new ServiceEnvironment(
+ "Custom", unitTestEnv.signatureServiceRootUrl(), unitTestEnv.certificatePaths()))
+ .defaultSender(new Sender("123456789"))
+ .jwtAuthentication(jwtAuthConfig);
+
+ ConfigurationException thrown = assertThrows(
+ ConfigurationException.class, customEnvironmentWithoutTokenEndpoint::build);
+ assertThat(thrown, where(Throwable::getMessage, containsString("withTokenEndpoint")));
+ }
+
+ @Test
+ void sendsTheAccessTokenAsABearerTokenOnApiRequests() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ new PortalClient(configBuilder.jwtAuthentication(jwtAuthConfig).build()).create(aPortalJob());
+
+ verify(postRequestedFor(urlPathMatching(JOBS_PATH))
+ .withHeader("Authorization", equalTo("Bearer a-token")));
+ }
+
+ @Test
+ void documentDownloadsAlsoCarryTheAccessToken() throws IOException {
+ stubTokenEndpoint("a-token");
+ givenThat(get(urlPathMatching(".*/pades$")).willReturn(ok("a PDF")));
+
+ PortalClient client = new PortalClient(configBuilder.jwtAuthentication(jwtAuthConfig).build());
+ PAdESReference padesReference = PAdESReference.of(URI.create(unitTestEnv.signatureServiceRootUrl() + "/pades"));
+
+ try (InputStream pades = client.getPAdES(padesReference)) {
+ assertThat(IOUtils.toString(pades, UTF_8), is("a PDF"));
+ }
+
+ verify(getRequestedFor(urlPathMatching(".*/pades$"))
+ .withHeader("Authorization", equalTo("Bearer a-token")));
+ }
+
+ @Test
+ void acquiresTheAccessTokenOnlyOnceForSeveralApiRequests() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ PortalClient client = new PortalClient(configBuilder.jwtAuthentication(jwtAuthConfig).build());
+ client.create(aPortalJob());
+ client.create(aPortalJob());
+
+ verify(1, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ verify(2, postRequestedFor(urlPathMatching(JOBS_PATH)));
+ }
+
+ @Test
+ void sendsNoAuthorizationHeaderAndAcquiresNoTokenWhenJwtAuthenticationIsNotConfigured() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ new PortalClient(configBuilder.build()).create(aPortalJob());
+
+ verify(0, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ verify(postRequestedFor(urlPathMatching(JOBS_PATH)).withHeader("Authorization", absent()));
+ }
+
+ /**
+ * A proxy is configured for the client as a whole, and acquiring access tokens must go through it
+ * as well. In a proxied environment the token endpoint would otherwise be unreachable.
+ */
+ @Test
+ void routesTokenRequestsThroughTheConfiguredProxy() {
+ givenThat(post(urlPathMatching(ANY_TOKEN_PATH))
+ .willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":3600}")));
+ stubCreateJob();
+
+ // Nothing is listening on port 1, so the token endpoint is only reachable via the proxy.
+ ClientConfiguration proxiedConfig = ClientConfiguration.builder(CLIENT_KEYSTORE)
+ .serviceEnvironment(unitTestEnv.withTokenEndpoint(URI.create("http://localhost:1" + TOKEN_PATH)))
+ .defaultSender(new Sender("123456789"))
+ .proxyHost(wireMockBaseUri)
+ .jwtAuthentication(jwtAuthConfig)
+ .build();
+
+ new PortalClient(proxiedConfig).create(aPortalJob());
+
+ verify(postRequestedFor(urlPathMatching(ANY_TOKEN_PATH)));
+ }
+
+ @Test
+ void stillSendsTheMandatoryUserAgentToTheTokenEndpoint() {
+ stubTokenEndpoint("a-token");
+ stubCreateJob();
+
+ new PortalClient(configBuilder.includeInUserAgent("My Corporation").jwtAuthentication(jwtAuthConfig).build())
+ .create(aPortalJob());
+
+ verify(postRequestedFor(urlEqualTo(TOKEN_PATH))
+ .withHeader("User-Agent", containing("My Corporation")));
+ }
+
+
+ /**
+ * The decoded value of a single form parameter from the one expected token request, so that
+ * assertions can be made on the actual value rather than on its url-encoded form.
+ */
+ private static String tokenRequestParameter(String name) {
+ List tokenRequests = findAll(postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ assertThat(tokenRequests, hasSize(1));
+ return Stream.of(tokenRequests.get(0).getBodyAsString().split("&"))
+ .map(parameter -> parameter.split("=", 2))
+ .filter(parameter -> parameter[0].equals(name))
+ .map(parameter -> urlDecode(parameter[1]))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(
+ "No '" + name + "' parameter in the token request: " + tokenRequests.get(0).getBodyAsString()));
+ }
+
+ private static String urlDecode(String value) {
+ try {
+ return URLDecoder.decode(value, UTF_8.name());
+ } catch (UnsupportedEncodingException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static void stubTokenEndpoint(String accessToken) {
+ givenThat(post(urlEqualTo(TOKEN_PATH))
+ .willReturn(okJson("{\"access_token\":\"" + accessToken + "\",\"expires_in\":3600}")));
+ }
+
+ private void stubCreateJob() {
+ givenThat(post(urlPathMatching(JOBS_PATH)).willReturn(ok(responseMarshaller.marshalToString(
+ new XMLPortalSignatureJobResponse(null, 42, unitTestEnv.signatureServiceRootUrl())))));
+ }
+
+ private static PortalJob aPortalJob() {
+ return aPortalJobBuilder().build();
+ }
+
+ private static PortalJob aPortalJobFor(Sender sender) {
+ return aPortalJobBuilder().withSender(sender).build();
+ }
+
+ private static PortalJob.Builder aPortalJobBuilder() {
+ return PortalJob.builder("Job title",
+ PortalDocument.builder("Document title", "contents".getBytes(UTF_8)).build(),
+ PortalSigner.identifiedByEmail("jane@example.com").build());
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/ClientConfigurationRejectedTokenTest.java b/lib/src/test/java/no/digipost/signature/client/ClientConfigurationRejectedTokenTest.java
new file mode 100644
index 00000000..93913b9e
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/ClientConfigurationRejectedTokenTest.java
@@ -0,0 +1,190 @@
+package no.digipost.signature.client;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.github.tomakehurst.wiremock.verification.LoggedRequest;
+import no.digipost.signature.api.xml.XMLPortalSignatureJobResponse;
+import no.digipost.signature.client.core.PAdESReference;
+import no.digipost.signature.client.core.Sender;
+import no.digipost.signature.client.core.exceptions.SignatureException;
+import no.digipost.signature.client.direct.DirectClient;
+import no.digipost.signature.client.direct.WithSignerUrl;
+import no.digipost.signature.client.portal.PortalClient;
+import no.digipost.signature.client.portal.PortalDocument;
+import no.digipost.signature.client.portal.PortalJob;
+import no.digipost.signature.client.portal.PortalSigner;
+import no.digipost.signature.client.security.BrokerId;
+import no.digipost.signature.client.security.JwtAuthConfig;
+import no.digipost.signature.jaxb.JaxbMarshaller;
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.findAll;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.givenThat;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static no.digipost.signature.client.ServiceEnvironment.STAGING;
+import static no.digipost.signature.client.TestKonfigurasjon.CLIENT_KEYSTORE;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * An access token can be rejected before this client considers it stale, e.g. if it is revoked or if
+ * this host's clock runs ahead of the token endpoint's. The rejected token must then be discarded,
+ * and the request retried when it is safe to do so.
+ */
+@WireMockTest
+class ClientConfigurationRejectedTokenTest {
+
+ private static final JaxbMarshaller responseMarshaller = JaxbMarshaller.ForResponsesOfAllApis.singleton();
+
+ private static final String TOKEN_PATH = "/token";
+ private static final String JOBS_PATH = ".*/portal/signature-jobs";
+ private static final String PADES_PATH = ".*/pades$";
+ private static final String SIGNER_PATH = ".*/signers/.*";
+
+ private final ServiceEnvironment unitTestEnv;
+ private final ClientConfiguration.Builder configBuilder;
+
+ ClientConfigurationRejectedTokenTest(WireMockRuntimeInfo wireMockInfo) {
+ this.unitTestEnv = STAGING
+ .withServiceUrl(URI.create(wireMockInfo.getHttpBaseUrl()))
+ .withTokenEndpoint(URI.create(wireMockInfo.getHttpBaseUrl() + TOKEN_PATH));
+ this.configBuilder = ClientConfiguration.builder(CLIENT_KEYSTORE)
+ .serviceEnvironment(unitTestEnv)
+ .defaultSender(new Sender("123456789"))
+ .jwtAuthentication(JwtAuthConfig.forClient("my-client-id", BrokerId.of("555444")));
+ }
+
+
+ @Test
+ void retriesASafeRequestOnceWithAFreshTokenWhenTheFirstIsRejected() throws IOException {
+ stubTwoTokensInSequence();
+
+ String scenario = "rejected token";
+ givenThat(get(urlPathMatching(PADES_PATH)).inScenario(scenario).whenScenarioStateIs(STARTED)
+ .willReturn(aResponse().withStatus(401).withBody("token rejected"))
+ .willSetStateTo("token rejected once"));
+ givenThat(get(urlPathMatching(PADES_PATH)).inScenario(scenario).whenScenarioStateIs("token rejected once")
+ .willReturn(ok("a PDF")));
+
+ PortalClient client = new PortalClient(configBuilder.build());
+ try (InputStream pades = client.getPAdES(PAdESReference.of(
+ URI.create(unitTestEnv.signatureServiceRootUrl() + "/pades")))) {
+ assertThat(IOUtils.toString(pades, UTF_8), is("a PDF"));
+ }
+
+ // Transparent to the caller: two attempts were made, the second with the replacement token.
+ assertThat(authorizationHeadersOf(findAll(getRequestedFor(urlPathMatching(PADES_PATH)))),
+ contains("Bearer first-token", "Bearer second-token"));
+ verify(2, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ @Test
+ void doesNotRetryCreatingASignatureJobButStillDiscardsTheRejectedToken() {
+ stubTwoTokensInSequence();
+ givenThat(post(urlPathMatching(JOBS_PATH)).willReturn(aResponse().withStatus(401).withBody("token rejected")));
+
+ PortalClient client = new PortalClient(configBuilder.build());
+
+ // The rejected request is not retried, as sending a signature job twice is not safe.
+ assertThrows(SignatureException.class, () -> client.create(aPortalJob()));
+ assertThrows(SignatureException.class, () -> client.create(aPortalJob()));
+
+ // ... but the token was discarded, so the second attempt used a newly acquired one.
+ assertThat(authorizationHeadersOf(findAll(postRequestedFor(urlPathMatching(JOBS_PATH)))),
+ contains("Bearer first-token", "Bearer second-token"));
+ verify(2, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ @Test
+ void keepsUsingTheCachedTokenWhenRequestsSucceed() {
+ stubTwoTokensInSequence();
+ givenThat(post(urlPathMatching(JOBS_PATH)).willReturn(ok(responseMarshaller.marshalToString(
+ new XMLPortalSignatureJobResponse(null, 42, unitTestEnv.signatureServiceRootUrl())))));
+
+ PortalClient client = new PortalClient(configBuilder.build());
+ client.create(aPortalJob());
+ client.create(aPortalJob());
+
+ assertThat(authorizationHeadersOf(findAll(postRequestedFor(urlPathMatching(JOBS_PATH)))),
+ contains("Bearer first-token", "Bearer first-token"));
+ verify(1, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ /**
+ * Requesting a new redirect URL is a POST whose entity is repeatable, unlike creating a
+ * signature job. It must still not be retried, as it is not a safe request to repeat.
+ */
+ @Test
+ void doesNotRetryAnUnsafeRequestEvenWhenItsBodyCouldBeResent() {
+ stubTwoTokensInSequence();
+ givenThat(post(urlPathMatching(SIGNER_PATH)).willReturn(aResponse().withStatus(401).withBody("token rejected")));
+
+ DirectClient client = new DirectClient(configBuilder.build());
+ WithSignerUrl signerUrl = WithSignerUrl.of(
+ URI.create(unitTestEnv.signatureServiceRootUrl() + "/direct/signature-jobs/1/signers/1"));
+
+ assertThrows(SignatureException.class, () -> client.requestNewRedirectUrl(signerUrl));
+
+ verify(1, postRequestedFor(urlPathMatching(SIGNER_PATH)));
+ assertThat(authorizationHeadersOf(findAll(postRequestedFor(urlPathMatching(SIGNER_PATH)))),
+ contains("Bearer first-token"));
+ }
+
+ @Test
+ void aPersistentlyRejectedTokenIsRetriedOnlyOncePerRequest() {
+ stubTwoTokensInSequence();
+ givenThat(get(urlPathMatching(PADES_PATH)).willReturn(aResponse().withStatus(401).withBody("token rejected")));
+
+ PortalClient client = new PortalClient(configBuilder.build());
+ PAdESReference pades = PAdESReference.of(URI.create(unitTestEnv.signatureServiceRootUrl() + "/pades"));
+
+ assertThrows(SignatureException.class, () -> client.getPAdES(pades));
+
+ // One initial attempt and exactly one retry, rather than looping.
+ verify(2, getRequestedFor(urlPathMatching(PADES_PATH)));
+ }
+
+
+ private static List authorizationHeadersOf(List requests) {
+ return requests.stream()
+ .map(request -> request.getHeader("Authorization"))
+ .collect(Collectors.toList());
+ }
+
+ private static void stubTwoTokensInSequence() {
+ String scenario = "two tokens";
+ givenThat(post(urlEqualTo(TOKEN_PATH)).inScenario(scenario).whenScenarioStateIs(STARTED)
+ .willReturn(okJson("{\"access_token\":\"first-token\",\"expires_in\":3600}"))
+ .willSetStateTo("first token acquired"));
+ givenThat(post(urlEqualTo(TOKEN_PATH)).inScenario(scenario).whenScenarioStateIs("first token acquired")
+ .willReturn(okJson("{\"access_token\":\"second-token\",\"expires_in\":3600}")));
+ }
+
+ private static PortalJob aPortalJob() {
+ return PortalJob.builder("Job title",
+ PortalDocument.builder("Document title", "contents".getBytes(UTF_8)).build(),
+ PortalSigner.identifiedByEmail("jane@example.com").build())
+ .build();
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/ServiceEnvironmentTest.java b/lib/src/test/java/no/digipost/signature/client/ServiceEnvironmentTest.java
new file mode 100644
index 00000000..5a3cb536
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/ServiceEnvironmentTest.java
@@ -0,0 +1,64 @@
+package no.digipost.signature.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.Arrays;
+
+import static java.util.Collections.singletonList;
+import static no.digipost.signature.client.ServiceEnvironment.DIFIQA;
+import static no.digipost.signature.client.ServiceEnvironment.DIFITEST;
+import static no.digipost.signature.client.ServiceEnvironment.PRODUCTION;
+import static no.digipost.signature.client.ServiceEnvironment.STAGING;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static uk.co.probablyfine.matchers.OptionalMatchers.contains;
+import static uk.co.probablyfine.matchers.OptionalMatchers.empty;
+
+class ServiceEnvironmentTest {
+
+ private static final URI CUSTOM_TOKEN_ENDPOINT = URI.create("https://midp.example.com/oauth2/token");
+
+ @Test
+ void thePredefinedEnvironmentsKnowTheirTokenEndpoint() {
+ assertThat(PRODUCTION.tokenEndpoint(), contains(URI.create("https://midp.digipost.no/oauth2/token")));
+ assertThat(DIFITEST.tokenEndpoint(), contains(URI.create("https://midp.difitest.digipost.no/oauth2/token")));
+ assertThat(DIFIQA.tokenEndpoint(), contains(URI.create("https://midp.qa.digipost.no/oauth2/token")));
+ }
+
+ @Test
+ void stagingInheritsTheTokenEndpointOfDifitest() {
+ assertThat(STAGING.tokenEndpoint(), is(DIFITEST.tokenEndpoint()));
+ }
+
+ /**
+ * Every copy method has to carry the token endpoint over, or enabling JWT authentication would
+ * silently stop working for anyone customizing their environment.
+ */
+ @Test
+ void copyingAnEnvironmentRetainsTheTokenEndpoint() {
+ assertThat(DIFITEST.withDescription("Other").tokenEndpoint(), is(DIFITEST.tokenEndpoint()));
+ assertThat(DIFITEST.withServiceUrl(URI.create("https://localhost:8443")).tokenEndpoint(), is(DIFITEST.tokenEndpoint()));
+ assertThat(DIFITEST.withCertificates("some/certificate.cer").tokenEndpoint(), is(DIFITEST.tokenEndpoint()));
+ assertThat(DIFITEST.withCertificates(singletonList("some/certificate.cer")).tokenEndpoint(), is(DIFITEST.tokenEndpoint()));
+ assertThat(DIFITEST.withAdditionalCertificates("some/certificate.cer").tokenEndpoint(), is(DIFITEST.tokenEndpoint()));
+ assertThat(DIFITEST.withAdditionalCertificates(singletonList("some/certificate.cer")).tokenEndpoint(), is(DIFITEST.tokenEndpoint()));
+ }
+
+ @Test
+ void aCustomEnvironmentHasNoTokenEndpointUntilGivenOne() {
+ ServiceEnvironment custom = new ServiceEnvironment(
+ "Custom", URI.create("https://localhost:8443/api"), Arrays.asList("some/certificate.cer"));
+
+ assertThat(custom.tokenEndpoint(), empty());
+ assertThat(custom.withTokenEndpoint(CUSTOM_TOKEN_ENDPOINT).tokenEndpoint(), contains(CUSTOM_TOKEN_ENDPOINT));
+ }
+
+ @Test
+ void theTokenEndpointOfAPredefinedEnvironmentCanBeOverridden() {
+ assertThat(DIFITEST.withTokenEndpoint(CUSTOM_TOKEN_ENDPOINT).tokenEndpoint(), contains(CUSTOM_TOKEN_ENDPOINT));
+ // without affecting the predefined environment itself
+ assertThat(DIFITEST.tokenEndpoint(), contains(URI.create("https://midp.difitest.digipost.no/oauth2/token")));
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/TestClientCertificateRecordingServer.java b/lib/src/test/java/no/digipost/signature/client/TestClientCertificateRecordingServer.java
new file mode 100644
index 00000000..0b29cc13
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/TestClientCertificateRecordingServer.java
@@ -0,0 +1,155 @@
+package no.digipost.signature.client;
+
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsExchange;
+import com.sun.net.httpserver.HttpsParameters;
+import com.sun.net.httpserver.HttpsServer;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * A local HTTPS server which records the client certificate presented to it, if any, and answers
+ * every request with the same canned response. Used to assert what a client actually puts on the
+ * wire during the TLS handshake, which no amount of inspecting its configuration can prove.
+ *
+ * The server is configured to want, not require, a client certificate.
+ * Requiring one would make a client that sends none fail the handshake, which would prove only that
+ * something went wrong, not that no certificate was sent.
+ */
+public final class TestClientCertificateRecordingServer implements AutoCloseable {
+
+ private final HttpsServer server;
+ private final List> presentedClientCertificates = new CopyOnWriteArrayList<>();
+
+ private TestClientCertificateRecordingServer(HttpsServer server) {
+ this.server = server;
+ }
+
+ /**
+ * Start a server on a free port on localhost.
+ *
+ * @param serverSslContext the {@link SSLContext} the server presents itself with,
+ * cf. {@link #sslContextPresenting(KeyStore, char[])}
+ * @param contentType the {@code Content-Type} of the canned response
+ * @param body the body of the canned response
+ */
+ public static TestClientCertificateRecordingServer start(SSLContext serverSslContext, String contentType, String body)
+ throws IOException {
+
+ HttpsServer server = HttpsServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.setHttpsConfigurator(new HttpsConfigurator(serverSslContext) {
+ @Override
+ public void configure(HttpsParameters params) {
+ SSLParameters sslParameters = getSSLContext().getDefaultSSLParameters();
+ sslParameters.setWantClientAuth(true);
+ params.setSSLParameters(sslParameters);
+ }
+ });
+
+ TestClientCertificateRecordingServer recordingServer = new TestClientCertificateRecordingServer(server);
+ byte[] responseBody = body.getBytes(UTF_8);
+ server.createContext("/", exchange -> {
+ recordingServer.presentedClientCertificates.add(clientCertificateOf((HttpsExchange) exchange));
+
+ // The request body must be consumed before the response can be written.
+ try (InputStream request = exchange.getRequestBody()) {
+ byte[] discarded = new byte[4096];
+ while (request.read(discarded) != -1) {
+ // just draining
+ }
+ }
+
+ exchange.getResponseHeaders().set("Content-Type", contentType);
+ exchange.sendResponseHeaders(200, responseBody.length);
+ try (OutputStream response = exchange.getResponseBody()) {
+ response.write(responseBody);
+ }
+ });
+ server.start();
+ return recordingServer;
+ }
+
+ /**
+ * An {@link SSLContext} presenting the certificate of the given key store, and accepting any
+ * client certificate. The point of this server is to record what the client presented, not to
+ * judge it.
+ */
+ public static SSLContext sslContextPresenting(KeyStore keyStore, char[] keyPassword) throws GeneralSecurityException {
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(keyStore, keyPassword);
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagerFactory.getKeyManagers(), new TrustManager[]{acceptAnyClientCertificate()}, null);
+ return sslContext;
+ }
+
+ /**
+ * The base URI of this server, using the host name {@code localhost}. Clients which verify the
+ * server's host name will need its certificate to be issued for that name.
+ */
+ public URI baseUri() {
+ return URI.create("https://localhost:" + server.getAddress().getPort());
+ }
+
+ /**
+ * The client certificate presented for each request the server has received, in order, empty for
+ * the requests where the client presented none.
+ */
+ public List> presentedClientCertificates() {
+ return presentedClientCertificates;
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+
+ private static Optional clientCertificateOf(HttpsExchange exchange) {
+ try {
+ Certificate[] peerCertificates = exchange.getSSLSession().getPeerCertificates();
+ return Optional.of((X509Certificate) peerCertificates[0]);
+ } catch (SSLPeerUnverifiedException e) {
+ // Which is how the JVM communicates that the client presented no certificate at all.
+ return Optional.empty();
+ }
+ }
+
+ private static TrustManager acceptAnyClientCertificate() {
+ return new X509TrustManager() {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ };
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProviderClientCertificateTest.java b/lib/src/test/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProviderClientCertificateTest.java
new file mode 100644
index 00000000..9136cd65
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProviderClientCertificateTest.java
@@ -0,0 +1,172 @@
+package no.digipost.signature.client.core.internal.http;
+
+import no.digipost.signature.client.TestClientCertificateRecordingServer;
+import no.digipost.signature.client.core.exceptions.HttpIOException;
+import no.digipost.signature.client.core.internal.configuration.Configurer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.net.ssl.SSLContext;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import java.time.Clock;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import static no.digipost.signature.client.TestKonfigurasjon.CLIENT_KEYSTORE;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static uk.co.probablyfine.matchers.OptionalMatchers.contains;
+
+/**
+ * Verifies that the connection to the token endpoint really is mutually authenticated, i.e. that the
+ * client certificate is presented during the TLS handshake. This is the entire premise of
+ * {@link MutualTlsTokenProvider}, and only a real handshake against a real server can show it.
+ *
+ * The token endpoint client validates the server against the JVM's default trust store
+ * with ordinary host name verification, deliberately without the escape hatches the Posten signering
+ * API client has. That is the behaviour under test, so it is not circumvented here: instead the test
+ * server presents a certificate issued for {@code localhost}, and the JVM's default trust store is
+ * pointed at that certificate while the client is being built.
+ *
+ * @see no.digipost.signature.client.ClientConfigurationClientCertificateTest
+ * for the same kind of assertion on the connection to the API itself
+ */
+class MutualTlsTokenProviderClientCertificateTest {
+
+ /**
+ * A self signed certificate issued for {@code localhost}, valid until 2126. Regenerate with:
+ *
+ * keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -validity 36500 \
+ * -storetype PKCS12 -keystore lib/src/test/resources/localhost-tls-testserver.p12 \
+ * -storepass password1234 -keypass password1234 \
+ * -dname "CN=localhost, OU=Posten signering, O=Posten signering test, L=Oslo, C=NO" \
+ * -ext "SAN=dns:localhost,ip:127.0.0.1"
+ *
+ */
+ private static final String SERVER_KEYSTORE_RESOURCE = "/localhost-tls-testserver.p12";
+ private static final String SERVER_ALIAS = "localhost";
+ private static final char[] SERVER_PASSWORD = "password1234".toCharArray();
+
+ private static final String TOKEN_RESPONSE = "{\"access_token\":\"a-token\",\"expires_in\":3600}";
+
+
+ @Test
+ void presentsTheClientCertificateToTheTokenEndpoint(@TempDir Path tempDirectory) throws Exception {
+ KeyStore serverKeyStore = serverKeyStore();
+
+ try (TestClientCertificateRecordingServer tokenEndpoint = startTokenEndpoint(serverKeyStore)) {
+ Path trustStore = trustStoreContaining(serverKeyStore, tempDirectory);
+
+ MutualTlsTokenProvider tokenProvider = withDefaultTrustStore(trustStore,
+ () -> MutualTlsTokenProvider.create(
+ tokenRequestTo(tokenEndpoint.baseUri()), CLIENT_KEYSTORE,
+ Configurer.notConfigured(), Clock.systemUTC()));
+
+ assertThat(tokenProvider.getToken(), is("a-token"));
+
+ List> presented = tokenEndpoint.presentedClientCertificates();
+ assertThat(presented, hasSize(1));
+ assertThat(presented.get(0), contains(CLIENT_KEYSTORE.getCertificate()));
+ }
+ }
+
+ /**
+ * The token endpoint is a separate service from the Posten signering API, and is not covered by
+ * the API's trust configuration. It must be validated the ordinary way, which means an unknown
+ * certificate is refused rather than accepted.
+ */
+ @Test
+ void refusesATokenEndpointWhoseCertificateTheJvmDoesNotTrust() throws Exception {
+ try (TestClientCertificateRecordingServer tokenEndpoint = startTokenEndpoint(serverKeyStore())) {
+
+ // Note: no trust store override, so the self signed certificate of the test server is unknown.
+ MutualTlsTokenProvider tokenProvider = MutualTlsTokenProvider.create(
+ tokenRequestTo(tokenEndpoint.baseUri()), CLIENT_KEYSTORE,
+ Configurer.notConfigured(), Clock.systemUTC());
+
+ assertThrows(HttpIOException.class, tokenProvider::getToken);
+ assertThat(tokenEndpoint.presentedClientCertificates(), hasSize(0));
+ }
+ }
+
+
+ private static TestClientCertificateRecordingServer startTokenEndpoint(KeyStore serverKeyStore) throws Exception {
+ SSLContext serverSslContext = TestClientCertificateRecordingServer.sslContextPresenting(serverKeyStore, SERVER_PASSWORD);
+ return TestClientCertificateRecordingServer.start(serverSslContext, "application/json", TOKEN_RESPONSE);
+ }
+
+ private static AccessTokenRequest tokenRequestTo(URI tokenEndpointBaseUri) {
+ return new AccessTokenRequest(
+ URI.create(tokenEndpointBaseUri + "/token"),
+ "my-client-id",
+ "signering-api:555444",
+ "https://api.example.com");
+ }
+
+ private static KeyStore serverKeyStore() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ try (InputStream pkcs12 = MutualTlsTokenProviderClientCertificateTest.class.getResourceAsStream(SERVER_KEYSTORE_RESOURCE)) {
+ keyStore.load(pkcs12, SERVER_PASSWORD);
+ }
+ return keyStore;
+ }
+
+ /**
+ * A trust store containing only the test server's certificate, written to a file, as the JVM's
+ * default trust store is configured as a file path.
+ */
+ private static Path trustStoreContaining(KeyStore serverKeyStore, Path directory) throws Exception {
+ KeyStore trustStore = KeyStore.getInstance("JKS");
+ trustStore.load(null, null);
+ trustStore.setCertificateEntry("token-endpoint", serverKeyStore.getCertificate(SERVER_ALIAS));
+
+ Path trustStoreFile = directory.resolve("token-endpoint-truststore.jks");
+ try (OutputStream out = Files.newOutputStream(trustStoreFile)) {
+ trustStore.store(out, SERVER_PASSWORD);
+ }
+ return trustStoreFile;
+ }
+
+ /**
+ * Run the given action with the JVM's default trust store replaced by the given one.
+ *
+ * The action must be the creation of the {@link MutualTlsTokenProvider} itself, and nothing more:
+ * the {@link SSLContext} resolves its trust managers when it is initialized, so the replacement
+ * only has to be in place while the client is built, not while it is used.
+ */
+ private static T withDefaultTrustStore(Path trustStore, Supplier action) {
+ String previousPath = System.getProperty("javax.net.ssl.trustStore");
+ String previousType = System.getProperty("javax.net.ssl.trustStoreType");
+ String previousPassword = System.getProperty("javax.net.ssl.trustStorePassword");
+
+ System.setProperty("javax.net.ssl.trustStore", trustStore.toAbsolutePath().toString());
+ System.setProperty("javax.net.ssl.trustStoreType", "JKS");
+ System.setProperty("javax.net.ssl.trustStorePassword", new String(SERVER_PASSWORD));
+ try {
+ return action.get();
+ } finally {
+ restore("javax.net.ssl.trustStore", previousPath);
+ restore("javax.net.ssl.trustStoreType", previousType);
+ restore("javax.net.ssl.trustStorePassword", previousPassword);
+ }
+ }
+
+ private static void restore(String property, String previousValue) {
+ if (previousValue == null) {
+ System.clearProperty(property);
+ } else {
+ System.setProperty(property, previousValue);
+ }
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProviderTest.java b/lib/src/test/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProviderTest.java
new file mode 100644
index 00000000..a1bb4f48
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/core/internal/http/MutualTlsTokenProviderTest.java
@@ -0,0 +1,353 @@
+package no.digipost.signature.client.core.internal.http;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import no.digipost.signature.client.core.exceptions.AccessTokenException;
+import no.digipost.signature.client.core.exceptions.HttpIOException;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.givenThat;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED;
+import static java.time.Duration.ofSeconds;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static uk.co.probablyfine.matchers.Java8Matchers.where;
+
+@WireMockTest
+class MutualTlsTokenProviderTest {
+
+ private static final String TOKEN_PATH = "/token";
+ private static final Instant NOW = Instant.parse("2026-08-17T12:00:00Z");
+
+ private final AccessTokenRequest accessTokenRequest;
+ private final MutableClock clock = new MutableClock(NOW);
+ private final HttpClient httpClient = HttpClientBuilder.create().build();
+
+ MutualTlsTokenProviderTest(WireMockRuntimeInfo wireMockInfo) {
+ this.accessTokenRequest = new AccessTokenRequest(
+ URI.create(wireMockInfo.getHttpBaseUrl() + TOKEN_PATH),
+ "my-client-id",
+ "signering-api:555444",
+ "https://api.signering.posten.no/api");
+ }
+
+ private MutualTlsTokenProvider tokenProvider() {
+ return new MutualTlsTokenProvider(accessTokenRequest, httpClient, clock);
+ }
+
+
+ @Test
+ void acquiresAnAccessToken() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":3600}")));
+
+ assertThat(tokenProvider().getToken(), is("a-token"));
+ }
+
+ @Test
+ void sendsAClientCredentialsGrantWithScopeAndResource() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":3600}")));
+
+ tokenProvider().getToken();
+
+ verify(postRequestedFor(urlEqualTo(TOKEN_PATH))
+ .withHeader("Content-Type", containing("application/x-www-form-urlencoded"))
+ .withRequestBody(containing("grant_type=client_credentials"))
+ .withRequestBody(containing("client_id=my-client-id"))
+ .withRequestBody(containing("scope=signering-api%3A555444"))
+ .withRequestBody(containing("resource=https%3A%2F%2Fapi.signering.posten.no%2Fapi")));
+ }
+
+ @Test
+ void cachesTheTokenInsteadOfAcquiringOnePerCall() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":3600}")));
+
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+ assertThat(tokenProvider.getToken(), is("a-token"));
+ assertThat(tokenProvider.getToken(), is("a-token"));
+ clock.advance(ofSeconds(3600 - MutualTlsTokenProvider.REFRESH_MARGIN_SECONDS - 1));
+ assertThat(tokenProvider.getToken(), is("a-token"));
+
+ verify(1, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ @Test
+ void acquiresANewTokenOnceTheCachedOneIsWithinTheRefreshMargin() {
+ stubTwoTokensInSequence();
+
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+ assertThat(tokenProvider.getToken(), is("first-token"));
+
+ // Still outside the refresh margin, so the first token is reused.
+ clock.advance(ofSeconds(3600 - MutualTlsTokenProvider.REFRESH_MARGIN_SECONDS - 1));
+ assertThat(tokenProvider.getToken(), is("first-token"));
+
+ // Now within the refresh margin, and a new token is acquired even though the first has not
+ // technically expired yet.
+ clock.advance(ofSeconds(2));
+ assertThat(tokenProvider.getToken(), is("second-token"));
+
+ verify(2, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ /**
+ * Several threads may need an access token before any of them has one. Acquiring a token for each
+ * of them would be both wasteful and needless load on the token endpoint.
+ */
+ @Test
+ void concurrentCallersShareTheOneAcquiredToken() throws Exception {
+ givenThat(post(urlEqualTo(TOKEN_PATH))
+ .willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":3600}").withFixedDelay(200)));
+
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ int callers = 8;
+ ExecutorService threadPool = Executors.newFixedThreadPool(callers);
+ CountDownLatch releaseAllCallers = new CountDownLatch(1);
+ try {
+ List> acquiredTokens = new ArrayList<>();
+ for (int caller = 0; caller < callers; caller++) {
+ acquiredTokens.add(threadPool.submit(() -> {
+ releaseAllCallers.await();
+ return tokenProvider.getToken();
+ }));
+ }
+ releaseAllCallers.countDown();
+ for (Future acquiredToken : acquiredTokens) {
+ assertThat(acquiredToken.get(30, SECONDS), is("a-token"));
+ }
+ } finally {
+ threadPool.shutdownNow();
+ }
+
+ verify(1, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ @Test
+ void invalidatingTheCachedTokenMakesTheNextCallAcquireANewOne() {
+ stubTwoTokensInSequence();
+
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+ assertThat(tokenProvider.getToken(), is("first-token"));
+
+ tokenProvider.invalidate("first-token");
+
+ assertThat(tokenProvider.getToken(), is("second-token"));
+ verify(2, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ /**
+ * Two requests may fail with the same rejected token, or one may fail while another thread has
+ * already replaced it. Invalidating a token which is no longer the cached one must not throw away
+ * the replacement.
+ */
+ @Test
+ void invalidatingATokenWhichIsNoLongerTheCachedOneKeepsTheCachedOne() {
+ stubTwoTokensInSequence();
+
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+ assertThat(tokenProvider.getToken(), is("first-token"));
+
+ tokenProvider.invalidate("a-token-acquired-before-this-one");
+
+ assertThat(tokenProvider.getToken(), is("first-token"));
+ verify(1, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ @Test
+ void invalidatingWhenNoTokenIsCachedIsHarmless() {
+ stubTwoTokensInSequence();
+
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+ tokenProvider.invalidate("a-token-which-was-never-acquired");
+
+ assertThat(tokenProvider.getToken(), is("first-token"));
+ verify(1, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ /**
+ * A token which is already stale when it arrives is still handed out, as it is the only one there
+ * is, but it can not be cached. This is warned about, as it means the token endpoint is called for
+ * every single request.
+ */
+ @Test
+ void aTokenExpiringWithinTheRefreshMarginIsUsedButNotCached() {
+ givenThat(post(urlEqualTo(TOKEN_PATH))
+ .willReturn(okJson("{\"access_token\":\"a-short-lived-token\",\"expires_in\":10}")));
+
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+ assertThat(tokenProvider.getToken(), is("a-short-lived-token"));
+ assertThat(tokenProvider.getToken(), is("a-short-lived-token"));
+
+ verify(2, postRequestedFor(urlEqualTo(TOKEN_PATH)));
+ }
+
+ @Test
+ void anExpiresInWhichGivesTheTokenNoLifetimeIsRejected() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":0}")));
+
+ AccessTokenException thrown = assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ assertThat(thrown, where(Throwable::getMessage, containsString("not a usable value")));
+ }
+
+ @Test
+ void aNegativeExpiresInIsRejected() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":-1}")));
+
+ assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ }
+
+ /**
+ * A lifetime this large would overflow when added to the current time, and must be reported as the
+ * unusable response it is, not as an arithmetic error.
+ */
+ @Test
+ void anExpiresInWhichCannotBeAddedToTheCurrentTimeIsRejected() {
+ givenThat(post(urlEqualTo(TOKEN_PATH))
+ .willReturn(okJson("{\"access_token\":\"a-token\",\"expires_in\":" + Long.MAX_VALUE + "}")));
+
+ AccessTokenException thrown = assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ assertThat(thrown, where(Throwable::getMessage, containsString("not a usable value")));
+ }
+
+ @Test
+ void aResponseWithoutExpiresInIsReported() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"access_token\":\"an-opaque-token\"}")));
+
+ AccessTokenException thrown = assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ assertThat(thrown, where(Throwable::getMessage, containsString("expires_in")));
+ }
+
+ @Test
+ void aNonIntegerExpiresInIsNotAccepted() {
+ givenThat(post(urlEqualTo(TOKEN_PATH))
+ .willReturn(okJson("{\"access_token\":\"an-opaque-token\",\"expires_in\":\"3600\"}")));
+
+ assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ }
+
+ @Test
+ void ignoresOtherFieldsOfTheResponseIncludingNestedOnes() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson(
+ "{\"token_type\":\"Bearer\"," +
+ "\"nested\":{\"access_token\":\"decoy\",\"deeper\":[1,{\"expires_in\":1}]}," +
+ "\"access_token\":\"a-token\"," +
+ "\"expires_in\":3600}")));
+
+ assertThat(tokenProvider().getToken(), is("a-token"));
+ }
+
+ @Test
+ void anUnsuccessfulResponseIncludesTheStatusAndBody() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(aResponse().withStatus(401)
+ .withBody("{\"error\":\"invalid_client\"}")));
+
+ AccessTokenException thrown = assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ assertThat(thrown, where(Throwable::getMessage, containsString("401")));
+ assertThat(thrown, where(Throwable::getMessage, containsString("invalid_client")));
+ }
+
+ @Test
+ void anUnsuccessfulResponseWithoutABodyIsStillReported() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(aResponse().withStatus(503)));
+
+ AccessTokenException thrown = assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ assertThat(thrown, where(Throwable::getMessage, containsString("503")));
+ }
+
+ @Test
+ void aResponseWhichIsNotJsonIsReported() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(aResponse().withStatus(200)
+ .withBody("Gateway error")));
+
+ AccessTokenException thrown = assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ assertThat(thrown, where(Throwable::getMessage, containsString("as JSON")));
+ }
+
+ @Test
+ void aResponseWithoutAnAccessTokenIsReported() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"token_type\":\"Bearer\",\"expires_in\":60}")));
+
+ AccessTokenException thrown = assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ assertThat(thrown, where(Throwable::getMessage, containsString("access_token")));
+ }
+
+ @Test
+ void anEmptyAccessTokenIsReported() {
+ givenThat(post(urlEqualTo(TOKEN_PATH)).willReturn(okJson("{\"access_token\":\"\",\"expires_in\":60}")));
+
+ assertThrows(AccessTokenException.class, () -> tokenProvider().getToken());
+ }
+
+ @Test
+ void aTokenEndpointWhichCannotBeReachedIsReportedAsAnIoProblem() {
+ AccessTokenRequest unreachable = new AccessTokenRequest(
+ URI.create("http://localhost:1/token"), "my-client-id", "a-scope", "https://api.example.com");
+
+ assertThrows(HttpIOException.class,
+ () -> new MutualTlsTokenProvider(unreachable, httpClient, clock).getToken());
+ }
+
+
+ private static void stubTwoTokensInSequence() {
+ String scenario = "two tokens";
+ givenThat(post(urlEqualTo(TOKEN_PATH)).inScenario(scenario).whenScenarioStateIs(STARTED)
+ .willReturn(okJson("{\"access_token\":\"first-token\",\"expires_in\":3600}"))
+ .willSetStateTo("first token acquired"));
+ givenThat(post(urlEqualTo(TOKEN_PATH)).inScenario(scenario).whenScenarioStateIs("first token acquired")
+ .willReturn(okJson("{\"access_token\":\"second-token\",\"expires_in\":3600}")));
+ }
+
+ private static final class MutableClock extends Clock {
+
+ private Instant now;
+
+ MutableClock(Instant now) {
+ this.now = now;
+ }
+
+ void advance(Duration amount) {
+ this.now = this.now.plus(amount);
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/security/BrokerIdTest.java b/lib/src/test/java/no/digipost/signature/client/security/BrokerIdTest.java
new file mode 100644
index 00000000..a3acb9a0
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/security/BrokerIdTest.java
@@ -0,0 +1,37 @@
+package no.digipost.signature.client.security;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import no.digipost.signature.client.core.exceptions.ConfigurationException;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static uk.co.probablyfine.matchers.Java8Matchers.where;
+
+class BrokerIdTest {
+
+ @Test
+ void retainsTheGivenId() {
+ assertThat(BrokerId.of("555444").value(), is("555444"));
+ }
+
+ @Test
+ void requiresAnId() {
+ assertThrows(NullPointerException.class, () -> BrokerId.of(null));
+ }
+
+ @Test
+ void rejectsABlankId() {
+ assertThat(assertThrows(ConfigurationException.class, () -> BrokerId.of("")),
+ where(Throwable::getMessage, containsString("must not be blank")));
+ assertThrows(ConfigurationException.class, () -> BrokerId.of(" "));
+ }
+
+ @Test
+ void correctEqualsAndHashCode() {
+ EqualsVerifier.forClass(BrokerId.class).verify();
+ }
+
+}
diff --git a/lib/src/test/java/no/digipost/signature/client/security/JwtAuthConfigTest.java b/lib/src/test/java/no/digipost/signature/client/security/JwtAuthConfigTest.java
new file mode 100644
index 00000000..772c7c25
--- /dev/null
+++ b/lib/src/test/java/no/digipost/signature/client/security/JwtAuthConfigTest.java
@@ -0,0 +1,49 @@
+package no.digipost.signature.client.security;
+
+import no.digipost.signature.client.core.exceptions.ConfigurationException;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static uk.co.probablyfine.matchers.Java8Matchers.where;
+
+class JwtAuthConfigTest {
+
+ private static final BrokerId BROKER = BrokerId.of("555444");
+
+ @Test
+ void retainsTheConfiguredClientIdAndBroker() {
+ JwtAuthConfig config = JwtAuthConfig.forClient("my-client-id", BROKER);
+
+ assertThat(config.clientId, is("my-client-id"));
+ assertThat(config.brokerId, is(BROKER));
+ }
+
+ @Test
+ void requiresAClientId() {
+ assertThrows(NullPointerException.class, () -> JwtAuthConfig.forClient(null, BROKER));
+ }
+
+ @Test
+ void requiresABrokerId() {
+ assertThrows(NullPointerException.class, () -> JwtAuthConfig.forClient("my-client-id", null));
+ }
+
+ @Test
+ void rejectsABlankClientId() {
+ assertThat(assertThrows(ConfigurationException.class, () -> JwtAuthConfig.forClient("", BROKER)),
+ where(Throwable::getMessage, containsString("must not be blank")));
+ assertThrows(ConfigurationException.class, () -> JwtAuthConfig.forClient(" ", BROKER));
+ }
+
+ @Test
+ void describesItselfWithoutRevealingAnythingSensitive() {
+ String description = JwtAuthConfig.forClient("my-client-id", BROKER).toString();
+
+ assertThat(description, containsString("my-client-id"));
+ assertThat(description, containsString("555444"));
+ }
+
+}
diff --git a/lib/src/test/resources/localhost-tls-testserver.p12 b/lib/src/test/resources/localhost-tls-testserver.p12
new file mode 100644
index 00000000..a7cc33da
Binary files /dev/null and b/lib/src/test/resources/localhost-tls-testserver.p12 differ