diff --git a/NOTICE b/NOTICE index b424b79d..d44a7834 100644 --- a/NOTICE +++ b/NOTICE @@ -13,6 +13,7 @@ This software includes third party software subject to the following licenses: Apache HttpComponents Core HTTP/2 under Apache License, Version 2.0 Digipost Certificate Validator under The Apache Software License, Version 2.0 Digipost JAXB Resolver - com.sun.xml.bind under The Apache Software License, Version 2.0 + Jackson-core under The Apache Software License, Version 2.0 JavaBeans Activation Framework API jar under CDDL/GPLv2+CE JAXB Tools :: JAXB Basics :: Runtime under BSD-Style License jaxb-api under CDDL 1.1 or GPL2 w/ CPE diff --git a/lib/NOTICE b/lib/NOTICE index 7a6b480e..df26ebd8 100644 --- a/lib/NOTICE +++ b/lib/NOTICE @@ -13,6 +13,7 @@ This software includes third party software subject to the following licenses: Apache HttpComponents Core HTTP/2 under Apache License, Version 2.0 Digipost Certificate Validator under The Apache Software License, Version 2.0 Digipost JAXB Resolver - com.sun.xml.bind under The Apache Software License, Version 2.0 + Jackson-core under The Apache Software License, Version 2.0 JavaBeans Activation Framework API jar under CDDL/GPLv2+CE JAXB Tools :: JAXB Basics :: Runtime under BSD-Style License jaxb-api under CDDL 1.1 or GPL2 w/ CPE diff --git a/lib/pom.xml b/lib/pom.xml index c66fe102..5b356cd2 100644 --- a/lib/pom.xml +++ b/lib/pom.xml @@ -82,6 +82,17 @@ 5.4.3 + + + com.fasterxml.jackson.core + jackson-core + 2.22.2 + + commons-io commons-io diff --git a/lib/src/main/java/no/digipost/signature/client/ClientConfiguration.java b/lib/src/main/java/no/digipost/signature/client/ClientConfiguration.java index 99a28a03..0c3b41be 100644 --- a/lib/src/main/java/no/digipost/signature/client/ClientConfiguration.java +++ b/lib/src/main/java/no/digipost/signature/client/ClientConfiguration.java @@ -7,13 +7,18 @@ import no.digipost.signature.client.core.Sender; import no.digipost.signature.client.core.SignatureJob; import no.digipost.signature.client.core.WithSignatureServiceRootUrl; +import no.digipost.signature.client.core.exceptions.ConfigurationException; import no.digipost.signature.client.core.internal.MaySpecifySender; +import no.digipost.signature.client.core.internal.configuration.ApacheHttpClientBearerTokenConfigurer; import no.digipost.signature.client.core.internal.configuration.ApacheHttpClientBuilderConfigurer; import no.digipost.signature.client.core.internal.configuration.ApacheHttpClientProxyConfigurer; import no.digipost.signature.client.core.internal.configuration.ApacheHttpClientSslConfigurer; import no.digipost.signature.client.core.internal.configuration.ApacheHttpClientUserAgentConfigurer; import no.digipost.signature.client.core.internal.configuration.Configurer; +import no.digipost.signature.client.core.internal.http.AccessTokenRequest; +import no.digipost.signature.client.core.internal.http.MutualTlsTokenProvider; import no.digipost.signature.client.security.CertificateChainValidation; +import no.digipost.signature.client.security.JwtAuthConfig; import no.digipost.signature.client.security.KeyStoreConfig; import no.digipost.signature.client.security.OrganizationNumberValidation; import org.apache.hc.client5.http.classic.HttpClient; @@ -35,6 +40,7 @@ import java.util.logging.Level; import java.util.logging.Logger; +import static java.util.Objects.requireNonNull; import static no.digipost.signature.client.core.internal.MaySpecifySender.NO_SPECIFIED_SENDER; public final class ClientConfiguration implements ASiCEConfiguration, WithSignatureServiceRootUrl, ArchiveClient.Configuration { @@ -52,6 +58,16 @@ public final class ClientConfiguration implements ASiCEConfiguration, WithSignat public static final String MANDATORY_USER_AGENT = "posten-signature-api-client-java/" + ClientMetadata.VERSION + " (" + JAVA_DESCRIPTION + ")"; + /** + * Prefix of the OAuth 2.0 {@code scope} which access tokens are requested for when using + * {@link Builder#jwtAuthentication(JwtAuthConfig) JWT/mTLS authentication}, completed with the + * {@link no.digipost.signature.client.security.BrokerId broker id} of the {@link JwtAuthConfig}. + *

+ * Note: this value is a contract with the identity provider issuing the access + * tokens, which matches it as an exact string. It is not defined by this library, and should not + * be changed without coordinating with the identity provider. + */ + static final String ACCESS_TOKEN_SCOPE_PREFIX = "signering-api:"; private final MaySpecifySender defaultSender; private final URI serviceRoot; @@ -139,6 +155,7 @@ public static class Builder { private MaySpecifySender defaultSender = NO_SPECIFIED_SENDER; private List 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