Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lib/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@
<version>5.4.3</version>
</dependency>

<!--
Only the streaming parser is needed, to read the few fields of the token endpoint's
response. jackson-databind is deliberately not used, as it additionally pulls in
byte-buddy and byte-buddy-agent.
-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.22.2</version>
</dependency>

<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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}.
* <p>
* <strong>Note:</strong> 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;
Expand Down Expand Up @@ -139,6 +155,7 @@ public static class Builder {
private MaySpecifySender defaultSender = NO_SPECIFIED_SENDER;
private List<DocumentBundleProcessor> documentBundleProcessors = new ArrayList<>();
private Clock clock = Clock.systemDefaultZone();
private JwtAuthConfig jwtAuthConfig;


private Builder(KeyStoreConfig keyStoreConfig) {
Expand Down Expand Up @@ -203,6 +220,31 @@ public Builder defaultSender(Sender sender) {
return this;
}

/**
* Authenticate with Posten signering using an OAuth 2.0 <em>client credentials</em> 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.
*
* <p>The organization certificate passed to {@link ClientConfiguration#builder(KeyStoreConfig)}
* is still required, and is used to authenticate against the token endpoint.
*
* <p>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.
Expand Down Expand Up @@ -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.
* <p>
* Uses the {@link Clock#systemDefaultZone() system clock with default time zone}
* if not specified.
Expand All @@ -367,13 +410,72 @@ public Builder clock(Clock clock) {
public ClientConfiguration build() {
Configurer<HttpClientBuilder> commonConfig = userAgentConfigurer.andThen(proxyConfigurer);

return new ClientConfiguration(defaultSender, serviceEnvironment.signatureServiceRootUrl(), keyStoreConfig,
commonConfig.andThen(defaultHttpClientConfigurer), commonConfig.andThen(httpClientForDocumentDownloadsConfigurer),
documentBundleProcessors, clock);
Configurer<HttpClientBuilder> 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 <em>not</em> defined by this library.
*/
private String accessTokenScope() {
return ACCESS_TOKEN_SCOPE_PREFIX + jwtAuthConfig.brokerId.value();
}

}



Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,34 +20,55 @@
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");


private final String description;
private final URI serviceRootUrl;
private final List<String> certificatePaths;
private final URI tokenEndpointUrl;


public ServiceEnvironment(String description, URI serviceRootUrl, Collection<String> certificatePaths) {
this(description, serviceRootUrl, certificatePaths, null);
}

private ServiceEnvironment(String description, URI serviceRootUrl, Collection<String> 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) {
Expand All @@ -64,14 +86,22 @@ public ServiceEnvironment withCertificates(String ... certificatePaths) {
}

public ServiceEnvironment withCertificates(Collection<String> certificatePaths) {
return new ServiceEnvironment(this.description, this.serviceRootUrl, certificatePaths);
return new ServiceEnvironment(this.description, this.serviceRootUrl, certificatePaths, this.tokenEndpointUrl);
}

@Override
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<URI> tokenEndpoint() {
return Optional.ofNullable(tokenEndpointUrl);
}

@Override
public List<String> certificatePaths() {
return certificatePaths;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading