getHeaders() {
@Override
public String getPath() {
- String pathWithQueryParams = (String) context.getAttribute("request-path");
+ String pathWithQueryParams = (String) context.getAttribute(REQUEST_PATH_ATTRIBUTE);
int indexOfQuestionMark = pathWithQueryParams.indexOf('?');
if (indexOfQuestionMark != -1) {
return pathWithQueryParams.substring(0, indexOfQuestionMark);
diff --git a/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java b/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java
new file mode 100644
index 00000000..c44af0d3
--- /dev/null
+++ b/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import no.digipost.http.client.HttpClientConnectionSettings;
+import no.digipost.http.client.HttpClientSettings;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Configures how the client obtains OAuth 2.0 access tokens over a mutual-TLS channel:
+ * which token endpoint to ask, which client to identify as, and which client certificate
+ * to present in the handshake.
+ *
+ * The resource the tokens are requested for is not configured here. It is derived
+ * from {@link no.digipost.api.client.DigipostClientConfig#digipostApiUri}, so that the
+ * tokens are always issued for the same API the client actually talks to.
+ */
+public final class JwtAuthConfig {
+
+ public final URI tokenEndpointUri;
+ public final String clientId;
+ final KeyStore keyStore;
+ final char[] keyPassword;
+ final HttpClientSettings httpClientSettings;
+ final HttpClientConnectionSettings httpClientConnectionSettings;
+
+ public static Builder newConfig(String clientId) {
+ return new Builder(clientId);
+ }
+
+ public static class Builder {
+ private URI tokenEndpointUri = URI.create("https://midp.digipost.no/oauth2/token");
+ private final String clientId;
+ private KeyStore keyStore;
+ private char[] keyPassword;
+ private HttpClientSettings httpClientSettings = HttpClientSettings.DEFAULT;
+ private HttpClientConnectionSettings httpClientConnectionSettings = HttpClientConnectionSettings.DEFAULT;
+
+ private Builder(String clientId) {
+ this.clientId = requireNonNull(clientId, "clientId cannot be null");
+ }
+
+ public Builder tokenEndpoint(String tokenEndpoint) {
+ this.tokenEndpointUri = URI.create(tokenEndpoint);
+ return this;
+ }
+
+ public Builder pkcs12KeyStore(InputStream pkcs12Stream, String password) {
+ requireNonNull(pkcs12Stream, "pkcs12Stream cannot be null");
+ requireNonNull(password, "password cannot be null");
+ try {
+ KeyStore ks = KeyStore.getInstance("PKCS12");
+ ks.load(pkcs12Stream, password.toCharArray());
+ this.keyStore = ks;
+ this.keyPassword = password.toCharArray();
+ return this;
+ } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) {
+ throw new IllegalArgumentException("Could not load PKCS12 keystore", e);
+ }
+ }
+
+ public Builder keyStore(KeyStore keyStore, String keyPassword) {
+ this.keyStore = requireNonNull(keyStore, "keyStore cannot be null");
+ this.keyPassword = requireNonNull(keyPassword, "keyPassword cannot be null").toCharArray();
+ return this;
+ }
+
+ /**
+ * Customizes the HTTP client used to fetch access tokens from the token endpoint, e.g. its
+ * timeouts. The client is built by this library, as it must present the client certificate
+ * configured here in the TLS handshake, and is separate from the client used to talk to the
+ * Digipost API. Both parameters have sensible defaults, so pass
+ * {@link HttpClientSettings#DEFAULT} or {@link HttpClientConnectionSettings#DEFAULT} for
+ * the one you do not need to change.
+ *
+ *
{@code
+ * .tokenEndpointHttpSettings(
+ * HttpClientSettings.DEFAULT.timeouts(HttpClientDefaults.DEFAULT_TIMEOUTS_MS.connect(2000).connectionRequest(1000)),
+ * HttpClientConnectionSettings.DEFAULT.socketTimeout(5000))
+ * }
+ *
+ * @param httpClientSettings the connect and connection request timeouts, and any proxy, of the token client
+ * @param httpClientConnectionSettings the socket timeout and connection pool of the token client
+ */
+ public Builder tokenEndpointHttpSettings(HttpClientSettings httpClientSettings, HttpClientConnectionSettings httpClientConnectionSettings) {
+ this.httpClientSettings = requireNonNull(httpClientSettings, "httpClientSettings cannot be null");
+ this.httpClientConnectionSettings = requireNonNull(httpClientConnectionSettings, "httpClientConnectionSettings cannot be null");
+ return this;
+ }
+
+ public JwtAuthConfig build() {
+ requireNonNull(keyStore, "A keyStore is required. Call pkcs12KeyStore() or keyStore().");
+ return new JwtAuthConfig(tokenEndpointUri, clientId, keyStore, keyPassword, httpClientSettings, httpClientConnectionSettings);
+ }
+ }
+
+ private JwtAuthConfig(URI tokenEndpointUri, String clientId, KeyStore keyStore, char[] keyPassword,
+ HttpClientSettings httpClientSettings, HttpClientConnectionSettings httpClientConnectionSettings) {
+ this.tokenEndpointUri = tokenEndpointUri;
+ this.clientId = clientId;
+ this.keyStore = keyStore;
+ this.keyPassword = keyPassword;
+ this.httpClientSettings = httpClientSettings;
+ this.httpClientConnectionSettings = httpClientConnectionSettings;
+ }
+}
diff --git a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java
new file mode 100644
index 00000000..a6cacff8
--- /dev/null
+++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import no.digipost.api.client.BrokerId;
+import no.digipost.api.client.errorhandling.DigipostClientException;
+import no.digipost.http.client.HttpClientConnectionManagerFactory;
+import no.digipost.http.client.HttpClientFactory;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+
+import static java.util.Objects.requireNonNull;
+import static no.digipost.api.client.errorhandling.ErrorCode.FAILED_TO_OBTAIN_ACCESS_TOKEN;
+
+public class MutualTlsTokenProvider implements Closeable {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MutualTlsTokenProvider.class);
+
+ private static final Duration REFRESH_MARGIN = Duration.ofSeconds(30);
+ private static final Duration MINIMUM_CACHE_TIME = Duration.ofSeconds(5);
+ private static final Duration FALLBACK_TOKEN_LIFETIME = Duration.ofSeconds(60);
+
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ private final JwtAuthConfig config;
+ private final Clock clock;
+ private final CloseableHttpClient tokenClient;
+ private final SSLContext sslContext;
+
+ private final List oAuthTokenEndpointParams;
+
+ private volatile String cachedToken;
+ private volatile Instant cacheValidUntil = Instant.MIN;
+ private final Object refreshLock = new Object();
+
+ public MutualTlsTokenProvider(JwtAuthConfig config, BrokerId brokerId, URI resourceServerUri, Clock clock) {
+ this(config, brokerId, resourceServerUri, clock, null);
+ }
+
+ MutualTlsTokenProvider(JwtAuthConfig config, BrokerId brokerId, URI resourceServerUri, Clock clock, TrustManager[] trustManagers) {
+ this.config = config;
+ this.clock = clock;
+ this.sslContext = buildSslContext(config, trustManagers);
+ this.tokenClient = buildTokenClient(config, this.sslContext);
+ this.oAuthTokenEndpointParams = createOAuth2TokenEndpointParams(config, brokerId, resourceServerUri);
+ }
+
+ public String getToken() {
+ if (Instant.now(clock).isBefore(cacheValidUntil)) {
+ return cachedToken;
+ }
+ synchronized (refreshLock) {
+ if (Instant.now(clock).isBefore(cacheValidUntil)) {
+ return cachedToken;
+ }
+ return fetchAndCacheToken();
+ }
+ }
+
+ public SSLContext getSslContext() {
+ return sslContext;
+ }
+
+ @Override
+ public void close() {
+ try {
+ tokenClient.close();
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to close the http client used for " + config.tokenEndpointUri, e);
+ }
+ }
+
+ private String fetchAndCacheToken() {
+ HttpPost request = new HttpPost(config.tokenEndpointUri);
+ request.setEntity(new UrlEncodedFormEntity(oAuthTokenEndpointParams, StandardCharsets.UTF_8));
+
+ try {
+ return tokenClient.execute(request, response -> {
+ int statusCode = response.getCode();
+ if (statusCode != 200) {
+ HttpEntity responseEntity = response.getEntity();
+ if (responseEntity != null) {
+ String body = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8);
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri + ": " + body);
+ } else {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri);
+ }
+ }
+
+ String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+ JsonNode tokenResponse = parseTokenResponse(responseBody);
+ String token = extractAccessToken(tokenResponse);
+ Instant expiry = resolveExpiry(token, tokenResponse);
+
+ cachedToken = token;
+ cacheValidUntil = resolveCacheValidUntil(Instant.now(clock), expiry);
+
+ LOG.debug("Fetched new access token from {}, valid until {}, cached until {}", config.tokenEndpointUri, expiry, cacheValidUntil);
+ return token;
+ });
+ } catch (IOException e) {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Failed to fetch access token from " + config.tokenEndpointUri, e);
+ }
+ }
+
+ private static JsonNode parseTokenResponse(String responseBody) {
+ try {
+ return JSON.readTree(responseBody);
+ } catch (IOException e) {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Could not parse token endpoint response as JSON", e);
+ }
+ }
+
+ private static String extractAccessToken(JsonNode tokenResponse) {
+ JsonNode accessToken = tokenResponse.get("access_token");
+ if (accessToken == null || !accessToken.isTextual() || accessToken.asText().isEmpty()) {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint response did not contain an 'access_token' field");
+ }
+ return accessToken.asText();
+ }
+
+ private Instant resolveExpiry(String accessToken, JsonNode tokenResponse) {
+ JsonNode expiresIn = tokenResponse.get("expires_in");
+ if (expiresIn != null && expiresIn.canConvertToLong()) {
+ return Instant.now(clock).plusSeconds(expiresIn.asLong());
+ }
+
+ try {
+ String[] parts = accessToken.split("\\.");
+ if (parts.length >= 2) {
+ JsonNode payload = JSON.readTree(Base64.getUrlDecoder().decode(parts[1]));
+ JsonNode exp = payload.get("exp");
+ if (exp != null && exp.canConvertToLong()) {
+ return Instant.ofEpochSecond(exp.asLong());
+ }
+ }
+ } catch (Exception e) {
+ LOG.warn("Could not determine token expiry; caching for {} only. Reason: {}", FALLBACK_TOKEN_LIFETIME, e.getMessage());
+ }
+
+ return Instant.now(clock).plus(FALLBACK_TOKEN_LIFETIME);
+ }
+
+ static Instant resolveCacheValidUntil(Instant now, Instant expiry) {
+ Instant refreshAt = expiry.minus(REFRESH_MARGIN);
+ Instant minimum = now.plus(MINIMUM_CACHE_TIME);
+ if (refreshAt.isAfter(minimum)) {
+ return refreshAt;
+ }
+ return minimum.isBefore(expiry) ? minimum : expiry;
+ }
+
+ private static SSLContext buildSslContext(JwtAuthConfig config, TrustManager[] trustManagers) {
+ try {
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(config.keyStore, config.keyPassword);
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagerFactory.getKeyManagers(), trustManagers, null);
+ return sslContext;
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not build SSL context from keystore for " + config.tokenEndpointUri, e);
+ }
+ }
+
+ private static CloseableHttpClient buildTokenClient(JwtAuthConfig config, SSLContext sslContext) {
+
+ return HttpClientFactory.create(config.httpClientSettings,
+ HttpClientConnectionManagerFactory.createBuilder(config.httpClientConnectionSettings)
+ .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
+ .setSslContext(sslContext)
+ .build())
+ .build());
+ }
+
+ private static List createOAuth2TokenEndpointParams(JwtAuthConfig config, BrokerId brokerId, URI resourceServerUri){
+ return Arrays.asList(
+ new BasicNameValuePair("grant_type", "client_credentials"),
+ new BasicNameValuePair("client_id", config.clientId),
+ new BasicNameValuePair("scope", "dpost-api:" + brokerId.stringValue()),
+ new BasicNameValuePair("resource", requireNonNull(resourceServerUri, "resourceServerUri cannot be null").toString())
+ );
+ }
+}
diff --git a/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java b/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java
index bcc939b3..f26b9003 100644
--- a/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java
+++ b/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java
@@ -636,7 +636,7 @@ public void actionPerformed(final ActionEvent e) {
.digipostApiUri(URI.create(endpointField.getText()))
.build();
try (InputStream certStream = newInputStream(Paths.get(certField.getText()))) {
- client = new DigipostClient(clientConfig, BrokerId.of(Long.parseLong(senderField.getText())),
+ client = DigipostClient.withCertificateAuthentication(clientConfig, BrokerId.of(Long.parseLong(senderField.getText())),
Signer.usingKeyFromPKCS12KeyStore(certStream, new String(passwordField.getPassword())));
} catch (NumberFormatException e1) {
eventLogger.log("FEIL: Avsenders ID må være et tall > 0");
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/AddTagEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/AddTagEksempel.java
index 161a5cee..1d53c56d 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/AddTagEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/AddTagEksempel.java
@@ -46,7 +46,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
// 3. Vi oppretter et fødselsnummerobjekt
PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787");
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/ArkiverDokumenterEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/ArkiverDokumenterEksempel.java
index 1551ba6b..1af29632 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ArkiverDokumenterEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ArkiverDokumenterEksempel.java
@@ -48,7 +48,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(),
AVSENDERS_KONTOID.asBrokerId(), signer);
// 3. Vi beskriver to dokumenter du ønsker å arkivere i ditt arkiv.
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/AutocompleteEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/AutocompleteEksempel.java
index 66160567..dc5805d5 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/AutocompleteEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/AutocompleteEksempel.java
@@ -50,7 +50,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(),
AVSENDERS_KONTOID.asBrokerId(), signer);
// 3. Vi ber om forslag til autofullføring
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/BatchSendMessagesEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/BatchSendMessagesEksempel.java
index d8ff9fc7..46585564 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/BatchSendMessagesEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/BatchSendMessagesEksempel.java
@@ -67,7 +67,7 @@ public static void main(final String[] args) throws IOException {
try (PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setDefaultConnectionConfig(config)
.build()) {
- client = new DigipostClient(DigipostClientConfig.newConfiguration().digipostApiUri(URI.create("http://localhost:8282")).build(),
+ client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().digipostApiUri(URI.create("http://localhost:8282")).build(),
AVSENDERS_KONTOID.asBrokerId(), signer, HttpClientBuilder.create().setConnectionManager(connectionManager));
}
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/FallbackTilPrintEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/FallbackTilPrintEksempel.java
index 769985ce..1386d2e1 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/FallbackTilPrintEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/FallbackTilPrintEksempel.java
@@ -67,7 +67,7 @@ public static void main(final String[] args) throws IOException {
}
// 3. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(),
AVSENDERS_KONTOID.asBrokerId(), signer);
// 4. Vi oppretter et fødselsnummerobjekt som skal brukes til å
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempel.java
index b28965c3..fac2a269 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempel.java
@@ -56,7 +56,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(),
AVSENDERS_KONTOID.asBrokerId(), signer);
// 3. Vi oppretter et fødselsnummerobjekt
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelDigipostadresse.java b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelDigipostadresse.java
index 39c89b1e..2aa390e4 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelDigipostadresse.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelDigipostadresse.java
@@ -56,7 +56,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(),
AVSENDERS_KONTOID.asBrokerId(), signer);
// 3. Vi oppretter et digipostadresseobjekt
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelNavnogAdresse.java b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelNavnogAdresse.java
index 01122004..71a9bfde 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelNavnogAdresse.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelNavnogAdresse.java
@@ -56,7 +56,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(),
AVSENDERS_KONTOID.asBrokerId(), signer);
// 3. Vi oppretter et nameandaddress-objekt
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesArchiveExamples.java b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesArchiveExamples.java
index 3aaf7ba1..f321f4a7 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesArchiveExamples.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesArchiveExamples.java
@@ -47,7 +47,7 @@ public class GithubPagesArchiveExamples {
public void set_up_client() throws FileNotFoundException {
SenderId senderId = SenderId.of(10987);
- DigipostClient client = new DigipostClient(
+ DigipostClient client = DigipostClient.withCertificateAuthentication(
DigipostClientConfig.newConfiguration().build(),
senderId.asBrokerId(),
Signer.usingKeyFromPKCS12KeyStore(new FileInputStream("certificate.p12"), "TheSecretPassword"));
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesReceiveExamples.java b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesReceiveExamples.java
index c2b7dbfe..e6786267 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesReceiveExamples.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesReceiveExamples.java
@@ -35,7 +35,7 @@ public class GithubPagesReceiveExamples {
public void set_up_client() throws FileNotFoundException {
SenderId senderId = SenderId.of(10987);
- DigipostClient client = new DigipostClient(
+ DigipostClient client = DigipostClient.withCertificateAuthentication(
DigipostClientConfig.newConfiguration().build(),
senderId.asBrokerId(),
Signer.usingKeyFromPKCS12KeyStore(new FileInputStream("certificate.p12"), "TheSecretPassword"));
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesSendExamples.java b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesSendExamples.java
index 9934bda3..78aea58f 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesSendExamples.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesSendExamples.java
@@ -74,7 +74,7 @@ public void set_up_client() throws IOException {
signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, "TheSecretPassword");
}
- DigipostClient client = new DigipostClient(
+ DigipostClient client = DigipostClient.withCertificateAuthentication(
DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), signer);
}
@@ -246,7 +246,7 @@ public void send_letter_through_norsk_helsenett() throws IOException {
signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, CERTIFICATE_PASSWORD);
}
- DigipostClient client = new DigipostClient(config, SENDER_ID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.withCertificateAuthentication(config, SENDER_ID.asBrokerId(), signer);
PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787");
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/PeppolEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/PeppolEksempel.java
index 1309b27c..6505db02 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/PeppolEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/PeppolEksempel.java
@@ -55,7 +55,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(),
AVSENDERS_KONTOID.asBrokerId(), signer);
// 3. Vi oppretter et fødselsnummerobjekt
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/SokEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/SokEksempel.java
index 428ad9e3..98d9896d 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/SokEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/SokEksempel.java
@@ -50,7 +50,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
// 3. Vi søker etter personer med matchende navn eller adresse
List recipients = client.search("Ole Nilsen Stavanger").getRecipients();
diff --git a/src/test/java/no/digipost/api/client/eksempelkode/VedleggEksempel.java b/src/test/java/no/digipost/api/client/eksempelkode/VedleggEksempel.java
index dbaf3e5d..da8ac4ca 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/VedleggEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/VedleggEksempel.java
@@ -53,7 +53,7 @@ public static void main(final String[] args) throws IOException {
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
+ DigipostClient client = DigipostClient.withCertificateAuthentication(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
// 3. Vi oppretter et fødselsnummerobjekt
PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787");
diff --git a/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java
new file mode 100644
index 00000000..a5aecc82
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal;
+
+import no.digipost.api.client.BrokerId;
+import no.digipost.api.client.DigipostClientConfig;
+import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
+import no.digipost.http.client.HttpClientFactory;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+
+import static no.digipost.api.client.DigipostClientConfig.newConfiguration;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class ApiServiceImplTest {
+
+ private static final BrokerId BROKER_ID = BrokerId.of(1234);
+ private static final String P12_RESOURCE = "/no/digipost/api/client/security/jwt/client-cert.p12";
+ private static final String P12_PASSWORD = "qwer1234";
+
+ private static final Signer DUMMY_SIGNER = dataToSign -> new byte[0];
+
+ @Test
+ void bygger_jwt_autentiserende_klient() {
+ DigipostClientConfig config = newConfiguration().build();
+
+ assertDoesNotThrow(() ->
+ ApiServiceImpl.withJwtMtlsAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, jwtAuthConfig()));
+ }
+
+ @Test
+ void bygger_sertifikat_autentiserende_klient() {
+ DigipostClientConfig config = newConfiguration().build();
+
+ assertDoesNotThrow(() ->
+ ApiServiceImpl.withCertificateAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, DUMMY_SIGNER));
+ }
+
+ @Test
+ void krever_signer_for_sertifikatbasert_autentisering() {
+ DigipostClientConfig config = newConfiguration().build();
+
+ assertThrows(NullPointerException.class, () ->
+ ApiServiceImpl.withCertificateAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null));
+ }
+
+ @Test
+ void krever_jwtAuthConfig_for_jwt_basert_autentisering() {
+ DigipostClientConfig config = newConfiguration().build();
+
+ assertThrows(NullPointerException.class, () ->
+ ApiServiceImpl.withJwtMtlsAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null));
+ }
+
+ private static JwtAuthConfig jwtAuthConfig() {
+ return JwtAuthConfig
+ .newConfig("test-client")
+ .pkcs12KeyStore(p12Stream(), P12_PASSWORD)
+ .build();
+ }
+
+ private static InputStream p12Stream() {
+ InputStream stream = ApiServiceImplTest.class.getResourceAsStream(P12_RESOURCE);
+ if (stream == null) {
+ throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE);
+ }
+ return stream;
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.java b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.java
new file mode 100644
index 00000000..8a70897f
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal.http.request.interceptor;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class RequestBearerTokenInterceptorTest {
+
+ @Test
+ public void setter_authorization_headeren_med_bearer_prefiks() {
+ HttpGet request = new HttpGet("https://api.digipost.no/");
+
+ new RequestBearerTokenInterceptor(() -> "the-token").process(request, null, new BasicHttpContext());
+
+ assertThat(request.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer the-token"));
+ }
+
+ @Test
+ public void henter_tokenet_paa_nytt_for_hvert_request() {
+ List tokens = new ArrayList<>(List.of("first-token", "second-token"));
+ RequestBearerTokenInterceptor interceptor = new RequestBearerTokenInterceptor(() -> tokens.remove(0));
+
+ HttpGet first = new HttpGet("https://api.digipost.no/");
+ HttpGet second = new HttpGet("https://api.digipost.no/");
+ interceptor.process(first, null, new BasicHttpContext());
+ interceptor.process(second, null, new BasicHttpContext());
+
+ assertThat(first.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer first-token"));
+ assertThat(second.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer second-token"));
+ }
+
+ @Test
+ public void erstatter_en_eksisterende_authorization_header() {
+ HttpGet request = new HttpGet("https://api.digipost.no/");
+ request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer stale-token");
+
+ new RequestBearerTokenInterceptor(() -> "fresh-token").process(request, null, new BasicHttpContext());
+
+ assertThat(request.getHeaders(HttpHeaders.AUTHORIZATION).length, is(1));
+ assertThat(request.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer fresh-token"));
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptorTest.java b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptorTest.java
new file mode 100644
index 00000000..18038b93
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptorTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal.http.request.interceptor;
+
+import no.digipost.api.client.internal.http.Headers;
+import no.digipost.api.client.security.Digester;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+public class RequestContentHashInterceptorTest {
+
+ private final RequestContentHashInterceptor interceptor =
+ new RequestContentHashInterceptor(Digester.sha256, Headers.X_Content_SHA256);
+
+ @Test
+ public void setter_sha256_header_beregnet_over_request_body() throws IOException, NoSuchAlgorithmException {
+ byte[] body = "digipost".getBytes(StandardCharsets.UTF_8);
+ HttpPost request = new HttpPost("https://api.digipost.no/");
+ request.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_OCTET_STREAM));
+
+ interceptor.process(request, null, new BasicHttpContext());
+
+ String expected = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(body));
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256), notNullValue());
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256).getValue(), is(expected));
+ }
+
+ @Test
+ public void setter_hash_over_tom_body() throws IOException, NoSuchAlgorithmException {
+ HttpPost request = new HttpPost("https://api.digipost.no/");
+ request.setEntity(new ByteArrayEntity(new byte[0], ContentType.APPLICATION_OCTET_STREAM));
+
+ interceptor.process(request, null, new BasicHttpContext());
+
+ String expected = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(new byte[0]));
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256).getValue(), is(expected));
+ }
+
+ @Test
+ public void setter_ingen_header_naar_request_ikke_har_body() throws IOException {
+ HttpGet request = new HttpGet("https://api.digipost.no/");
+
+ interceptor.process(request, null, new BasicHttpContext());
+
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256), nullValue());
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptorTest.java b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptorTest.java
new file mode 100644
index 00000000..1dc47b26
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptorTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal.http.request.interceptor;
+
+import no.digipost.api.client.internal.http.Headers;
+import no.digipost.api.client.security.Digester;
+import no.digipost.api.client.security.Signer;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+public class RequestSignatureInterceptorTest {
+
+ private final AtomicReference signedContent = new AtomicReference<>();
+ private final Signer capturingSigner = dataToSign -> {
+ signedContent.set(dataToSign);
+ return new byte[0];
+ };
+
+ private final RequestContentHashInterceptor contentHashInterceptor =
+ new RequestContentHashInterceptor(Digester.sha256, Headers.X_Content_SHA256);
+ private final RequestSignatureInterceptor signatureInterceptor = new RequestSignatureInterceptor(capturingSigner);
+
+ @Test
+ public void signerer_over_innholdshashen_naar_interceptorene_kjoerer_i_registrert_rekkefoelge() throws IOException, NoSuchAlgorithmException {
+ byte[] body = "digipost".getBytes(StandardCharsets.UTF_8);
+ HttpPost request = new HttpPost("https://api.digipost.no/api/documents");
+ request.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_OCTET_STREAM));
+
+ contentHashInterceptor.process(request, null, new BasicHttpContext());
+ signatureInterceptor.process(request, null, new BasicHttpContext());
+
+ String expectedHash = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(body));
+ assertThat(signedContent.get(), containsString(Headers.X_Content_SHA256.toLowerCase() + ": " + expectedHash));
+ assertThat(request.getFirstHeader(Headers.X_Digipost_Signature), notNullValue());
+ }
+
+ @Test
+ public void signerer_request_uten_innhold() {
+ HttpGet request = new HttpGet("https://api.digipost.no/api/documents");
+
+ assertDoesNotThrow(() -> signatureInterceptor.process(request, null, new BasicHttpContext()));
+
+ assertThat(request.getFirstHeader(Headers.X_Digipost_Signature), notNullValue());
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderCacheTest.java b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderCacheTest.java
new file mode 100644
index 00000000..bba37772
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderCacheTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static java.time.temporal.ChronoUnit.SECONDS;
+import static no.digipost.api.client.security.jwt.MutualTlsTokenProvider.resolveCacheValidUntil;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.is;
+
+public class MutualTlsTokenProviderCacheTest {
+
+ private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
+
+ @Test
+ public void refresher_tokenet_kort_foer_det_utloeper() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(300, SECONDS)), is(NOW.plus(270, SECONDS)));
+ }
+
+ @Test
+ public void cacher_kortlevde_tokens_i_stedet_for_aa_hente_nytt_per_request() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(10, SECONDS)), is(NOW.plus(5, SECONDS)));
+ }
+
+ @Test
+ public void cacher_aldri_lenger_enn_tokenet_er_gyldig() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(3, SECONDS)), is(NOW.plus(3, SECONDS)));
+ }
+
+ @Test
+ public void cacher_alltid_i_et_positivt_tidsrom() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(31, SECONDS)), greaterThan(NOW));
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(30, SECONDS)), greaterThan(NOW));
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(1, SECONDS)), greaterThan(NOW));
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java
new file mode 100644
index 00000000..e8f03afd
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import no.digipost.api.client.BrokerId;
+import no.digipost.api.client.errorhandling.DigipostClientException;
+import no.digipost.http.client.HttpClientConnectionSettings;
+import no.digipost.http.client.HttpClientSettings;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.InputStream;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import static java.time.temporal.ChronoUnit.SECONDS;
+import static no.digipost.api.client.errorhandling.ErrorCode.FAILED_TO_OBTAIN_ACCESS_TOKEN;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class MutualTlsTokenProviderTest {
+
+ private static final String P12_RESOURCE = "client-cert.p12";
+ private static final String P12_PASSWORD = "qwer1234";
+ private static final String CLIENT_ID = "test-client";
+ private static final BrokerId BROKER_ID = BrokerId.of(1234);
+ private static final URI RESOURCE_SERVER_URI = URI.create("https://api.digipost.no");
+ private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
+
+ private TokenEndpointStub tokenEndpoint;
+ private SettableClock clock;
+ private final List tokenProviders = new ArrayList<>();
+
+ @BeforeEach
+ void startTokenEndpoint() throws Exception {
+ tokenEndpoint = new TokenEndpointStub();
+ clock = new SettableClock(NOW);
+ }
+
+ @AfterEach
+ void closeTokenProvidersAndStopTokenEndpoint() {
+ tokenProviders.forEach(MutualTlsTokenProvider::close);
+ tokenProviders.clear();
+ if (tokenEndpoint != null) {
+ tokenEndpoint.close();
+ }
+ }
+
+ @Test
+ void henter_token_og_presenterer_klientsertifikatet_i_handshaken() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+
+ assertThat(tokenProvider().getToken(), is("the-token"));
+
+ Certificate[] presented = tokenEndpoint.certificatesPresentedByClient();
+ assertThat("mIdP mottok ingen klientsertifikat – klienten presenterte ingenting i handshaken", presented, notNullValue());
+ assertThat(presented[0], instanceOf(X509Certificate.class));
+ assertThat(((X509Certificate) presented[0]).getSubjectX500Principal().getName(), containsString("sertifikat-TEST"));
+ }
+
+ @Test
+ void sender_client_credentials_parametrene_til_token_endepunktet() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+
+ tokenProvider().getToken();
+
+ assertThat(parameter("grant_type"), is("client_credentials"));
+ assertThat(parameter("client_id"), is(CLIENT_ID));
+ assertThat(parameter("scope"), is("dpost-api:1234"));
+ assertThat(parameter("resource"), is(RESOURCE_SERVER_URI.toString()));
+ }
+
+ @Test
+ void cacher_tokenet_mellom_kall() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ clock.advance(Duration.ofSeconds(100));
+
+ assertThat(tokenProvider.getToken(), is("the-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(1));
+ }
+
+ @Test
+ void henter_nytt_token_naar_det_forrige_naermer_seg_utloep() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"first-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ clock.advance(Duration.ofSeconds(280));
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"second-token\",\"expires_in\":300}");
+
+ assertThat(tokenProvider.getToken(), is("second-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void bruker_exp_fra_tokenet_naar_expires_in_mangler() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"" + jwtExpiringAt(NOW.plus(300, SECONDS)) + "\"}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ clock.advance(Duration.ofSeconds(100));
+ tokenProvider.getToken();
+ assertThat("tokenet er gyldig i 300s, så det skal fortsatt være cachet", tokenEndpoint.receivedRequestCount(), is(1));
+
+ clock.advance(Duration.ofSeconds(180));
+ tokenProvider.getToken();
+ assertThat(tokenEndpoint.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void feil_fra_token_endepunktet_gir_DigipostClientException() throws Exception {
+ tokenEndpoint.respondWith(503, "{\"error\":\"temporarily_unavailable\"}");
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider().getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat(thrown.getMessage(), containsString("503"));
+ }
+
+ /**
+ * Statuskoder som ikke kan ha en responsbody gir ingen {@link HttpEntity} å lese
+ * feilmeldingen fra, og {@link EntityUtils#toString(HttpEntity, java.nio.charset.Charset)}
+ * kaster {@link NullPointerException} hvis den blir kalt med en null-entity.
+ */
+ @ParameterizedTest
+ @ValueSource(ints = { 204, 304 })
+ void feil_uten_responsbody_gir_DigipostClientException_og_ikke_NullPointerException(int statusUtenBody) throws Exception {
+ tokenEndpoint.respondWithoutBody(statusUtenBody);
+
+ Exception thrown = assertThrows(Exception.class, () -> tokenProvider().getToken());
+
+ assertThat("EntityUtils.toString(..) ble kalt med responsens null-entity", thrown, not(instanceOf(NullPointerException.class)));
+ assertThat(thrown, instanceOf(DigipostClientException.class));
+
+ DigipostClientException clientException = (DigipostClientException) thrown;
+ assertThat(clientException.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat(clientException.getMessage(), containsString(String.valueOf(statusUtenBody)));
+ assertThat(clientException.getMessage(), containsString(tokenEndpoint.tokenEndpointUri().toString()));
+ assertThat("feilmeldingen skal ikke antyde at det fulgte med en body", clientException.getMessage(), not(containsString("null")));
+ }
+
+ @Test
+ void svar_som_ikke_er_json_gir_DigipostClientException() throws Exception {
+ tokenEndpoint.respondWith(200, "not json");
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider().getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ }
+
+ @Test
+ void svar_uten_access_token_gir_DigipostClientException() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"expires_in\":300}");
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider().getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat(thrown.getMessage(), containsString("access_token"));
+ }
+
+ @Test
+ void bruker_timeoutene_som_er_konfigurert_for_token_klienten() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+ tokenEndpoint.delayResponsesBy(Duration.ofSeconds(2));
+
+ JwtAuthConfig config = configBuilder()
+ .tokenEndpointHttpSettings(HttpClientSettings.DEFAULT, HttpClientConnectionSettings.DEFAULT.socketTimeout(200))
+ .build();
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider(config).getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat("token-klienten ventet lenger enn den konfigurerte socket-timeouten", thrown.getCause(), instanceOf(SocketTimeoutException.class));
+ }
+
+ private MutualTlsTokenProvider tokenProvider() throws Exception {
+ return tokenProvider(configBuilder().build());
+ }
+
+ private MutualTlsTokenProvider tokenProvider(JwtAuthConfig config) throws Exception {
+ MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(config, BROKER_ID, RESOURCE_SERVER_URI, clock, tokenEndpoint.trustManagers());
+ tokenProviders.add(tokenProvider);
+ return tokenProvider;
+ }
+
+ private JwtAuthConfig.Builder configBuilder() {
+ return JwtAuthConfig
+ .newConfig(CLIENT_ID)
+ .tokenEndpoint(tokenEndpoint.tokenEndpointUri().toString())
+ .pkcs12KeyStore(p12Stream(), P12_PASSWORD);
+ }
+
+ private String parameter(String name) {
+ List form = tokenEndpoint.lastReceivedForm();
+ return form.stream()
+ .filter(parameter -> parameter.getName().equals(name))
+ .map(NameValuePair::getValue)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Parameteren '" + name + "' ble ikke sendt. Mottok: " + form));
+ }
+
+ private static String jwtExpiringAt(Instant expiry) {
+ Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
+ String header = encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8));
+ String payload = encoder.encodeToString(("{\"exp\":" + expiry.getEpochSecond() + "}").getBytes(StandardCharsets.UTF_8));
+ return header + "." + payload + ".signature";
+ }
+
+ private static InputStream p12Stream() {
+ InputStream stream = MutualTlsTokenProviderTest.class.getResourceAsStream(P12_RESOURCE);
+ if (stream == null) {
+ throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE);
+ }
+ return stream;
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/SettableClock.java b/src/test/java/no/digipost/api/client/security/jwt/SettableClock.java
new file mode 100644
index 00000000..3c15aae1
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/SettableClock.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+
+final class SettableClock extends Clock {
+
+ private volatile Instant now;
+
+ SettableClock(Instant now) {
+ this.now = now;
+ }
+
+ void advance(Duration duration) {
+ now = now.plus(duration);
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java b/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java
new file mode 100644
index 00000000..89226ae5
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+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 org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.net.WWWFormCodec;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+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.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+import java.io.Closeable;
+import java.math.BigInteger;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * A local HTTPS server standing in for the OAuth 2.0 token endpoint, presenting a
+ * generated certificate valid for 127.0.0.1 and requesting a client certificate.
+ */
+final class TokenEndpointStub implements Closeable {
+
+ private final HttpsServer server;
+ private final X509Certificate serverCertificate;
+ private final URI tokenEndpointUri;
+
+ private final List> receivedForms = new ArrayList<>();
+ private final AtomicReference certificatesPresentedByClient = new AtomicReference<>();
+
+ private volatile int responseStatus = 200;
+ private volatile String responseBody = "{}";
+ private volatile Duration responseDelay = Duration.ZERO;
+
+ TokenEndpointStub() throws Exception {
+ KeyPair keyPair = generateKeyPair();
+ this.serverCertificate = selfSignedCertificateFor(keyPair);
+
+ server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ SSLContext serverContext = serverSslContext(keyPair, serverCertificate);
+ server.setHttpsConfigurator(new HttpsConfigurator(serverContext) {
+ @Override
+ public void configure(HttpsParameters params) {
+ SSLParameters sslParameters = serverContext.getDefaultSSLParameters();
+ // TLS 1.3 defers client authentication past the handshake, which would leave
+ // getPeerCertificates() empty in the handler below.
+ sslParameters.setProtocols(new String[]{ "TLSv1.2" });
+ sslParameters.setWantClientAuth(true);
+ params.setSSLParameters(sslParameters);
+ }
+ });
+ server.createContext("/token", exchange -> {
+ try {
+ certificatesPresentedByClient.set(((HttpsExchange) exchange).getSSLSession().getPeerCertificates());
+ } catch (SSLPeerUnverifiedException e) {
+ certificatesPresentedByClient.set(null);
+ }
+ String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ synchronized (receivedForms) {
+ receivedForms.add(WWWFormCodec.parse(form, StandardCharsets.UTF_8));
+ }
+
+ sleep(responseDelay);
+
+ String body = responseBody;
+ if (body == null) {
+ exchange.sendResponseHeaders(responseStatus, -1);
+ } else {
+ byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(responseStatus, bodyBytes.length);
+ exchange.getResponseBody().write(bodyBytes);
+ }
+ exchange.close();
+ });
+ server.start();
+
+ this.tokenEndpointUri = URI.create("https://127.0.0.1:" + server.getAddress().getPort() + "/token");
+ }
+
+ URI tokenEndpointUri() {
+ return tokenEndpointUri;
+ }
+
+ void respondWith(int status, String body) {
+ this.responseStatus = status;
+ this.responseBody = body;
+ }
+
+ /** Wait the given duration before responding, e.g. to provoke a socket timeout in the client. */
+ void delayResponsesBy(Duration delay) {
+ this.responseDelay = delay;
+ }
+
+ /** Respond with the given status and no response body at all, i.e. not even an empty one. */
+ void respondWithoutBody(int status) {
+ this.responseStatus = status;
+ this.responseBody = null;
+ }
+
+ int receivedRequestCount() {
+ synchronized (receivedForms) {
+ return receivedForms.size();
+ }
+ }
+
+ List lastReceivedForm() {
+ synchronized (receivedForms) {
+ return receivedForms.get(receivedForms.size() - 1);
+ }
+ }
+
+ Certificate[] certificatesPresentedByClient() {
+ return certificatesPresentedByClient.get();
+ }
+
+ /** Trust managers accepting this stub's certificate, in place of the JVM default trust store. */
+ TrustManager[] trustManagers() throws Exception {
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ trustStore.setCertificateEntry("token-endpoint", serverCertificate);
+
+ TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init(trustStore);
+ return trustManagerFactory.getTrustManagers();
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ private static void sleep(Duration duration) {
+ if (duration.isZero() || duration.isNegative()) {
+ return;
+ }
+ try {
+ Thread.sleep(duration.toMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static SSLContext serverSslContext(KeyPair keyPair, X509Certificate certificate) throws Exception {
+ char[] password = "token-endpoint-stub".toCharArray();
+
+ KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ keyStore.load(null, null);
+ keyStore.setKeyEntry("token-endpoint", keyPair.getPrivate(), password, new Certificate[]{ certificate });
+
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(keyStore, password);
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagerFactory.getKeyManagers(), anyClientCertificate(), null);
+ return sslContext;
+ }
+
+ private static KeyPair generateKeyPair() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ }
+
+ private static X509Certificate selfSignedCertificateFor(KeyPair keyPair) throws Exception {
+ X500Name subject = new X500Name("CN=token-endpoint-stub");
+ Date notBefore = new Date(System.currentTimeMillis() - 86400_000);
+ Date notAfter = new Date(System.currentTimeMillis() + 86400_000);
+
+ JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
+ subject, BigInteger.ONE, notBefore, notAfter, subject, keyPair.getPublic());
+ builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
+ builder.addExtension(Extension.subjectAlternativeName, false,
+ new GeneralNames(new GeneralName(GeneralName.iPAddress, "127.0.0.1")));
+
+ return new JcaX509CertificateConverter().getCertificate(
+ builder.build(new JcaContentSignerBuilder("SHA256WithRSA").build(keyPair.getPrivate())));
+ }
+
+ private static TrustManager[] anyClientCertificate() {
+ return new TrustManager[]{ 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/src/test/resources/no/digipost/api/client/security/jwt/client-cert.p12 b/src/test/resources/no/digipost/api/client/security/jwt/client-cert.p12
new file mode 100644
index 00000000..84eb6363
Binary files /dev/null and b/src/test/resources/no/digipost/api/client/security/jwt/client-cert.p12 differ