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();
+ }
+ }
+
+ /**
+ * Discards the cached access token, so that the next {@link #getToken()} fetches a new one
+ * from the token endpoint. This is meant for the case where the resource server rejects a
+ * token the client still considers valid, e.g. because it was revoked before it expired.
+ *
+ * Only the given token is discarded. Another thread may already have replaced it with a
+ * newly fetched one, and that replacement must survive the late rejection of its predecessor.
+ *
+ * @param rejectedToken the access token that was rejected
+ */
+ public void invalidate(String rejectedToken) {
+ synchronized (refreshLock) {
+ if (Objects.equals(cachedToken, rejectedToken)) {
+ cacheValidUntil = Instant.MIN;
+ LOG.debug("Discarded the cached access token from {} after it was rejected", config.tokenEndpointUri);
+ }
+ }
+ }
+
+ 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)
+ .setTlsSocketStrategy(ClientTlsStrategyBuilder.create()
+ .setSslContext(sslContext)
+ .buildClassic())
+ .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..beda0dea
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.api.client.security.jwt.MutualTlsTokenProvider;
+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));
+ }
+
+ @Test
+ void lukker_ogsaa_token_provideren_sin_http_klient() {
+ DigipostClientConfig config = newConfiguration().build();
+ MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig(), BROKER_ID, config.digipostApiUri, config.clock);
+ ApiServiceImpl apiService = ApiServiceImpl.withMutualTlsTokenProvider(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, tokenProvider);
+
+ apiService.close();
+
+ assertThrows(IllegalStateException.class, tokenProvider::getToken,
+ "token provideren har fortsatt en åpen http-klient, og lekker connection poolen sin");
+ }
+
+ @Test
+ void lukking_av_sertifikatbasert_klient_gaar_greit() {
+ ApiServiceImpl apiService = ApiServiceImpl.withCertificateAuthentication(
+ newConfiguration().build(), HttpClientFactory.createDefaultBuilder(), BROKER_ID, DUMMY_SIGNER);
+
+ assertDoesNotThrow(apiService::close);
+ }
+
+ private static JwtAuthConfig jwtAuthConfig() {
+ return JwtAuthConfig
+ .newConfig("test-client")
+ // ingen skal svare her: testene under skal aldri komme så langt som til å gjøre et kall
+ .tokenEndpoint("https://localhost:1/oauth2/token")
+ .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/DigipostApiStub.java b/src/test/java/no/digipost/api/client/internal/DigipostApiStub.java
new file mode 100644
index 00000000..6c04d8e9
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/DigipostApiStub.java
@@ -0,0 +1,135 @@
+/*
+ * 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 com.sun.net.httpserver.HttpServer;
+import org.apache.hc.core5.http.HttpHeaders;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.security.MessageDigest;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Collections.unmodifiableList;
+import static no.digipost.api.client.internal.http.Headers.X_Content_SHA256;
+import static no.digipost.api.client.util.JAXBContextUtils.jaxbContext;
+import static no.digipost.api.client.util.JAXBContextUtils.marshal;
+
+/**
+ * A local HTTP server standing in for the Digipost API, answering each request with the next of
+ * the responses it is stubbed with, and recording the {@code Authorization} header of every
+ * request it received.
+ *
+ * It speaks plain HTTP: the JWT client only presents its client certificate towards the token
+ * endpoint, and its TLS configuration is of no consequence to the requests tested here.
+ */
+final class DigipostApiStub implements Closeable {
+
+ private final HttpServer server;
+ private final URI uri;
+
+ private final List receivedAuthorizationHeaders = new ArrayList<>();
+ private volatile List responses = List.of();
+
+ DigipostApiStub() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ StubbedResponse response;
+ synchronized (receivedAuthorizationHeaders) {
+ receivedAuthorizationHeaders.add(exchange.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION));
+ List stubbed = responses;
+ response = stubbed.get(Math.min(receivedAuthorizationHeaders.size() - 1, stubbed.size() - 1));
+ }
+ exchange.getRequestBody().readAllBytes();
+
+ if (response.digipostHeaders) {
+ // The Digipost API dates and hashes its responses, and the client rejects responses lacking it.
+ exchange.getResponseHeaders().set(HttpHeaders.DATE, DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC)));
+ exchange.getResponseHeaders().set(X_Content_SHA256, sha256Base64(response.body));
+ }
+ exchange.sendResponseHeaders(response.status, response.body.length == 0 ? -1 : response.body.length);
+ exchange.getResponseBody().write(response.body);
+ exchange.close();
+ });
+ server.start();
+
+ this.uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
+ }
+
+ URI uri() {
+ return uri;
+ }
+
+ /** Answer the requests with these responses in order, repeating the last one if more requests arrive. */
+ void respondWith(StubbedResponse... responses) {
+ this.responses = List.of(responses);
+ }
+
+ List receivedAuthorizationHeaders() {
+ synchronized (receivedAuthorizationHeaders) {
+ return unmodifiableList(new ArrayList<>(receivedAuthorizationHeaders));
+ }
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ /** A response from the Digipost application itself, carrying the headers it dates and hashes its responses with. */
+ static StubbedResponse marshalled(int status, Object representation) {
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ marshal(jaxbContext, representation, body);
+ return new StubbedResponse(status, body.toByteArray(), true);
+ }
+
+ /**
+ * A response carrying none of the headers the Digipost application would have added. This is
+ * what a request rejected before it reaches the application, e.g. by a gateway refusing its
+ * access token, looks like.
+ */
+ static StubbedResponse withoutDigipostHeaders(int status, String body) {
+ return new StubbedResponse(status, body.getBytes(UTF_8), false);
+ }
+
+ static final class StubbedResponse {
+ final int status;
+ final byte[] body;
+ final boolean digipostHeaders;
+
+ private StubbedResponse(int status, byte[] body, boolean digipostHeaders) {
+ this.status = status;
+ this.body = body;
+ this.digipostHeaders = digipostHeaders;
+ }
+ }
+
+ private static String sha256Base64(byte[] content) {
+ try {
+ return Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(content));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/UnauthorizedRetryTest.java b/src/test/java/no/digipost/api/client/internal/UnauthorizedRetryTest.java
new file mode 100644
index 00000000..de8e1979
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/UnauthorizedRetryTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.errorhandling.DigipostClientException;
+import no.digipost.api.client.errorhandling.ErrorCode;
+import no.digipost.api.client.representations.DigipostUri;
+import no.digipost.api.client.representations.EntryPoint;
+import no.digipost.api.client.representations.ErrorMessage;
+import no.digipost.api.client.representations.ErrorType;
+import no.digipost.api.client.representations.Link;
+import no.digipost.api.client.representations.Relation;
+import no.digipost.api.client.security.jwt.MutualTlsTokenProvider;
+import no.digipost.http.client.HttpClientFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static no.digipost.api.client.DigipostClientConfig.newConfiguration;
+import static org.apache.hc.core5.http.HttpStatus.SC_OK;
+import static org.apache.hc.core5.http.HttpStatus.SC_UNAUTHORIZED;
+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;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies that the JWT-authenticating client reacts to a rejected access token the way it is
+ * supposed to: by discarding the token, fetching a new one and sending the request once more.
+ *
+ * This goes through the fully wired client, and not just the individual pieces, because what it
+ * needs to establish is that they are placed correctly relative to each other in the execution
+ * chain, i.e. that the retry re-runs the request interceptors, and that the response
+ * verifications do not fail the 401 before the retry gets to see it.
+ */
+public class UnauthorizedRetryTest {
+
+ private static final BrokerId BROKER_ID = BrokerId.of(1234);
+
+ private DigipostApiStub digipostApi;
+ private MutualTlsTokenProvider tokenProvider;
+ private ApiServiceImpl apiService;
+
+ @BeforeEach
+ void startApiAndBuildClient() throws Exception {
+ digipostApi = new DigipostApiStub();
+ tokenProvider = mock(MutualTlsTokenProvider.class);
+ when(tokenProvider.getSslContext()).thenReturn(javax.net.ssl.SSLContext.getDefault());
+
+ DigipostClientConfig config = newConfiguration().digipostApiUri(digipostApi.uri()).build();
+ apiService = ApiServiceImpl.withMutualTlsTokenProvider(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, tokenProvider);
+ }
+
+ @AfterEach
+ void closeClientAndStopApi() {
+ if (apiService != null) {
+ apiService.close();
+ }
+ if (digipostApi != null) {
+ digipostApi.close();
+ }
+ }
+
+ @Test
+ void henter_nytt_token_og_sender_requesten_paa_nytt_naar_det_forrige_blir_avvist() {
+ when(tokenProvider.getToken()).thenReturn("rejected-token", "fresh-token");
+ digipostApi.respondWith(unauthorizedByGateway(), entryPoint());
+
+ assertThat(apiService.getEntryPoint().getCertificate(), is("the-certificate"));
+
+ verify(tokenProvider).invalidate("rejected-token");
+ assertThat(digipostApi.receivedAuthorizationHeaders(), contains("Bearer rejected-token", "Bearer fresh-token"));
+ }
+
+ @Test
+ void gir_opp_naar_ogsaa_det_nye_tokenet_blir_avvist() {
+ when(tokenProvider.getToken()).thenReturn("rejected-token", "also-rejected-token");
+ digipostApi.respondWith(unauthorized(), unauthorized());
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> apiService.getEntryPoint());
+
+ assertThat("den faktiske feilen fra apiet skal nå fram, ikke en signaturfeil på det usignerte 401-svaret",
+ thrown.getErrorCode(), is(ErrorCode.UNKNOWN_USER_ID));
+ assertThat("requesten skal sendes én gang til, ikke i det uendelige",
+ digipostApi.receivedAuthorizationHeaders(), contains("Bearer rejected-token", "Bearer also-rejected-token"));
+ }
+
+ @Test
+ void roerer_ikke_tokenet_naar_apiet_svarer_som_normalt() {
+ when(tokenProvider.getToken()).thenReturn("the-token");
+ digipostApi.respondWith(entryPoint());
+
+ apiService.getEntryPoint();
+
+ verify(tokenProvider, never()).invalidate(org.mockito.ArgumentMatchers.anyString());
+ assertThat(digipostApi.receivedAuthorizationHeaders(), contains("Bearer the-token"));
+ }
+
+ /**
+ * A 401 from in front of the Digipost application, i.e. one that is neither dated, hashed
+ * nor signed the way the client expects a response from the application itself to be.
+ */
+ private static DigipostApiStub.StubbedResponse unauthorizedByGateway() {
+ return DigipostApiStub.withoutDigipostHeaders(SC_UNAUTHORIZED, "{\"error\":\"invalid_token\"}");
+ }
+
+ private static DigipostApiStub.StubbedResponse unauthorized() {
+ return DigipostApiStub.marshalled(SC_UNAUTHORIZED, new ErrorMessage(ErrorType.CLIENT_TECHNICAL, "UNKNOWN_USER_ID", "Unknown access token"));
+ }
+
+ private static DigipostApiStub.StubbedResponse entryPoint() {
+ return DigipostApiStub.marshalled(SC_OK, new EntryPoint("the-certificate",
+ new Link(Relation.SEARCH, new DigipostUri("/recipients/search"))));
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/RefreshAccessTokenOnUnauthorizedExecTest.java b/src/test/java/no/digipost/api/client/internal/http/RefreshAccessTokenOnUnauthorizedExecTest.java
new file mode 100644
index 00000000..6fca72ee
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/RefreshAccessTokenOnUnauthorizedExecTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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;
+
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.classic.ExecChain;
+import org.apache.hc.client5.http.classic.ExecRuntime;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+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.HttpHost;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.entity.InputStreamEntity;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor.ATTEMPTED_ACCESS_TOKEN;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+
+public class RefreshAccessTokenOnUnauthorizedExecTest {
+
+ private final List invalidatedTokens = new ArrayList<>();
+ private final RefreshAccessTokenOnUnauthorizedExec exec = new RefreshAccessTokenOnUnauthorizedExec(invalidatedTokens::add);
+
+ @Test
+ void sender_requesten_paa_nytt_naar_tokenet_blir_avvist() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(get(), scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_OK));
+ assertThat(chain.receivedRequestCount(), is(2));
+ assertThat(invalidatedTokens, contains("rejected-token"));
+ }
+
+ @Test
+ void sender_requesten_paa_nytt_bare_en_gang() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_UNAUTHORIZED);
+
+ ClassicHttpResponse response = exec.execute(get(), scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_UNAUTHORIZED));
+ assertThat(chain.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void roerer_ikke_svar_som_ikke_er_401() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_FORBIDDEN, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(get(), scopeWithAttemptedToken("the-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_FORBIDDEN));
+ assertThat(chain.receivedRequestCount(), is(1));
+ assertThat(invalidatedTokens, is(empty()));
+ }
+
+ @Test
+ void gjoer_ingenting_naar_requesten_ikke_ble_sendt_med_et_token() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(get(), scope(HttpClientContext.create()), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_UNAUTHORIZED));
+ assertThat(chain.receivedRequestCount(), is(1));
+ assertThat(invalidatedTokens, is(empty()));
+ }
+
+ @Test
+ void sender_ikke_innhold_som_ikke_kan_sendes_paa_nytt() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+ ClassicHttpRequest post = post(new InputStreamEntity(new ByteArrayInputStream("content".getBytes(UTF_8)), 7, null));
+
+ ClassicHttpResponse response = exec.execute(post, scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_UNAUTHORIZED));
+ assertThat(chain.receivedRequestCount(), is(1));
+ assertThat("tokenet skal ikke kastes når vi likevel ikke kan prøve på nytt", invalidatedTokens, is(empty()));
+ }
+
+ @Test
+ void sender_innhold_som_kan_sendes_paa_nytt() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(post(new StringEntity("content", UTF_8)), scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_OK));
+ assertThat(chain.receivedRequestCount(), is(2));
+ }
+
+ private static ClassicHttpRequest get() {
+ return new HttpGet("https://api.digipost.no/");
+ }
+
+ private static ClassicHttpRequest post(HttpEntity entity) {
+ HttpPost post = new HttpPost("https://api.digipost.no/");
+ post.setEntity(entity);
+ return post;
+ }
+
+ private static ExecChain.Scope scopeWithAttemptedToken(String token) {
+ HttpClientContext context = HttpClientContext.create();
+ context.setAttribute(ATTEMPTED_ACCESS_TOKEN, token);
+ return scope(context);
+ }
+
+ private static ExecChain.Scope scope(HttpClientContext context) {
+ return new ExecChain.Scope("test-exchange", new HttpRoute(new HttpHost("https", "api.digipost.no", 443)), get(), mock(ExecRuntime.class), context);
+ }
+
+
+ /**
+ * Answers each request with the next of the given statuses, keeping the last one once they
+ * are exhausted, and records the requests it was asked to send.
+ */
+ private static final class RespondingChain implements ExecChain {
+
+ private final List statuses;
+ private final List receivedRequests = new ArrayList<>();
+
+ RespondingChain(Integer... statuses) {
+ this.statuses = List.of(statuses);
+ }
+
+ @Override
+ public ClassicHttpResponse proceed(ClassicHttpRequest request, Scope scope) {
+ int status = statuses.get(Math.min(receivedRequests.size(), statuses.size() - 1));
+ receivedRequests.add(request);
+ return new BasicClassicHttpResponse(status);
+ }
+
+ int receivedRequestCount() {
+ return receivedRequests.size();
+ }
+ }
+}
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..59df185f
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.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 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 no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor.ATTEMPTED_ACCESS_TOKEN;
+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 legger_tokenet_i_konteksten_saa_det_kan_invalideres_om_det_blir_avvist() {
+ BasicHttpContext context = new BasicHttpContext();
+
+ new RequestBearerTokenInterceptor(() -> "the-token").process(new HttpGet("https://api.digipost.no/"), null, context);
+
+ assertThat((String) context.getAttribute(ATTEMPTED_ACCESS_TOKEN), is("the-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/internal/http/response/interceptor/VerifyUnlessUnauthorizedTest.java b/src/test/java/no/digipost/api/client/internal/http/response/interceptor/VerifyUnlessUnauthorizedTest.java
new file mode 100644
index 00000000..a97a6ef2
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/response/interceptor/VerifyUnlessUnauthorizedTest.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.internal.http.response.interceptor;
+
+import org.apache.hc.core5.http.HttpResponseInterceptor;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static no.digipost.api.client.internal.http.response.interceptor.VerifyUnlessUnauthorized.unlessUnauthorized;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.is;
+
+public class VerifyUnlessUnauthorizedTest {
+
+ private final List verifiedResponses = new ArrayList<>();
+ private final HttpResponseInterceptor verification =
+ unlessUnauthorized((response, entityDetails, context) -> verifiedResponses.add(response.getCode()));
+
+ @Test
+ void verifiserer_vanlige_svar() throws Exception {
+ verification.process(new BasicClassicHttpResponse(HttpStatus.SC_OK), null, new BasicHttpContext());
+ verification.process(new BasicClassicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR), null, new BasicHttpContext());
+
+ assertThat(verifiedResponses, contains(HttpStatus.SC_OK, HttpStatus.SC_INTERNAL_SERVER_ERROR));
+ }
+
+ @Test
+ void verifiserer_ikke_svar_om_at_tokenet_ble_avvist() throws Exception {
+ verification.process(new BasicClassicHttpResponse(HttpStatus.SC_UNAUTHORIZED), null, new BasicHttpContext());
+
+ assertThat(verifiedResponses, is(empty()));
+ }
+}
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..437f113c
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java
@@ -0,0 +1,276 @@
+/*
+ * 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 henter_nytt_token_naar_det_forrige_er_invalidert() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"first-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"second-token\",\"expires_in\":300}");
+ tokenProvider.invalidate("first-token");
+
+ assertThat(tokenProvider.getToken(), is("second-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void beholder_tokenet_naar_et_annet_blir_invalidert() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ tokenProvider.invalidate("a-token-already-replaced-by-the-cached-one");
+
+ assertThat(tokenProvider.getToken(), is("the-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(1));
+ }
+
+ @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