From c416c83a30a61e6325bc332d57208cb5a37bf23a Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Thu, 9 Jul 2026 13:12:55 +0200 Subject: [PATCH 01/28] Add JWT/mTLS authentication The client can now authenticate in two ways: the existing certificate-based signing (Signer), or OAuth 2.0 client credentials with a certificate-bound JWT (RFC 8705) over mutual TLS. - JwtAuthConfig: configures the token endpoint, resource server, clientId and client certificate (PKCS12 keystore or KeyStore). - MutualTlsTokenProvider: fetches and caches an access token from mIdP over mTLS, with a refresh margin and expiry derived from expires_in or the exp claim. - RequestBearerTokenInterceptor: sets the Authorization:Bearer header. - ApiServiceImpl selects the authentication mode based on whether a Signer or a JwtAuthConfig is set, and throws when neither is configured. - New DigipostClient constructors without a Signer. --- pom.xml | 4 + .../digipost/api/client/DigipostClient.java | 9 + .../api/client/DigipostClientConfig.java | 15 +- .../api/client/internal/ApiServiceImpl.java | 56 +++++- .../RequestBearerTokenInterceptor.java | 36 ++++ .../client/security/jwt/JwtAuthConfig.java | 86 ++++++++ .../security/jwt/MutualTlsTokenProvider.java | 183 ++++++++++++++++++ .../jwt/MutualTlsTokenProviderTest.java | 165 ++++++++++++++++ .../api/client/security/jwt/client-cert.p12 | Bin 0 -> 8436 bytes 9 files changed, 542 insertions(+), 12 deletions(-) create mode 100644 src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java create mode 100644 src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java create mode 100644 src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java create mode 100644 src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java create mode 100644 src/test/resources/no/digipost/api/client/security/jwt/client-cert.p12 diff --git a/pom.xml b/pom.xml index 7c7e9a89..3c461c27 100644 --- a/pom.xml +++ b/pom.xml @@ -159,6 +159,10 @@ digipost-data-types 1.3.0 + + com.fasterxml.jackson.core + jackson-databind + org.glassfish.jaxb jaxb-runtime diff --git a/src/main/java/no/digipost/api/client/DigipostClient.java b/src/main/java/no/digipost/api/client/DigipostClient.java index 27975e39..66ba3b13 100644 --- a/src/main/java/no/digipost/api/client/DigipostClient.java +++ b/src/main/java/no/digipost/api/client/DigipostClient.java @@ -53,6 +53,7 @@ import no.digipost.api.client.representations.shareddocuments.SharedDocumentContent; import no.digipost.api.client.security.CryptoUtil; import no.digipost.api.client.security.Signer; +import no.digipost.api.client.security.jwt.JwtAuthConfig; import no.digipost.api.client.shareddocuments.SharedDocumentsApi; import no.digipost.api.client.tag.TagApi; import no.digipost.api.client.util.JAXBContextUtils; @@ -105,6 +106,14 @@ public DigipostClient(DigipostClientConfig config, BrokerId brokerId, Signer sig this(config, new ApiServiceImpl(config, clientBuilder, brokerId, signer)); } + public DigipostClient(DigipostClientConfig config, BrokerId brokerId) { + this(config, brokerId, HttpClientFactory.createDefaultBuilder()); + } + + public DigipostClient(DigipostClientConfig config, BrokerId brokerId, HttpClientBuilder clientBuilder) { + this(config, new ApiServiceImpl(config, clientBuilder, brokerId, null)); + } + private DigipostClient(DigipostClientConfig config, ApiServiceImpl apiService) { this(config, apiService, apiService, apiService, apiService, apiService, apiService, apiService); } diff --git a/src/main/java/no/digipost/api/client/DigipostClientConfig.java b/src/main/java/no/digipost/api/client/DigipostClientConfig.java index 165adf7d..5734b3f4 100644 --- a/src/main/java/no/digipost/api/client/DigipostClientConfig.java +++ b/src/main/java/no/digipost/api/client/DigipostClientConfig.java @@ -15,9 +15,12 @@ */ package no.digipost.api.client; +import no.digipost.api.client.security.jwt.JwtAuthConfig; + import java.net.URI; import java.time.Clock; import java.time.Duration; +import java.util.Optional; import static java.util.Objects.requireNonNull; @@ -33,6 +36,7 @@ public static class Builder { private EventLogger eventLogger = EventLogger.NOOP_LOGGER; private Clock clock = Clock.systemDefaultZone(); private boolean failOnHtmlDiff = false; + private JwtAuthConfig jwtAuthConfig = null; private Builder() { } @@ -66,8 +70,13 @@ public Builder clock(Clock clock) { return this; } + public Builder jwtAuthConfig(JwtAuthConfig jwtAuthConfig) { + this.jwtAuthConfig = jwtAuthConfig; + return this; + } + public DigipostClientConfig build() { - return new DigipostClientConfig(digipostApiUri, printKeyCacheTimeToLive, eventLogger, clock, failOnHtmlDiff); + return new DigipostClientConfig(digipostApiUri, printKeyCacheTimeToLive, eventLogger, clock, failOnHtmlDiff, jwtAuthConfig); } } @@ -80,13 +89,15 @@ public DigipostClientConfig build() { public final EventLogger eventLogger; public final Clock clock; public final boolean failOnHtmlDiff; + public final JwtAuthConfig jwtAuthConfig; - private DigipostClientConfig(URI digipostApiUri, Duration printKeyCacheTimeToLive, EventLogger eventLogger, Clock clock, boolean failOnHtmlDiff) { + private DigipostClientConfig(URI digipostApiUri, Duration printKeyCacheTimeToLive, EventLogger eventLogger, Clock clock, boolean failOnHtmlDiff, JwtAuthConfig jwtAuthConfig) { this.digipostApiUri = requireNonNull(digipostApiUri, "digipostApiUri cat not be null"); this.printKeyCacheTimeToLive = requireNonNull(printKeyCacheTimeToLive, "printKeyCacheTimeToLive can not be null"); this.eventLogger = requireNonNull(eventLogger, "eventLogger can not be null"); this.clock = clock; this.failOnHtmlDiff = failOnHtmlDiff; + this.jwtAuthConfig = jwtAuthConfig; } } diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index fa160b47..b2078456 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -28,6 +28,7 @@ import no.digipost.api.client.inbox.InboxApi; import no.digipost.api.client.internal.http.Headers; import no.digipost.api.client.internal.http.MultipartNoLengthCheckHttpEntity; +import no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestContentHashFilter; import no.digipost.api.client.internal.http.request.interceptor.RequestDateInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestSignatureInterceptor; @@ -65,6 +66,8 @@ import no.digipost.api.client.representations.shareddocuments.SharedDocumentContent; import no.digipost.api.client.security.Digester; 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.api.client.shareddocuments.SharedDocumentsApi; import no.digipost.api.client.tag.TagApi; import no.digipost.api.client.util.JAXBContextUtils; @@ -75,8 +78,10 @@ import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.classic.methods.HttpPut; +import no.digipost.http.client.HttpClientConnectionManagerFactory; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; @@ -95,12 +100,14 @@ import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; +import java.time.Clock; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Supplier; import static jakarta.xml.bind.JAXB.unmarshal; import static java.util.Optional.ofNullable; @@ -137,17 +144,46 @@ public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientB this.brokerId = brokerId; this.eventLogger = config.eventLogger.withDebugLogTo(LOG); this.digipostUrl = config.digipostApiUri; - this.cached = new Cached(() -> fetchEntryPoint(Optional.empty())); - this.httpClient = httpClientBuilder - .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, config.clock)) - .addRequestInterceptorLast(new RequestUserAgentInterceptor()) - .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, config.eventLogger, new RequestContentHashFilter(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256))) - .addResponseInterceptorLast(new ResponseDateInterceptor(config.clock)) - .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) - .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) - .build(); - this.eventLogger.log("Initialiserte apache-klient mot " + config.digipostApiUri); + + if (signer != null) { + this.httpClient = createCertificateAuthenticatingHttpClient(httpClientBuilder, eventLogger, signer, config.clock); + this.eventLogger.log("Initialiserte apache-klient (sertifikatmodus) mot " + config.digipostApiUri); + } else if (config.jwtAuthConfig != null) { + this.httpClient = createJwtAuthenticatingHttpClient(httpClientBuilder, eventLogger, config.jwtAuthConfig, brokerId, this::getEntryPoint, config.clock); + this.eventLogger.log("Initialiserte apache-klient (JWT/mTLS-modus) mot " + config.digipostApiUri); + } else { + throw new IllegalArgumentException("Klienten må konfigureres med en Signer for sertifikatbasert autentisering, eller JwtAuthConfig for OAuth 2.0 mTLS-basert autentisering"); + } + } + + private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, Signer signer, Clock clock) { + return httpClientBuilder + .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) + .addRequestInterceptorLast(new RequestUserAgentInterceptor()) + .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, eventLogger, new RequestContentHashFilter(eventLogger, Digester.sha256, Headers.X_Content_SHA256))) + .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) + .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) + .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) + .build(); + } + + private static CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, JwtAuthConfig jwtAuthConfig, BrokerId brokerId, Supplier entryPointSupplier, Clock clock) { + MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig, brokerId, clock); + + return httpClientBuilder + .setConnectionManager(HttpClientConnectionManagerFactory.createDefaultBuilder() + .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() + .setSslContext(tokenProvider.getSslContext()) + .build()) + .build()) + .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) + .addRequestInterceptorLast(new RequestUserAgentInterceptor()) + .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider)) + .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) + .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) + .addResponseInterceptorLast(new ResponseSignatureInterceptor(entryPointSupplier)) + .build(); } //Kan sende inn null. Man får da det samme som getEntryPoint() diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java new file mode 100644 index 00000000..51a7ebaa --- /dev/null +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java @@ -0,0 +1,36 @@ +/* + * 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.security.jwt.MutualTlsTokenProvider; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.protocol.HttpContext; + +public class RequestBearerTokenInterceptor implements HttpRequestInterceptor { + + private final MutualTlsTokenProvider tokenProvider; + + public RequestBearerTokenInterceptor(MutualTlsTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) { + request.setHeader("Authorization", "Bearer " + tokenProvider.getToken()); + } +} 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..d7c502f1 --- /dev/null +++ b/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java @@ -0,0 +1,86 @@ +/* + * 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.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; + +public final class JwtAuthConfig { + + public final URI tokenEndpointUri; + public final URI resourceServerUri; + public final String clientId; + final KeyStore keyStore; + final char[] keyPassword; + + public static Builder newConfig(URI tokenEndpointUri, URI resourceServerUri, String clientId) { + return new Builder(tokenEndpointUri, resourceServerUri, clientId); + } + + public static class Builder { + private final URI tokenEndpointUri; + private final URI resourceServerUri; + private final String clientId; + private KeyStore keyStore; + private char[] keyPassword; + + private Builder(URI tokenEndpointUri, URI resourceServerUri, String clientId) { + this.tokenEndpointUri = requireNonNull(tokenEndpointUri, "tokenEndpointUri cannot be null"); + this.resourceServerUri = requireNonNull(resourceServerUri, "resourceServerUri cannot be null"); + this.clientId = requireNonNull(clientId, "clientId cannot be null"); + } + + 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; + } + + public JwtAuthConfig build() { + requireNonNull(keyStore, "A keyStore is required. Call pkcs12KeyStore() or keyStore()."); + return new JwtAuthConfig(tokenEndpointUri, resourceServerUri, clientId, keyStore, keyPassword); + } + } + + private JwtAuthConfig(URI tokenEndpointUri, URI resourceServerUri, String clientId, KeyStore keyStore, char[] keyPassword) { + this.tokenEndpointUri = tokenEndpointUri; + this.resourceServerUri = resourceServerUri; + this.clientId = clientId; + this.keyStore = keyStore; + this.keyPassword = keyPassword; + } +} 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..3c4d8ff7 --- /dev/null +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -0,0 +1,183 @@ +/* + * 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.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.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 java.io.IOException; +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; + +public class MutualTlsTokenProvider { + + private static final Logger LOG = LoggerFactory.getLogger(MutualTlsTokenProvider.class); + + private static final Duration REFRESH_MARGIN = Duration.ofSeconds(30); + 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, Clock clock) { + this.config = config; + this.clock = clock; + this.sslContext = buildSslContext(config); + this.tokenClient = buildTokenClient(this.sslContext); + this.oAuthTokenEndpointParams = createOAuth2TokenEndpointParams(config, brokerId); + } + + 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; + } + + 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) { + String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + throw new IllegalStateException("Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri + ": " + body); + } + + 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 = expiry.minus(REFRESH_MARGIN); + + LOG.debug("Fetched new access token from {}, valid until {}", config.tokenEndpointUri, expiry); + return token; + }); + } catch (IOException e) { + throw new IllegalStateException("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 IllegalStateException("Could not parse token endpoint response as JSON"); + } + } + + private static String extractAccessToken(JsonNode tokenResponse) { + JsonNode accessToken = tokenResponse.get("access_token"); + if (accessToken == null || !accessToken.isTextual() || accessToken.asText().isEmpty()) { + throw new IllegalStateException("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); + } + + private static SSLContext buildSslContext(JwtAuthConfig config) { + try { + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(config.keyStore, config.keyPassword); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + return sslContext; + } catch (Exception e) { + throw new IllegalStateException("Could not build SSL context from keystore for " + config.tokenEndpointUri, e); + } + } + + private static CloseableHttpClient buildTokenClient(SSLContext sslContext) { + + return HttpClientFactory.create(HttpClientConnectionManagerFactory.createDefaultBuilder() + .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslContext) + .build()) + .build()); + } + + private static List createOAuth2TokenEndpointParams(JwtAuthConfig config, BrokerId brokerId){ + return Arrays.asList( + new BasicNameValuePair("grant_type", "client_credentials"), + new BasicNameValuePair("client_id", config.clientId), + new BasicNameValuePair("scope", brokerId.stringValue() + ":dpost-api"), + new BasicNameValuePair("resource", config.resourceServerUri.toString()) + ); + } +} 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..a5a97b7f --- /dev/null +++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java @@ -0,0 +1,165 @@ +/* + * 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.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.ssl.TLS; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.concurrent.atomic.AtomicReference; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.notNullValue; + +public class MutualTlsTokenProviderTest { + + private static final String P12_RESOURCE = "client-cert.p12"; + private static final String P12_PASSWORD = "qwer1234"; + + private HttpsServer server; + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void presenterer_klientsertifikat_i_mtls_handshake() throws Exception { + JwtAuthConfig config = JwtAuthConfig + .newConfig(URI.create("https://localhost/token"), URI.create("api.localhost"), "test-client") + .pkcs12KeyStore(p12Stream(), P12_PASSWORD) + .build(); + + AtomicReference presentedByClient = new AtomicReference<>(); + URI tokenEndpoint = startTokenServer(config, presentedByClient); + + try (CloseableHttpClient client = clientPresentingConfiguredCertificate(config)) { + client.execute(new HttpGet(tokenEndpoint), response -> { + EntityUtils.consume(response.getEntity()); + return null; + }); + } + + Certificate[] presented = presentedByClient.get(); + assertThat("mIdP mottok ingen klientsertifikat – klienten presenterte ingenting i handshaken", presented, notNullValue()); + assertThat(presented[0], instanceOf(X509Certificate.class)); + } + + private CloseableHttpClient clientPresentingConfiguredCertificate(JwtAuthConfig config) throws Exception { + SSLContext clientContext = SSLContext.getInstance("TLS"); + clientContext.init(keyManagers(config), new TrustManager[]{ TRUST_ALL }, null); + + PoolingHttpClientConnectionManagerBuilder connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() + .setSslContext(clientContext) + .setTlsVersions(TLS.V_1_2) + .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) + .build()); + + return HttpClients.custom() + .setConnectionManager(connectionManager.build()) + .build(); + } + + private URI startTokenServer(JwtAuthConfig config, AtomicReference presentedByClient) throws Exception { + SSLContext serverContext = SSLContext.getInstance("TLS"); + serverContext.init( + keyManagers(config), // server presents the .p12 cert + new TrustManager[]{ TRUST_ALL }, + null + ); + + server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(serverContext) { + @Override + public void configure(HttpsParameters params) { + SSLParameters sslParameters = serverContext.getDefaultSSLParameters(); + sslParameters.setProtocols(new String[]{ "TLSv1.2" }); + sslParameters.setWantClientAuth(true); + params.setSSLParameters(sslParameters); + } + }); + server.createContext("/token", exchange -> { + SSLSession sslSession = ((com.sun.net.httpserver.HttpsExchange) exchange).getSSLSession(); + try { + presentedByClient.set(sslSession.getPeerCertificates()); + } catch (SSLPeerUnverifiedException e) { + presentedByClient.set(null); + } + byte[] body = "{\"access_token\":\"t\",\"expires_in\":300}".getBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + return URI.create("https://127.0.0.1:" + server.getAddress().getPort() + "/token"); + } + + private static KeyManager[] keyManagers(JwtAuthConfig config) throws Exception { + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(config.keyStore, config.keyPassword); + return keyManagerFactory.getKeyManagers(); + } + + private static final X509TrustManager TRUST_ALL = 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]; + } + }; + + private InputStream p12Stream() { + InputStream stream = getClass().getResourceAsStream(P12_RESOURCE); + if (stream == null) { + throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE + " – legg den vedlagte .p12-filen under src/test/resources/no/digipost/api/client/security/jwt/"); + } + return stream; + } +} 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 0000000000000000000000000000000000000000..84eb6363af44772b65ea3b9d6aee26255491e21a GIT binary patch literal 8436 zcma)iWl$XeuO(bAuEpJ5FYfNH7cK5yC`F396?caM7k76rP~6?!-3#pZW_RA3{k1!p zOeW_f`I|GDfP%=c0njj@Ao5H&M8+_=uqRY#c0^UCv=l}p61^}8IgaU`~zb66^VWB`IaEK;ha?qAAKxjrF#$PCe60l*+cHI#)yLO`-aLm^sd^v&;q<~+U(c`!o$b7)+l>9TRpTRGt)qePFy)SF@g zw3hT|5BJ%auUPY-JEknDC$CE|)q~rhgXpji*JGt6wjPYx=wy~G>7mI0D-eDwtkmGv zxV+a3u?S-o2#Lei0C~o7!Zz+e zi})L_#r4G!-ps0~*XX-$e7z1CIa>4j!!og}C{S8-O~a#Bm{};~v@)LUS%0E=oz2RG z8^R_&y+lEpXoJ~Rgx101HF|zTc`9#_V%ecbO9iK92-ttv3M@5Z0v>!R9ngQ(VUH&s z-JwCQ!zD%Y>^zrA0)U@}(z-0&D*&thix54QyrTBXrZg09N(*1xJ#u4n%&`x(`Ok@= zR#l_Vev>Ar_(McXW2t^Jx_wOmt+sqiV$^G%!z+xX(@T?qDD19^|7p1sPp>^$%@=|` zi29tS@rQW7Fsj%4cVAkvHn6aVhcyZ*@SK=qV2*9qHpwCz=;*X->3yL5_|~<3bJw-L zIXU=jRNH^~=3{_91$WLgEv#uB%>ln5I8T|D{h9s8qXETXE=6Fo7yAjse;*Eanq{yc z`ZQ4E>$58IrZun$AJMhSuI=bl8eZ}p@m!^whM^4lj36>Z|5ZA{HSC|tQi>sgeC}H+ zUa9!jE0f*;EWC{V1um`x{P4NwlLl47-u{~5C+ZlsCIC*DqFnW|7-j$3~Nogexv(cudvYf-u6v?nHR@aZcLaxZQD7Nhlo zS02O_nVIm3E{P6~vLyULg~leO)=Uj&8&U-Hmakp91EFtS)#LK%mZWqID@LfHcroY7 zI@i+;so7ra5i)6_>?uM*2y3h8aDFa0(t+B0$w!$Q81t*JM` zRv)MMXle?pt9E~BtiT1#DllHJjh^Gut8I* z*MTwNA?2qu@?pYMip9u!(a6%sj66&kG~-~zu~wPfr)b1BBucNMz;f$c4|R+-d1^Na zV!mfHTOQ`s*vUu3^BPX&>(^#v+vUfdI-nL214=}H_U5^nd!$4 zXVNg;>)|p)wbv&urN9+xURM|DCN>hdoK!beYq9~Lx8n7JM|IuinXuy8dK4e6BqfHd zOq+#sY%ZqqKAzD3nP=%cj^_|{a{uUtWU_mL%#p@N3NF7&?mD4Wz-n|(0lW6jM!Q8x zCns;+uG98gd7w`$E6O{%?czd0lcyy7xls!*e!MJFdDaRcPMGX_;P{KKVC8j<`UD_l z%Kob5KUJ9U>p6~hC^ds7FKo5deNcwklkAnI1-D{D6OHHRk|C=S9DwnOaeIqY*W!s9jc0T$)7py{F8mRh@ZY)N z96pXh{-~?A$6QO&{FZCkvRK3CO2#ChFUnw~jl@5W42A8J6AVyd@p%1S!WQMX`Xh#M zIC@Ez6+KhSd}Zd`&OTHvjJ`LbfgZzcmfc0@q1SK)pED!29N{Ze6X94nfq!kl7x^s0 zZ93w4p}>EWI}s(g{i{;7e#npKeCDgj%efy!>H6!SIE8O(y+z$a#u`KuDj3IIcQACC zG+jPTR*u9W=_4xBXEy2c6;D{3%g_i($)-jTf=PxMAOIpAzqW1(5a7}(l}%Zz#2Wi1286& zLqN2pulkb;{*<7si)(3l>7WgKql0~i;l7HN2?yCSkJH|s&2nAEm7}wr6Myl zJaXVxB$5`uqc8M)c{rj+3J5x#Xai{MMP&)Q`xNg6$1q0Qc&_o9_Wav*y46JG<$&xu zsHfJ0acI)S|9#}xSR=D|5DGpzf7@*~Y!?ZSCukXtE49GBcdV4H3BXV+oYKK)%l9al z&(E>&wRd`?g}ivJkh!&6raERe$(Fb1$RbP&4^<0-gM)bIui|H@#$hr*QI$$%0)a3# z3pKv*c*K=FG6WR3zib-bS@l|xOeT>5K6S^`y3Y%&G=IOqunXyN2jF^}-W>2CmMDwZ ztD&>q@N_LNF)>7~w}(Zfn8J$yj9&K`eKZ4^gs`N(F-RZNj4kXj&XqYsnV7ut9=%g4 z-&1zfpqHhsy^>HBi{ByKawHfLDDFP|k{_Z>$X~D8ld8*2WdW?aLqBp}vT84ehmV#A zHe@PUHjPfU&?NPMAKkr}#B3}0k(yu}Iu1)#iykTmYL5%w78kqIH0LtZA=yt@e;X8P zDcLdeGWYle;}B@sFoX`Hm&{C;mIF3!#RT)Sl|aopJr9L{^(wK$mtc%S7 z-hMUDER=nYSOGyey(~w}EyWC`fBzuQbE{3k?f=uo+`Ws4t`hIZa8yvzwnG|~sHwb~ zE}bSuA5=>@8{ea1BgcJ*;rX|HSu@(z$<@g~_l|D^YqRoqq6$Ba*I=r$rD+d{YLl?0 zS~Y2=N&EeM0KJz%a*n_^o6ZXHWOlDgEXE}Uy(rxOLYc_*T7c4qtIrPD&G)RcqKYmvPkA^a`9w6X9GOI z5wc7jEBu=t3A;pwIjehyWI%gK}S?^Q`4tAJrszaEw)kEGnRytPoG|u!qOB+>L z-0gT01R8dYy9_s$dvN|9RsEFyJ7Cptvr2(zp67`?;p&m>alaq{a~ZzV(egPqXHiUH zS%MmHFOp@}?`bu)v|n~_2fmAg4*qSgT}F4v^9Omh81LX$H_@Sb!V}8lQjRXi6(HT& zSRrWNm}7b)IN}-){p~gN8^l`;gOJV$Q)GT6JU%R)U~M!kffeN}rFi1)dfg1+%N$DJB<(_RNsaQQ+x+_8Z{w8Ap zOb!+s+Ie2GWs6@5J_1*e64Dg1coXGj)z7zfM6iJznyG0I z&fk_A8E9xAP1ZSUSf^3>cz8Jg_Ws`aA@aQj-vbja^hAP|9mdY@<|1~ z48RzH#gKks|M3}iamf#$pF5eNWJ=1}Z|;}e5Z*C;g71sTu`(^g`Zd<#$56eSmob&y zHjj-6ab5v74*`O?=ZkdRJ+s|bpB#L$M<>9A4;zFMn%qx5=kM)?J-@p?wpm>_q5F|E z+eWGaM$${X=C@QgdU7V-g&(8lD{WR&t14L0ZE^%M>v8Djwgo!5Bs?3xB93L)Kh}@O zG|hdN-gs{nH2{rLY_O3O)0L&+${mVp=VSFCnra40`CE~eday=}8TmT4i0)BiIoe2O8kfdIT?cN*^zmR} zmG)Q~aA~C;XWOWyBjDHhDR`;Wj?b5ZX(%IuvaLgpiIn9PR+w&C9V317b4-sgv|dT~ zUpeX9MF~Bl6wB@>dO$S)ka>h*7rX6gUq<&kVP)xN*_*MrRIzcX-J!2=GlQg2bPZb4 zyAWLX`TBtV+=2so(lm0UbMvdQSNeXiizDW>(bNc{AZsdFvqQ#<(9xs)Tk+sod(uBP z_p)3m$wsu)t)#-r7t7o6PRPca2p{3lA`yDFc}g9tC~jy?JOweWRPsZtiRQJW4(O`f zewg0lF-G(D*viZMH3yW-Sh*gIHo$s&fdLm@)M8;9+3$$ds5)LCYH52}t=@3sD0|rx zJmzr8sf9n`gCqF-vMw%OEd|)@hHIS>4LOe=MhkkjRpZF-Vr&z$3o}>5L!y37V*cU+ z$yoJ0AfrC`Rd|2%c$CBR<$@Siqym`!atWT+*`6yzL7rr5mLo`u|4nE9{UY(U1}<;v zQ>hYM?w&)I%-@=CUO+e_X1@GX_wh_Un$k_x+%J*ai>$!eNQ0HFrYU%VLU4lB{L;=t z@zguKD3Yqf%rSI(rZD}(Q8%X9ZPwh|J_R$HFbs~;PC{I1`s|VeR3^9(*HziXWKLxJ zAy!>e!^erOot1gBu%9dVF*y!m@QQ*wKp$_=KRi+J&E^~RzoGz@xLMG668L9Hsm}1J zJPeF?z3q#B-n;!!aRSSJZwnER5Thd?xf;{|K6LUPWS|{BCSmay_g)i z8nnWed!VEQz0G_mM{-E&O?ptzE=F;4W!>g6^ic!FCy(&S)cIpn+ZQ-pH>1wMn0jmf z)1IilzhCqF{&#sfW|}HBt>*%<&uy(5e8EtGJHhOqGR(L~f2-A@?Z?F0yFt#YXGaVQ zPvx`f2raw6uU$%5UqZYZ!{%=DlhU;%)^P-+=LWb6I~E-eyGuY`*Hm#QLiN1rO!vqR zdFO$o*Gu8rIJp2qU`Z(mi=ow~WP0C$s??fNsf3|@fZ=g)LTp7$6t5j(u!zmnzN$_d z&2WSpnkGuj_E*9|r!_9kKs{Cwvg=An?>JWOH_H;_*%)J5glN z>7?xLC%DgqUw{Zjh5k}h$moC4Jp)|o6#lX%ZV~xYi6E5+ocr(!ViS?lb7d~ArGM6} z!+U$1!5hr>joSiVX)Ga=&CU=sqH4CIDNXcQ{UGafYMaCB|aIppaV6%pF@$!2Yx5 zr|lj;HaRLvQIQVWFU&E#F(-qGEwiX7#>9P)Y^TD$x7LUDjaw@{zkV+gK~ktU=uGdI z8;17r>E|GzlOxxp{Y}Z~WJ;aigiXX8LKkPcmfG7rORFKdK5=_&O_G&QY?YF+zffq9 zf-8>-5fKB}4L$)vY4C$S*o5viOzJFFYTs7U8yr&@FTx%lg7$9n>h z^q3q^sQ1mxg*T>`%V*pbiD3|JAG0zN?^w|l1IhWT9$_PGO9Hy};Ubq_5ZbZJu;5Qq z8crK2&y9Z$2pN8632RZ{bn3#Jd#ERhj8T6m^lC>EiMr=?uPR1j1B3zGb4ikA4qqCt z+%HSV^zeg~Dwwc=^N^6_vHU!Tvv2(AIa!oXI|7{98E-{mhUrQ=!Rono{2Szh5ubQI zsrwCj(TYW0)nB&GUt(>;-dyLlpg$AHf>;lMIe06rqr5ow_}EH`3S{wq@YN0U$LGBn zt&mEJ+%6K*7>8H6@@bnnu+eX7OHYTk@cnp>wx)6I{06v1CP#b(2~`oXD~l4UZ~Ep4a;k!%Z4eUiGw<#F zlcaOp=DhZ0(x$(w=(8Z#JF>6aLHZHa`e!=AfjjnJsk(#$R0ehha|vUQOC<39@pG$I z>_#mv6RJ@Fx`uIT zHloKmEs)7no@R^uSCOwl{WqsNw02o5rc4VhjOJc8mEBTY%e?UWbPy+vth>(qA|2^q zq6DRu;`z2U29jC(St+Jg9Xtju@SWqM@?|<~`z?BrpI$D}6ff;bsixg)FTs@TqkOvH zx&qh~50j^I;bO`_`;h)E82rzhx)%MJDN^_eB|_d5Lw(K4N?UaF{l2qYO+Kyas}qS~ zo62C=gLPWwDdxW@*A=QN{tkcpP)+8%zT!}0cE3u*DI;hV{nlmkjl_J3+?ht*_Q1o; z)!}Y@FbaM>$^`AO_4w*Iqc2?}`bN;<>NS9j&HcOUejwYKO!?ilaCp$m&L$XHZyS3B z4E&}v*@XZuqnz1xZ2`~&BkSiVwvrnVi$}e|@JtU*%y@KIz zBVP=L>3i)@lYHI*_ae%&LJJzz$~y`$*(~H?MwHVXe$>$ltC`!QUuYH#9S4Xm4y_~! zeGt&Y)X{pF{213@UcvZ$AgWue7Zm(b^8yuIw77cyEZ5%h+g|tZ%^|+7=c*6XHaq+5 z!+Euxt*UhyZdlNW^+pN#MGJo18Qsc);_u)vbmmrk!tHziJWjRzdVriUiwLrZ^pxr2 zI-BTCeC+#dMM}Isqr0W$QMk2l6zElm(1Uyja8}(u&4Zytl>&r9>HLPydVvC;46_$T z=1vGIOcnanJ+;-BmAHnI+`Pib*E;Z821B{khyLwxLKOShs?KvWeifI=SDJBAE4W}E zy$7>xohg~-)^~;g>jW5|Edt;U1MK@oeI>19A&UGQx+4#&>n(*+16CMUW7`*b zJGGVBM~My88#;9ag4vYOGCul@k6I>*snG z{6BAA0n$$8_(w!fQ>hKH3y_ME$NGilP3QjO?SxoYj0 ztADOX4O5e|OA}!$Gd{m{GT$Y@r&Az(Oy!!V8ToY)4d0G(r4t(5eaVovfO2iEc;&6K ze5H=(Wi!(JNAATWqwjX!HEbk?^*t53k>K~f{pNud3qYSN*#h&${I6W{cif;h=9kR@ z+>f>5A9sRk101z%{0pOY4OSHIN*%R=fd3_hTd*kvjANlaT8vHJJQV|)sKjJx9E0O|TgRW%fPSLV2 z9Luyp5Q7d)y(GpJkta4E=)NK4+rpzGf5*s={{^+!2(D07ntcShis9|3{7D#fyI9H3 zWi^QTY^U+4+!2cN-r>M;CYt38e5~^FAf$H9B=b8sv&!(CD{t11gud>=8k9R z*4SJghYO=WS5JSmNCmuPZd$$UGHg0f3~7JE@Ttz)jxa7kP7nY^*h}*2xbs7mMhy5C z^4hfY+50qxDeOzoO`;R*eX^*w`8~5!QZ+xdW6a|{g3u7QuehML=P=~TbHQfcktZ4D z7}8Chl?HydjVXYzhv@)_IJ+`ag$dKN3dkAw3sH9`H_}oa*a{SNInurCI@K!DkeI^2kU>ikkaB%8!|9W&a8>;ugDS7 zQc0**c?B(Fq`HmWGIB8UheQ0@gWZbolIVKm(5Ayg=Cuu!WyZ^@irsy%$|_gD+r;{I zt)j@beISY7TpN9XPbbGTFRu*ehs;n-vfGU-uh0Z>k$Y$L0T~I&PLt_P#93J1s*)w( zlRRToAek`T<-c?{5m?@$(ahF%eG=frs;`^)Mfd|LsBU?Rs)eSi?omiIj?~52zRC7i zP@q@HhW$~qb974-ERKQ-+p%3NnEA885F(v^fRCT;+9Mq3MHRXoP6^d0lJ)tu*+|Kc zV2f&jVX8o2W~VZAD#FX}#-J)DkO5-qTg-SMYagk`A)03QZB9_ISSXhZfr80C5zV;j+KgceP3VfvH;inuB3rwceMx;Mqb zIo#NnX6zi{Xj#t)_g1eC0ij(ZA-X*E*)si2N^;;9?@w{JX(5(S`zUusqV89bv1MwQIUPYaO>rN`TUu** zzx$>zXqPxC*=PH5rhsX literal 0 HcmV?d00001 From 51d2c43233acd97918266c32f9780e6d194c757d Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Thu, 9 Jul 2026 14:55:06 +0200 Subject: [PATCH 02/28] Rewrite RequestContentHashFilter as interceptor --- .../api/client/internal/ApiServiceImpl.java | 6 +- ...ava => RequestContentHashInterceptor.java} | 39 +++++++--- .../RequestSignatureInterceptor.java | 30 +------- .../RequestContentHashInterceptorTest.java | 75 +++++++++++++++++++ 4 files changed, 111 insertions(+), 39 deletions(-) rename src/main/java/no/digipost/api/client/internal/http/request/interceptor/{RequestContentHashFilter.java => RequestContentHashInterceptor.java} (51%) create mode 100644 src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptorTest.java diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index b2078456..d6ee231c 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -29,7 +29,7 @@ import no.digipost.api.client.internal.http.Headers; import no.digipost.api.client.internal.http.MultipartNoLengthCheckHttpEntity; import no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor; -import no.digipost.api.client.internal.http.request.interceptor.RequestContentHashFilter; +import no.digipost.api.client.internal.http.request.interceptor.RequestContentHashInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestDateInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestSignatureInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestUserAgentInterceptor; @@ -161,7 +161,8 @@ private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClient return httpClientBuilder .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) - .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, eventLogger, new RequestContentHashFilter(eventLogger, Digester.sha256, Headers.X_Content_SHA256))) + .addRequestInterceptorLast(new RequestContentHashInterceptor(eventLogger, Digester.sha256, Headers.X_Content_SHA256)) + .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, eventLogger)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) @@ -180,6 +181,7 @@ private static CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientB .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider)) + .addRequestInterceptorLast(new RequestContentHashInterceptor(eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) .addResponseInterceptorLast(new ResponseSignatureInterceptor(entryPointSupplier)) diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashFilter.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptor.java similarity index 51% rename from src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashFilter.java rename to src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptor.java index 8b6d6fe4..0e8e2f99 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashFilter.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptor.java @@ -17,33 +17,50 @@ import no.digipost.api.client.EventLogger; import no.digipost.api.client.security.Digester; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.protocol.HttpContext; import org.bouncycastle.util.encoders.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class RequestContentHashFilter { +import java.io.IOException; +import java.util.Optional; - private static final Logger LOG = LoggerFactory.getLogger(RequestContentHashFilter.class); +public class RequestContentHashInterceptor implements HttpRequestInterceptor { + + private static final Logger LOG = LoggerFactory.getLogger(RequestContentHashInterceptor.class); private final EventLogger eventLogger; private final Digester digester; private final String header; - public RequestContentHashFilter(EventLogger eventLogger, Digester digester, String header) { + public RequestContentHashInterceptor(Digester digester, String header) { + this(EventLogger.NOOP_LOGGER, digester, header); + } + + public RequestContentHashInterceptor(EventLogger eventLogger, Digester digester, String header) { this.eventLogger = (eventLogger != null ? eventLogger : EventLogger.NOOP_LOGGER).withDebugLogTo(LOG); this.digester = digester; this.header = header; } - public RequestContentHashFilter(Digester digester, final String header) { - this(EventLogger.NOOP_LOGGER, digester, header); - } - - public void settContentHashHeader(final byte[] data, final HttpRequest httpRequest) { - byte[] result = digester.createDigest(data); - String hash = new String(Base64.encode(result)); + @Override + public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) throws IOException { + if (!(httpRequest instanceof ClassicHttpRequest)) { + return; + } + HttpEntity entity = ((ClassicHttpRequest) httpRequest).getEntity(); + if (entity == null) { + return; + } + byte[] data = Optional.ofNullable(EntityUtils.toByteArray(entity)).orElseGet(() -> new byte[0]); + String hash = new String(Base64.encode(digester.createDigest(data))); httpRequest.setHeader(header, hash); - eventLogger.log(RequestContentHashFilter.class.getSimpleName() + " satt headeren " + header + "=" + hash); + eventLogger.log(getClass().getSimpleName() + " satt headeren " + header + "=" + hash); } } diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java index c3045ff9..519d42dd 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java @@ -19,36 +19,30 @@ import no.digipost.api.client.internal.http.Headers; import no.digipost.api.client.security.RequestMessageSignatureUtil; import no.digipost.api.client.security.Signer; -import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.EntityDetails; -import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; -import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.protocol.HttpContext; import org.bouncycastle.util.encoders.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.util.Optional; public class RequestSignatureInterceptor implements HttpRequestInterceptor { private static final Logger LOG = LoggerFactory.getLogger(RequestSignatureInterceptor.class); private final Signer signer; - private final RequestContentHashFilter hashFilter; private final EventLogger eventLogger; - public RequestSignatureInterceptor(Signer signer, RequestContentHashFilter hashFilter) { - this(signer, EventLogger.NOOP_LOGGER, hashFilter); + public RequestSignatureInterceptor(Signer signer) { + this(signer, EventLogger.NOOP_LOGGER); } - public RequestSignatureInterceptor(Signer signer, EventLogger eventLogger, RequestContentHashFilter hashFilter){ + public RequestSignatureInterceptor(Signer signer, EventLogger eventLogger) { this.eventLogger = (eventLogger != null ? eventLogger : EventLogger.NOOP_LOGGER).withDebugLogTo(LOG); this.signer = signer; - this.hashFilter = hashFilter; } private void setSignatureHeader(HttpRequest httpRequest) { @@ -66,23 +60,7 @@ private void setSignatureHeader(HttpRequest httpRequest) { @Override public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) throws IOException { - - if(httpRequest instanceof ClassicHttpRequest) { - ClassicHttpRequest request = (ClassicHttpRequest) httpRequest; - HttpEntity rqEntity = request.getEntity(); - - if (rqEntity == null) { - setSignatureHeader(httpRequest); - } else { - byte[] entityBytes = Optional.ofNullable(EntityUtils.toByteArray(rqEntity)).orElseGet(() -> new byte[0]); - hashFilter.settContentHashHeader(entityBytes, request); - setSignatureHeader(httpRequest); - } - } else { - setSignatureHeader(httpRequest); - } + setSignatureHeader(httpRequest); httpContext.setAttribute("request-path", httpRequest.getPath()); - - } } 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()); + } +} From 60c0d2918afdb0c2c4c6fb5729fa6f7bbf3733f2 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Thu, 9 Jul 2026 15:15:44 +0200 Subject: [PATCH 03/28] Set "request-path" attrubute in own interceptor Moving this out of RequestSignatureInterceptor, as we need this functionality also for OAuth-based authentication, which does not use the RequestSignatureInterceptor. Also defined the attribute name as a constant in the new interceptor to make the connection between the interceptor and verification step clearer. --- .../api/client/internal/ApiServiceImpl.java | 3 ++ .../RequestHttpRequestPathInterceptor.java | 31 +++++++++++++++++++ .../RequestSignatureInterceptor.java | 1 - .../ApacheHttpResponseToVerify.java | 4 ++- 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestHttpRequestPathInterceptor.java diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index d6ee231c..a49d2d97 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -31,6 +31,7 @@ import no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestContentHashInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestDateInterceptor; +import no.digipost.api.client.internal.http.request.interceptor.RequestHttpRequestPathInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestSignatureInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestUserAgentInterceptor; import no.digipost.api.client.internal.http.response.interceptor.ResponseContentSHA256Interceptor; @@ -161,6 +162,7 @@ private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClient return httpClientBuilder .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) + .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) .addRequestInterceptorLast(new RequestContentHashInterceptor(eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, eventLogger)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) @@ -180,6 +182,7 @@ private static CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientB .build()) .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) + .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider)) .addRequestInterceptorLast(new RequestContentHashInterceptor(eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestHttpRequestPathInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestHttpRequestPathInterceptor.java new file mode 100644 index 00000000..67db746f --- /dev/null +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestHttpRequestPathInterceptor.java @@ -0,0 +1,31 @@ +/* + * 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.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.protocol.HttpContext; + +public class RequestHttpRequestPathInterceptor implements HttpRequestInterceptor { + + public static final String REQUEST_PATH_ATTRIBUTE = "request-path"; + + @Override + public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) { + httpContext.setAttribute(REQUEST_PATH_ATTRIBUTE, httpRequest.getPath()); + } +} diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java index 519d42dd..c155d8e6 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java @@ -61,6 +61,5 @@ private void setSignatureHeader(HttpRequest httpRequest) { @Override public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) throws IOException { setSignatureHeader(httpRequest); - httpContext.setAttribute("request-path", httpRequest.getPath()); } } diff --git a/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java b/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java index eb2354ec..c593dbc0 100644 --- a/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java +++ b/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java @@ -23,6 +23,8 @@ import java.util.SortedMap; import java.util.TreeMap; +import static no.digipost.api.client.internal.http.request.interceptor.RequestHttpRequestPathInterceptor.REQUEST_PATH_ATTRIBUTE; + final class ApacheHttpResponseToVerify implements ResponseToVerify { private final HttpContext context; @@ -49,7 +51,7 @@ public SortedMap 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); From 9f2ca442adfdf762d30e5704d33b0428cb1133c7 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 24 Jul 2026 15:13:31 +0200 Subject: [PATCH 04/28] Make authentication method explicit The client previously selected its authentication mode based on whether a Signer was null, and the JWT/mTLS config was hidden inside DigipostClientConfig. The choice was scattered and easy to misconfigure. - Introduce DigipostClient.withCertificateAuthentication(...) and withJwtMtlsAuthentication(...) (each with an HttpClientBuilder overload), so the chosen authentication method is stated at the call site and the required credential cannot be forgotten. - Replace the implicit "signer == null" selection with an explicit AuthMode enum resolved in one place (ApiServiceImpl#resolveAuthMode). It now also throws when both certificate and JWT/mTLS auth are configured, or neither. - Move JwtAuthConfig out of DigipostClientConfig; it is now a required argument to the JWT factory method. - Add Javadoc for the factory methods, including the clientBuilder parameter. - Add ApiServiceImplAuthModeTest covering all four resolution cases. BREAKING CHANGE: the DigipostClient(config, brokerId, signer[, clientBuilder]) constructors and the implicit no-signer constructors have been removed. All call sites (example code and DigipostSwingClient) have been migrated to the new factory methods. --- .../digipost/api/client/DigipostClient.java | 40 ++++++-- .../api/client/DigipostClientConfig.java | 15 +-- .../api/client/internal/ApiServiceImpl.java | 44 +++++++-- .../api/client/swing/DigipostSwingClient.java | 2 +- .../client/eksempelkode/AddTagEksempel.java | 2 +- .../ArkiverDokumenterEksempel.java | 2 +- .../eksempelkode/AutocompleteEksempel.java | 2 +- .../BatchSendMessagesEksempel.java | 2 +- .../FallbackTilPrintEksempel.java | 2 +- .../eksempelkode/ForsendelseEksempel.java | 2 +- .../ForsendelseEksempelDigipostadresse.java | 2 +- .../ForsendelseEksempelNavnogAdresse.java | 2 +- .../GithubPagesArchiveExamples.java | 2 +- .../GithubPagesReceiveExamples.java | 2 +- .../eksempelkode/GithubPagesSendExamples.java | 4 +- .../client/eksempelkode/PeppolEksempel.java | 2 +- .../api/client/eksempelkode/SokEksempel.java | 2 +- .../client/eksempelkode/VedleggEksempel.java | 2 +- .../internal/ApiServiceImplAuthModeTest.java | 92 +++++++++++++++++++ 19 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java diff --git a/src/main/java/no/digipost/api/client/DigipostClient.java b/src/main/java/no/digipost/api/client/DigipostClient.java index 66ba3b13..2741cde6 100644 --- a/src/main/java/no/digipost/api/client/DigipostClient.java +++ b/src/main/java/no/digipost/api/client/DigipostClient.java @@ -69,6 +69,7 @@ import java.time.ZonedDateTime; import java.util.UUID; +import static java.util.Objects.requireNonNull; import static no.digipost.api.client.internal.http.response.HttpResponseUtils.checkResponse; import static no.digipost.api.client.util.JAXBContextUtils.jaxbContext; @@ -98,20 +99,43 @@ public class DigipostClient { private final SharedDocumentsApi sharedDocumentsApi; - public DigipostClient(DigipostClientConfig config, BrokerId brokerId, Signer signer) { - this(config, brokerId, signer, HttpClientFactory.createDefaultBuilder()); + /** + * Creates a client that authenticates with the Digipost API using certificate-base request signing. + * + * @param signer signs each request with the broker's private key + */ + public static DigipostClient withCertificateAuthentication(DigipostClientConfig config, BrokerId brokerId, Signer signer) { + return withCertificateAuthentication(config, brokerId, signer, HttpClientFactory.createDefaultBuilder()); } - public DigipostClient(DigipostClientConfig config, BrokerId brokerId, Signer signer, HttpClientBuilder clientBuilder) { - this(config, new ApiServiceImpl(config, clientBuilder, brokerId, signer)); + /** + * Creates a client that authenticates with the Digipost API using certificate-base request signing. + * + * @param signer signs each request with the broker's private key + * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. connection manager, timeouts and proxy settings + */ + public static DigipostClient withCertificateAuthentication(DigipostClientConfig config, BrokerId brokerId, Signer signer, HttpClientBuilder clientBuilder) { + return new DigipostClient(config, new ApiServiceImpl(config, clientBuilder, brokerId, requireNonNull(signer, "signer cannot be null"), null)); } - public DigipostClient(DigipostClientConfig config, BrokerId brokerId) { - this(config, brokerId, HttpClientFactory.createDefaultBuilder()); + /** + * Creates a client that authenticates with the Digipost API using OAuth 2.0 access tokens + * obtained over a mutual-TLS channel. + * + * @param jwtAuthConfig configures the token endpoint and the client certificate used for mTLS + */ + public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig config, BrokerId brokerId, JwtAuthConfig jwtAuthConfig) { + return withJwtMtlsAuthentication(config, brokerId, jwtAuthConfig, HttpClientFactory.createDefaultBuilder()); } - public DigipostClient(DigipostClientConfig config, BrokerId brokerId, HttpClientBuilder clientBuilder) { - this(config, new ApiServiceImpl(config, clientBuilder, brokerId, null)); + /** + * Creates a client that authenticates with the Digipost API using OAuth 2.0 access tokens obtained over a mutual-TLS channel. + * + * @param jwtAuthConfig configures the token endpoint and the client certificate used for mTLS + * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. timeouts and proxy settings. Note that its connection manager is replaced with one configured for the mTLS handshake. + */ + public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig config, BrokerId brokerId, JwtAuthConfig jwtAuthConfig, HttpClientBuilder clientBuilder) { + return new DigipostClient(config, new ApiServiceImpl(config, clientBuilder, brokerId, null, requireNonNull(jwtAuthConfig, "jwtAuthConfig cannot be null"))); } private DigipostClient(DigipostClientConfig config, ApiServiceImpl apiService) { diff --git a/src/main/java/no/digipost/api/client/DigipostClientConfig.java b/src/main/java/no/digipost/api/client/DigipostClientConfig.java index 5734b3f4..165adf7d 100644 --- a/src/main/java/no/digipost/api/client/DigipostClientConfig.java +++ b/src/main/java/no/digipost/api/client/DigipostClientConfig.java @@ -15,12 +15,9 @@ */ package no.digipost.api.client; -import no.digipost.api.client.security.jwt.JwtAuthConfig; - import java.net.URI; import java.time.Clock; import java.time.Duration; -import java.util.Optional; import static java.util.Objects.requireNonNull; @@ -36,7 +33,6 @@ public static class Builder { private EventLogger eventLogger = EventLogger.NOOP_LOGGER; private Clock clock = Clock.systemDefaultZone(); private boolean failOnHtmlDiff = false; - private JwtAuthConfig jwtAuthConfig = null; private Builder() { } @@ -70,13 +66,8 @@ public Builder clock(Clock clock) { return this; } - public Builder jwtAuthConfig(JwtAuthConfig jwtAuthConfig) { - this.jwtAuthConfig = jwtAuthConfig; - return this; - } - public DigipostClientConfig build() { - return new DigipostClientConfig(digipostApiUri, printKeyCacheTimeToLive, eventLogger, clock, failOnHtmlDiff, jwtAuthConfig); + return new DigipostClientConfig(digipostApiUri, printKeyCacheTimeToLive, eventLogger, clock, failOnHtmlDiff); } } @@ -89,15 +80,13 @@ public DigipostClientConfig build() { public final EventLogger eventLogger; public final Clock clock; public final boolean failOnHtmlDiff; - public final JwtAuthConfig jwtAuthConfig; - private DigipostClientConfig(URI digipostApiUri, Duration printKeyCacheTimeToLive, EventLogger eventLogger, Clock clock, boolean failOnHtmlDiff, JwtAuthConfig jwtAuthConfig) { + private DigipostClientConfig(URI digipostApiUri, Duration printKeyCacheTimeToLive, EventLogger eventLogger, Clock clock, boolean failOnHtmlDiff) { this.digipostApiUri = requireNonNull(digipostApiUri, "digipostApiUri cat not be null"); this.printKeyCacheTimeToLive = requireNonNull(printKeyCacheTimeToLive, "printKeyCacheTimeToLive can not be null"); this.eventLogger = requireNonNull(eventLogger, "eventLogger can not be null"); this.clock = clock; this.failOnHtmlDiff = failOnHtmlDiff; - this.jwtAuthConfig = jwtAuthConfig; } } diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index a49d2d97..e2f6d4e9 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -141,20 +141,46 @@ public class ApiServiceImpl implements MessageDeliveryApi, InboxApi, DocumentApi // which was the case for the pattern "yyyy-MM-dd'T'HH:mm:ss.SSSZZ". See commit messages for 59caeb5737e45a15 and dcf41785a84f42caf935 for details. private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); - public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer) { + /** + * The authentication mechanism the client uses when communicating with the Digipost API. + */ + private enum AuthMode { + /** Certificate-based authentication: requests are signed with a {@link Signer}. */ + CERTIFICATE, + /** OAuth 2.0 authentication where tokens are obtained over a mutual-TLS channel. */ + JWT_MTLS + } + + private static AuthMode resolveAuthMode(Signer signer, JwtAuthConfig jwtAuthConfig) { + if (signer != null && jwtAuthConfig != null) { + throw new IllegalArgumentException("Klienten kan ikke konfigureres med både en Signer og JwtAuthConfig – velg enten sertifikatbasert autentisering eller OAuth 2.0 mTLS-basert autentisering"); + } else if (signer != null) { + return AuthMode.CERTIFICATE; + } else if (jwtAuthConfig != null) { + return AuthMode.JWT_MTLS; + } else { + throw new IllegalArgumentException("Klienten må konfigureres med en Signer for sertifikatbasert autentisering, eller JwtAuthConfig for OAuth 2.0 mTLS-basert autentisering"); + } + } + + public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer, JwtAuthConfig jwtAuthConfig) { this.brokerId = brokerId; this.eventLogger = config.eventLogger.withDebugLogTo(LOG); this.digipostUrl = config.digipostApiUri; this.cached = new Cached(() -> fetchEntryPoint(Optional.empty())); - if (signer != null) { - this.httpClient = createCertificateAuthenticatingHttpClient(httpClientBuilder, eventLogger, signer, config.clock); - this.eventLogger.log("Initialiserte apache-klient (sertifikatmodus) mot " + config.digipostApiUri); - } else if (config.jwtAuthConfig != null) { - this.httpClient = createJwtAuthenticatingHttpClient(httpClientBuilder, eventLogger, config.jwtAuthConfig, brokerId, this::getEntryPoint, config.clock); - this.eventLogger.log("Initialiserte apache-klient (JWT/mTLS-modus) mot " + config.digipostApiUri); - } else { - throw new IllegalArgumentException("Klienten må konfigureres med en Signer for sertifikatbasert autentisering, eller JwtAuthConfig for OAuth 2.0 mTLS-basert autentisering"); + AuthMode authMode = resolveAuthMode(signer, jwtAuthConfig); + switch (authMode) { + case CERTIFICATE: + this.httpClient = createCertificateAuthenticatingHttpClient(httpClientBuilder, eventLogger, signer, config.clock); + this.eventLogger.log("Initialiserte apache-klient (sertifikatmodus) mot " + config.digipostApiUri); + break; + case JWT_MTLS: + this.httpClient = createJwtAuthenticatingHttpClient(httpClientBuilder, eventLogger, jwtAuthConfig, brokerId, this::getEntryPoint, config.clock); + this.eventLogger.log("Initialiserte apache-klient (JWT/mTLS-modus) mot " + config.digipostApiUri); + break; + default: + throw new IllegalStateException("Ukjent autentiseringsmodus: " + authMode); } } 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/ApiServiceImplAuthModeTest.java b/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java new file mode 100644 index 00000000..82c38d83 --- /dev/null +++ b/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java @@ -0,0 +1,92 @@ +/* + * 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 java.net.URI; + +import static no.digipost.api.client.DigipostClientConfig.newConfiguration; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ApiServiceImplAuthModeTest { + + 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 kaster_feil_naar_verken_signer_eller_jwtAuthConfig_er_konfigurert() { + DigipostClientConfig config = newConfiguration().build(); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> + new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null, null)); + + assertThat(thrown.getMessage(), containsString("må konfigureres")); + } + + @Test + void kaster_feil_naar_baade_signer_og_jwtAuthConfig_er_konfigurert() { + DigipostClientConfig config = newConfiguration().build(); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> + new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, DUMMY_SIGNER, jwtAuthConfig())); + + assertThat(thrown.getMessage(), containsString("kan ikke konfigureres med både")); + } + + @Test + void bygger_klient_naar_kun_jwtAuthConfig_er_konfigurert() { + DigipostClientConfig config = newConfiguration().build(); + + assertDoesNotThrow(() -> + new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null, jwtAuthConfig())); + } + + @Test + void bygger_klient_naar_kun_signer_er_konfigurert() { + DigipostClientConfig config = newConfiguration().build(); + + assertDoesNotThrow(() -> + new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, DUMMY_SIGNER, null)); + } + + private static JwtAuthConfig jwtAuthConfig() { + return JwtAuthConfig + .newConfig(URI.create("https://idp.example.com/token"), URI.create("https://api.digipost.no"), "test-client") + .pkcs12KeyStore(p12Stream(), P12_PASSWORD) + .build(); + } + + private static InputStream p12Stream() { + InputStream stream = ApiServiceImplAuthModeTest.class.getResourceAsStream(P12_RESOURCE); + if (stream == null) { + throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE); + } + return stream; + } +} From 4ebd3b0f4d6002958d7e2d0a16e6a8eee39656e8 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 24 Jul 2026 16:16:24 +0200 Subject: [PATCH 05/28] Add docs for v19 --- docs/_config.yml | 7 +- docs/_v19_x/1_client_config.md | 101 +++++++ docs/_v19_x/2_send.md | 467 +++++++++++++++++++++++++++++++++ docs/_v19_x/3_receive.md | 60 +++++ docs/_v19_x/4_archive.md | 240 +++++++++++++++++ docs/_v19_x/5_batch.md | 94 +++++++ docs/_v19_x/index.html | 15 ++ 7 files changed, 982 insertions(+), 2 deletions(-) create mode 100644 docs/_v19_x/1_client_config.md create mode 100644 docs/_v19_x/2_send.md create mode 100644 docs/_v19_x/3_receive.md create mode 100644 docs/_v19_x/4_archive.md create mode 100644 docs/_v19_x/5_batch.md create mode 100644 docs/_v19_x/index.html diff --git a/docs/_config.yml b/docs/_config.yml index 590e168d..71c8022f 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -8,8 +8,8 @@ licenseUrl: http://www.apache.org/licenses/LICENSE-2.0 baseurl: "/digipost-api-client-java" -currentVersion: "16.x" -versions: ["16.x", "15.x", "13.x", "11.x", "10.x", "9.0", "8.0"] +currentVersion: "19.x" +versions: ["19.x", "16.x", "15.x", "13.x", "11.x", "10.x", "9.0", "8.0"] deprecatedVersions: ["8.0", "9.0", "10.x", "11.x", "13.x"] collections: @@ -34,6 +34,9 @@ collections: v16_x: output: true permalink: /v16.x/ + v19_x: + output: true + permalink: /v19.x/ # list of additional links on the right of the top menu headerLinks: diff --git a/docs/_v19_x/1_client_config.md b/docs/_v19_x/1_client_config.md new file mode 100644 index 00000000..75cf4458 --- /dev/null +++ b/docs/_v19_x/1_client_config.md @@ -0,0 +1,101 @@ +--- +title: Instantiate and configure the client +identifier: client_config +layout: default +--- + +### Install + +The client library is available on the [Maven Central Repository](https://central.sonatype.com/artifact/no.digipost/digipost-api-client-java). +Copy the ``-snippet from that website and put it in your pom.xml file. +Make sure to use the latest version available. + +This client requires Java 11 and `jakarta.xml-bind`. + +### Configure for production use + +To instantiate the client instance you need to supply your assigned _broker ID_, which +is set up to be permitted to integrate with the Digipost API. In addition, you must choose +an authentication method. The client supports two: + +- **OAuth 2.0 over mutual TLS (JWT/mTLS):** the client obtains access tokens over an + mTLS-secured channel and sends them as bearer tokens. Use + `DigipostClient.withJwtMtlsAuthentication(...)`. +- **Certificate-based signing:** each request is signed with a private key. Use + `DigipostClient.withCertificateAuthentication(...)`. + +The chosen method is stated explicitly in the factory method you call. + + +#### JWT/mTLS authentication + +Before you can use the Digipost API using JWT/mTLS, you must register a client with the +Digipost OAuth 2 client authority. Contact the sales team at Digipost to get access to +the client authority and register your client. + +Configure a `JwtAuthConfig` with the OAuth 2.0 token endpoint, the resource server URI and +your client ID, together with the client certificate (as a `.p12` keystore) used for the +mutual-TLS handshake against the token endpoint. + +```java +SenderId senderId = SenderId.of(123456); + +JwtAuthConfig jwtAuthConfig; +try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("client-cert.p12"))) { + jwtAuthConfig = JwtAuthConfig + .newConfig( + URI.create("https://idp.example.com/oauth2/token"), // token endpoint + URI.create("https://api.digipost.no"), // resource server + "your-client-id") + .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword") + .build(); +} + +DigipostClient client = DigipostClient.withJwtMtlsAuthentication( + DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), jwtAuthConfig); +``` + +Access tokens are fetched lazily on first use and cached until shortly before they expire. + + +#### Certificate-based authentication + +Create a `Signer` instance, e.g. by using a `.p12` file to read the private key used to +sign the API requests. + +```java +SenderId senderId = SenderId.of(123456); + +Signer signer; +try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("certificate.p12"))) { + signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, "TheSecretPassword"); +} + +DigipostClient client = DigipostClient.withCertificateAuthentication( + DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), signer); +``` + +This example will configure the client to communicate with the regular Digipost production +environment. + +### Other environments + +If you have access to other environments, this can be configured using +`DigipostClientConfig`, e.g: + +```java +URI apiUri = URI.create("https://api.test.digipost.no"); +DigipostClientConfig config = DigipostClientConfig.newConfiguration().digipostApiUri(apiUri).build(); +``` + +#### Norsk Helsenett (NHN) + +The Digipost API is accessible from both internet and Norsk Helsenett (NHN). Both entry points use +the same API, the only difference is the base URL. + +```java +URI nhnApiUri = URI.create("https://api.nhn.digipost.no"); +DigipostClientConfig config = DigipostClientConfig.newConfiguration().digipostApiUri(nhnApiUri).build(); +``` + + diff --git a/docs/_v19_x/2_send.md b/docs/_v19_x/2_send.md new file mode 100644 index 00000000..5b27f3c4 --- /dev/null +++ b/docs/_v19_x/2_send.md @@ -0,0 +1,467 @@ +--- +title: Send messages +identifier: send +layout: default +--- + +The Java client library also contains some +[example code](https://github.com/digipost/digipost-api-client-java/tree/master/src/test/java/no/digipost/api/client/eksempelkode) +which include similar examples. + +## Send a message to a recipient + +To send a message to a recipient in Digipost, you need to choose a way to identify +the recipient, instantiate a primary `Document` and the containing `Message`. Finally +these are given to the client as well as the content of the document as an `InputStream`. +The actual API communication will happen when you invoke the `.send()` method. + +### Send using a personal identification number for the recipient + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID documentUuid = UUID.randomUUID(); +Document primaryDocument = new Document(documentUuid, "Document subject", FileType.PDF); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +### Other recipient types + +There are other recipient types available to identify recipients of messages. Note that +some recipient types may require special permissions to be set up in order to be used. +E.g. bank account number requires such permissions, and are _not_ enabled by default. + +```java +NameAndAddress nameAndAddress = new NameAndAddress("Ola Nordmann", "Gateveien 1", "Oppgang B", "0001", "Oslo"); +``` + +```java +BankAccountNumber accountNum = new BankAccountNumber("12345123451"); +``` + +### Multiple documents in one message + +A message is required to have at least one document, the _primary_ document. Additional +documents can also be included as _attachments_. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF); + +Document attachment1 = new Document(UUID2, "Attachment1 subject", FileType.PDF); +Document attachment2 = new Document(UUID3, "Attachment2 subject", FileType.PDF); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .attachments(attachment1, attachment2) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("main_document_content.pdf"))) + .addContent(attachment1, Files.newInputStream(Paths.get("attachment1_content.pdf"))) + .addContent(attachment2, Files.newInputStream(Paths.get("attachment2_content.pdf"))) + .send(); +``` +## Send invoice + +```java + +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +//Previous versions of the client uses what is called an Invoice Document. With the release of v15 this has been +//removed. Now we use digipost data types instead. +Document invoice = new Document( + UUID1 + , "Invoice subject" + , FileType.PDF + , new Invoice(null, ZonedDateTime.of(2022, 5, 5, 0, 0, 0, 0, ZoneId.of("Europe/Oslo")), new BigDecimal("1.20"), "704279604", "82760100435") +); + +Message message = Message.newMessage("messageId", invoice) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(invoice, Files.newInputStream(Paths.get("invoice.pdf"))) + .send(); + + +``` + +## Send a message with SMS notification + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +// The time the SMS is sent out can be based on time after letter is delivered +// or a specific date. This example specifies that the SMS should be sent out +// one day after the letter i delivered. +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF, null, + new SmsNotification(1), null, + AuthenticationLevel.PASSWORD, SensitivityLevel.NORMAL); + +Message message = Message.newMessage(UUID2, primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + + +## Send letter with fallback to print + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF); + +PrintDetails printDetails = new PrintDetails( + new PrintRecipient("Ola Nordmann", new NorwegianAddress("Prinsensveien 123", "0460", "Oslo")), + new PrintRecipient("Norgesbedriften", new NorwegianAddress("Akers Àle 2", "0400", "Oslo")), + PrintDetails.PrintColors.MONOCHROME, PrintDetails.NondeliverableHandling.RETURN_TO_SENDER); + +Message message = Message.newMessage(UUID2, primaryDocument) + .recipient(new MessageRecipient(pin, printDetails)) + .build(); + +// addContent can also take a third parameter which is the file/ipnput stream that will be used only +// for physical mail. The below example uses the same file/input stream in both channels (digital and physical mail) +MessageDelivery result = client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +## Send letter with html + +If you want to be able to send HTML-documents you first need to contact Digipost to activate +the feature for your broker/sender. Then it is just matter of specifing HTML as the filetype +and serve an html-file as content. +Bevare that there are strict rules to what is allowed. These rules are quite verbose. But +we have open sourced the html validator and santizer software we use to make sure that +html conforms to these rules. Check out [https://github.com/digipost/digipost-html-validator](digipost-html-validator). +If you preencrypt your document, this validation will be performed in the client instead of the +server so that you can be confident that you recipient will be able to open the document. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID documentUuid = UUID.randomUUID(); +Document primaryDocument = new Document(documentUuid, "Document subject", FileType.HTML); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.html"))) + .send(); +``` + + +## Send letter with higher security level + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +// TWO_FACTOR - require BankID or BuyPass authentication to open letter +// SENSITIVE - Sender information and subject will be hidden until Digipost user +// is logged in at the appropriate authentication level +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF, null, null, null, + AuthenticationLevel.TWO_FACTOR, SensitivityLevel.SENSITIVE); + +Message message = Message.newMessage(UUID2, primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream("content.pdf"))) + .send(); +``` + +## Send a message with extra computer readable data + +With version 7 of the Digipost API, messages can have extra bits of computer readable information that +allows the creation of a customized, dynamic user experience for messages in Digipost. These extra bits of +information are referred to as instances of "Datatypes". + +All datatypes are sent in the same way. Each document can accommodate one datatype-object. An exhaustive list of +available datatypes and their documentation can be found at +[digipost/digipost-data-types](https://github.com/digipost/digipost-data-types). + +For convenience, all datatypes are available as java-classes in the java client library. + +### Datatype Appointment + +In this example, an appointment-datatype that allows for certain calendar-related functions is added to a +message. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID messageUUID = UUID.randomUUID(); + +ZonedDateTime startTime = ZonedDateTime.of(2017, 10, 23, 10, 0, 0, 0, ZoneId.systemDefault()); +AppointmentAddress address = new AppointmentAddress("Storgata 1", "0001", "Oslo"); +Info preparation = new Info("Preparation", "Please do not eat or drink 6 hours prior to examination"); +Info about = new Info("About Oslo X-Ray center", "Oslo X-Ray center is specialized in advanced image diagnostics..."); +List info = Arrays.asList(preparation, about); + +Appointment appointment = new Appointment( + startTime, startTime.plusMinutes(30), "Please arrive 15 minutes early", + "Oslo X-Ray center", address, "Lower back examination", info, Language.EN); + +Document primaryDocument = new Document(messageUUID, "X-Ray appointment", FileType.PDF, appointment); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +### Datatype ExternalLink + +This Datatype enhances a message in Digipost with a button which sends the user to an external site. The button +can optionally have a deadline, a description and a custom text. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID messageUUID = UUID.randomUUID(); + +URI externalLinkTarget = URI.create("https://example.org/loan-offer/uniqueCustomerId/"); +ZonedDateTime deadline = ZonedDateTime.of(2018, 10, 23, 10, 0, 0, 0, ZoneId.systemDefault()); + +ExternalLink externalLink = new ExternalLink(externalLinkTarget, deadline, + "Please read the terms, and use the button above to accept them. The offer expires at 23/10-2018 10:00.", + "Accept offer"); + +Document primaryDocument = new Document(messageUUID, "Housing loan application", FileType.PDF, externalLink); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream("terms.pdf"))) + .send(); +``` + +### Datatype ShareDocumentsRequest + +This datatype enables sharing of documents between an organisation and a Digipost end user. The organisation +first sends a message of datatype ShareDocumentsRequest, to which the end user can attach a list of documents. When +new documents are shared, a DocumentEvent is generated. The organisation can retrieve the status of their +ShareDocumentsRequest. If documents are shared and the sharing is not cancelled, the documents can either be downloaded +or viewed on the digipostdata.no domain. Active requests can be cancelled both by the end user and the organisation. + +The `purpose` attribute of the ShareDocumentsRequest should briefly explain why the sender organisation want to gain +access to the relevant documents. This text will be displayed prominently, and should contain the information necessary +for the user to make an informed choice. The primary document should contain a more detailed explanation. + +#### Send ShareDocumentsRequest +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID messageUUID = UUID.randomUUID(); + +ShareDocumentsRequest shareDocumentsRequest = new ShareDocumentsRequest( + Duration.ofDays(60).toSeconds(), + "We require to see your six latest pay slips in order to give you a loan." +); + +Document primaryDocument = new Document(messageUUID, "Request to access your latest payslips", FileType.PDF, shareDocumentsRequest); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Path.of("longer-desc-of-sharing-purpose.pdf"))) + .send(); +``` + +#### Discover new shared documents +The sender organisation can discover new shared documents by polling document events regularly. Use the `uuid` attribute +of the DocumentEvent to match with the `messageUUID` of the origin ShareDocumentsRequest: + +```java +List sharedDocumentEvents = digipostClient.getDocumentEvents(brokerId.asSenderId(), ZonedDateTime.now().minus(Duration.ofDays(1)), ZonedDateTime.now(), 0, 100); + .getEvents() + .stream() + .filter(event -> DocumentEventType.SHARE_DOCUMENTS_REQUEST_DOCUMENTS_SHARED.equals(event.getType())) + .toList() +``` + +NB: events are attached to the broker, _not_ each individual sender. + +#### Get state of ShareDocumentsRequest + +```java +ShareDocumentsRequestState sharedDocumentsRequestState = sendClient.getShareDocumentsRequestState(senderId, uuid); +``` + +#### Get documents + +Each `SharedDocument` has attributes describing the document and its origin. If `SharedDocumentOrigin` is of type +`OrganisationOrigin`, the corresponding document was received by the end user through Digipost from the organisation +with the provided organisation number. If the origin is of type `PrivatePersonOrigin`, the document was received either +from another end user or uploaded by the user itself. + +Get a single document as stream: + +```java +SharedDocument doc1 = sharedDocumentsRequestState.getSharedDocuments().get(0); +InputStream inputStream = sendClient.getSharedDocumentContentStream(doc1.getSharedDocumentContentStream()); +``` + +Get link to view a single document on digipostdata.no + +```java +SharedDocumentContent sharedDocumentContent = sendClient.getSharedDocumentContent(doc1.getSharedDocumentContent()); +String uri = sharedDocumentContent.getUri(); +``` + +#### Stop sharing + +```java +client.stopSharing(senderId, sharedDocumentsRequestState.stopSharing()) +``` + + + +## Send message with request for registration + +It is possible to send a message to a person, who does not have a Digipost account, where the message triggers an SMS notification with a request for registration. The SMS notification says that if they register for a Digipost account the document will be delivered digitally. The actual content of the SMS notification is set manually by Digipost. If the user does not register for a Digipost account within the defined deadline, the document will either be delivered as physical mail or not at all. + +The phone number provided SHOULD include the country code (i.e. +47). If the phone number does not start with either `"+"`, `"00"` or `"011"`, we will prepend `"+47"` if and only if the phone number string is 8 characters long. If this is not the case, the request is rejected. + +### Request for registration with physical mail as fallback + +In this case the document will be delivered as physical mail if the recipient has not registered for a Digipost account by the defined deadline. + +```java +UUID documentId = UUID.randomUUID(); +Document document = new Document(documentId, "Hello!", FileType.PDF); + +PrintDetails printDetails = new PrintDetails(RECIPIENT, RETURN_RECIPIENT); + +RequestForRegistration requestForRegistration = new RequestForRegistration( +// Deadline for when the recipent can no longer register a Digipost account + ZonedDateTime.now().plus(6, ChronoUnit.HOURS), +// Phone number that will be used for the SMS notification. Make sure the country code is included, starting with "+". + new PhoneNumber("+4712345678"), + null, + printDetails +); + +UUID messageId = UUID.randomUUID(); +Message message = Message.newMessage(messageId.toString(), document) + .recipient(new PersonalIdentificationNumber("12345678901")) + .senderId(senderId) + .requestForRegistration(requestForRegistration) + .build(); + +MessageDelivery delivery = sendClient.createMessage(message) + .addContent(document, Contents.filFraDisk("gyldig-for-print.pdf")) + .send(); + +System.out.println("status: " + delivery.getStatus()); +System.out.println("channel: " + delivery.getChannel()); + +// If the recipient does not have a Digipost account already, the value of `getChannel()` will be `null`, otherwise `Channel.DIGIPOST`. +``` + +### Request for registration without physical mail as fallback + +If the sender wishes to send the document as physical mail through its own service (if the recipient does not register a Digipost account), print details must not be included. + +```java +UUID documentId = UUID.randomUUID(); +Document document = new Document(documentId, "Hello!", FileType.PDF); + +RequestForRegistration requestForRegistration = new RequestForRegistration( +// Deadline for when the recipent can receive the document digitally right after Digipost account registration. + ZonedDateTime.now().plus(6, ChronoUnit.HOURS), +// Phone number that will be used for the SMS notification + new PhoneNumber("+4712345678"), + null, + null +); + +UUID messageId = UUID.randomUUID(); +Message message = Message.newMessage(messageId.toString(), document) + .recipient(new PersonalIdentificationNumber("12345678901")) + .senderId(senderId) + .requestForRegistration(requestForRegistration) + .build(); + +MessageDelivery delivery = sendClient.createMessage(message) + .addContent(document, Contents.filFraDisk("gyldig-for-print.pdf")) + .send(); +``` +It is up to the sender to then check if the document has been delivered digitaly prior to the defined deadline. After the deadline has passed the document will not be delivered if recipient registers for a Digipost account. The delivery status can be checked with the following: + +```java +// The messageId would be the UUID that was used when the originating message was sent +UUID messageId = UUID.fromString("efe11ce1-dfce-459a-865b-52dc313dbcb9"); +DocumentStatus status = sendClient.getDocumentStatus(senderId, messageId); +System.out.println("Status: " + status.status); +System.out.println("Channel: " + status.channel); +``` +The following statuses are possible: + +* NOT_DELIVERED +* DELIVERED + * When the document is delivered the channel can be either "DIGITAL" or "PRINT" + +## Identify user based on personal identification number + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +Identification identification = new Identification(pin); + +IdentificationResult identificationResult = client.identifyRecipient(identification); +``` + + + +## Create Digipost User Accounts + +Create new or activate existing Digipost user account. + +```java +SenderId sender = SenderId.of(123456); +UserInformation user = new UserInformation( + new NationalIdentityNumber("01013300001"), + new PhoneNumber("+4799998888"), + new EmailAddress("user@example.com") +); + +UserAccount userAccount = client.createOrActivateUserAccount(sender, user); + +DigipostAddress digipostAddress = userAccount.getDigipostAddress(); +EncryptionKey encryptionKey = userAccount.getEncryptionKey(); +``` + + +## Get Status of Document + +After you have sent a message, you can get the _status_ of a document with `getDocumentStatus`. +The response includes basic information about the delivery, like the channel the document was delivered to, as well as +delivery times and more. + +```java +DocumentStatus status = client.getDocumentStatus(senderId, documentUuid); + +System.out.println("Status: " + status.status); +System.out.println("Channel: " + status.channel); +``` diff --git a/docs/_v19_x/3_receive.md b/docs/_v19_x/3_receive.md new file mode 100644 index 00000000..71a49b2f --- /dev/null +++ b/docs/_v19_x/3_receive.md @@ -0,0 +1,60 @@ +--- +title: Receive messages +identifier: inbox +layout: default +--- + +The inbox API makes it possible for an organisation to manage messages received in Digipost. + + + +## Get documents in inbox + +The inbox call outputs a list of documents ordered by delivery time. `Offset` is the start index of the list, and `limit` is the max number of documents to be returned. The `offset` and `limit` is therefore not in any way connected to `InboxDocument.id`. + +The values `offset` and `limit` is meant for pagination so that one can fetch 100 and then the next 100. + + +```java +//get first 100 documents +Inbox first100 = client.getInbox(SenderId.of(123456), 0, 100); + +//get next 100 documents +Inbox next100 = client.getInbox(SenderId.of(123456), 100, 100); +``` + +We have now fetched the 200 newest inbox documents. As long as no new documents are received, the two API-calls shown above will always return the same result. If we now receive a new document, this will change. The first 100 will now contain 1 new document and 99 documents we have seen before. This means that as soon as you stumble upon a document you have seen before you can stop processing, given that all the following older ones have been processed. + +## Download document content + +```java +Inbox inbox = client.getInbox(SenderId.of(123456)); + +InboxDocument documentMetadata = inbox.documents.get(0); + +System.out.println("Content type is: " + documentMetadata.getContentType()); +InputStream documentContent = client.getInboxDocumentContent(documentMetadata); +``` + +## Delete document + +```java +Inbox inbox = client.getInbox(SenderId.of(123456)); + +InboxDocument documentMetadata = inbox.documents.get(0); + +client.deleteInboxDocument(documentMetadata); +``` + +## Download attachment content + +```java +Inbox inbox = client.getInbox(SenderId.of(123456)); + +InboxDocument documentMetadata = inbox.documents.get(0); +InboxDocument attachment = documentMetadata.getAttachments().get(0); + +System.out.println("Content type is: " + attachment.getContentType()); +InputStream attachmentContent = client.getInboxDocumentContent(attachment); +``` + diff --git a/docs/_v19_x/4_archive.md b/docs/_v19_x/4_archive.md new file mode 100644 index 00000000..5df831a8 --- /dev/null +++ b/docs/_v19_x/4_archive.md @@ -0,0 +1,240 @@ +--- +title: Archive functionality +identifier: archive +layout: default +--- + +The archive API makes it possible for an organisation to manage documents in archives. These files are kept in separate +archives, and the files belong to the sender organisation. + + +## Archive documents to an archive + +Let's say you want to archive two documents eg. an invoice and an attachment and +you want to have some kind of reference to both documents. You can do that +by describing the two documents with `ArchiveDocument`. Then you need to create an archive +and add the documents to the archive. In the following example we use a default archive. +You then need to send this archive and attach the actual files to the request by linking +the `ArchiveDocument` with a file and send. + +```java +// 1. We describe the documents +final ArchiveDocument invoice = new ArchiveDocument( + UUID.randomUUID() + , "invoice_123123.pdf" + , "pdf" + , "application/pdf" +); +final ArchiveDocument attachment = new ArchiveDocument( + UUID.randomUUID() + , "attachment_123123.pdf" + , "pdf" + , "application/pdf" +); + +// 2. We create an archive and add the documents to it +Archive archive = Archive.defaultArchive() + .documents(invoice, attachment) + .build(); + +// 3. We create a request to archive the files with reference between the ArchiveDocument and the actual file +client.archiveDocuments(archive) + .addFile(invoice, readFileFromDisk("invoice_123123.pdf")) + .addFile(attachment, readFileFromDisk("attachment_123123.pdf")) + .send(); +``` + +## Get a list of archives + +An organisation can have many archives, or just the default unnamed archive. That is up to +your design wishes. To get a list of the archives for a given Sender, you can do this: + +```java +//get a list of the archives +Archives archives = client.getArchives(SenderId.of(123456)); +``` + +The class `Archives` holds a list of `Archive` where you can see the name of the archive. + +## Iterate documents in an archive + +You _can_ get content of an archive with paged requests. Under is an example of how to iterate +an archive. However, it's use is strongly discouraged because it leads to the idea that +an archive can be iterated. We expect an archive to possibly reach many million rows so the iteration +will possibly give huge loads. On the other hand being able to dump all data is a necessary feature of any archive. + +_Please use fetch document by UUID or referenceID instead to create functionality on top of the archive._ +You should on your side know where and how to get a document from an archive. You do this by knowing where +you put a file you want to retrieve. + +```java +final Archives archives = client.getArchives(); + +Archive current = archives.getArchives().get(0); +final List documents = new ArrayList<>(); + +while (current.getNextDocuments().isPresent()) { + current = current.getNextDocuments() + .map(client::getArchiveDocuments) + .orElse(new Archive()); + + documents.addAll(current.getDocuments()); +} + +// This prints to total content of the list of documents +System.out.println(documents); +``` +## Archive Document attributes + +You can add optional attributes to documents. An attribute is a key/val string-map that describe documents. You can add +up to 15 attributes pr. archive document. The attribute key and value is case sensitive. + +```java +final ArchiveDocument invoice = new ArchiveDocument( + UUID.randomUUID() + , "invoice_123123.pdf" + , "pdf" + , "application/pdf" +).withAttribute("INR", "123123").withAttribute("custid", "4321"); +``` + +The attributes can be queried, so that you can get an iterable list of documents. + +```java +final Archives archives = digipostClient.getArchives(); +Archive current = archives.getArchives().get(0); + +final List documents = current.getNextDocumentsWithAttributes(Map.of("INR", "123123", "custid", "4321")) + .map(digipostClient::getArchiveDocuments) + .map(Archive::getDocuments).orElse(emptyList()); + +// This prints to total content of the list of documents +System.out.println(documents); +``` + +We recommend that the usage of attributes is made such that the number of results for a query on attributes +is less than 100. If you still want that, it's ok, but you need to iterate the pages to get all the results. + +```java +final Archives archives = client.getArchives(); + +Archive current = archives.getArchives().get(0); +final List documents = new ArrayList<>(); + +while (current.getNextDocuments().isPresent()) { + current = current.getNextDocumentsWithAttributes(Map.of("INR", "123123")) + .map(client::getArchiveDocuments) + .orElse(new Archive()); + + documents.addAll(current.getDocuments()); +} + +// This prints to total content of the list of documents +System.out.println(documents); +``` + +You can now also select by date or by attributes by date. Date is when the documents has been stored in Digipost archive. + +```java +final Archives archives = client.getArchives(); + +Archive current = archives.getArchives().get(0); +final List documents = new ArrayList<>(); + +while (current.getNextDocuments().isPresent()) { + current = current.getNextDocumentsWithAttributesByDate(Map.of("INR", "123123"), OffsetDateTime.now().minus(Period.ofDays(4)), OffsetDateTime.now()) + //current = defaultArchive.getNextDocumentsByDate(OffsetDateTime.now().minus(Period.ofDays(4)), OffsetDateTime.now()); + .map(client::getArchiveDocuments) + .orElse(new Archive()); + + documents.addAll(current.getDocuments()); +} + +// This prints to total content of the list of documents +System.out.println(documents); +``` + + +## Get documents by referenceID + +You can retrieve a set of documents by a given referenceID. You will then get the documents listed in their respective +archives in return. + +```java +final Archives archives = client.getArchiveDocumentsByReferenceId("REFERENCE_ID"); +``` + +## Get documents by uuid + +You can retrieve a set of documents by the UUID that you give the document when you archive it. In the example above +we use `UUID.randomUUID()` to generate an uuid. You can either store that random uuid in your database for +retrieval later, or you can generate a deterministic uuid based on your conventions for later retrieval. + +You will get in return an instance of `Archive` which contains information on the archive the document is contained in +and the actual document. From this you can fetch the actual document. + +```java +final UUID myConvensionUUID = UUID.fromString("vedlegg:123123:txt"); + +final Archive archiveWithDocument = client.getArchiveDocumentByUuid(myConvensionUUID); +``` + +## Get content of a document as a single-use link + +You can get the actual content of a document after you have retrieved the archive document. Below is an example of how +you can achieve this with a given `ArchiveDocument`. In the resulting `ArchiveDocumentContent`, you will get a url to +the content which expires after 30 seconds. + +```java +// This ArchiveDocument must be retrieved beforehand using one of the methods described above +final ArchiveDocument archiveDocument; + +URI getDocumentContentURI = archiveDocument.getDocumentContent().orElseThrow(); +ArchiveDocumentContent content = client.getArchiveDocumentContent(getDocumentContentURI); +``` + +## Get content of a document as a stream + +In addition to a single-use link, you also have the option to retrieve the content of a document directly as a +byte stream. + +```java +// This ArchiveDocument must be retrieved beforehand using one of the methods described above +final ArchiveDocument archiveDocument; + +URI getDocumentContentStreamURI = archiveDocument.getDocumentContentStream().orElseThrow(); +InputStream content = client.getArchiveDocumentContentStream(getDocumentContentStreamURI); +``` + +## Update document attributes and/or referenceID + +You can add an attribute or change an attribute value, but not delete an attribute. You can however set the value +to empty string. The value of the field for referenceID can be changed as well. + +```java +final UUID myConvensionUUID = UUID.fromString("vedlegg:123123:txt"); + +final Archive archiveWithDocument = client.getArchiveDocumentByUuid(myConvensionUUID); + +archiveDocument.withReferenceId("My final referenceId").withAttribute("Status", "COMPLETED_PROCESS"); + +client.updateArchiveDocument(archiveDocument, archiveDocument.getUpdate()); +``` + +## Using archive as a broker + +It is possible to be a broker for an actual sender. Most of the api described above also support +the use of SenderId to specify who you are archiving for. + +eg.: +```java +client.getArchives(SenderId.of(123456)) +client.getArchiveDocumentsByReferenceId(SenderId.of(123456), "REFERENCE_ID"); +client.getArchiveDocumentByUuid(SenderId.of(123456), myConvensionUUID); + + +Archive archive = Archive.defaultArchive() + .documents(faktura) + .senderId(SenderId.of(123456)) + .build(); +``` diff --git a/docs/_v19_x/5_batch.md b/docs/_v19_x/5_batch.md new file mode 100644 index 00000000..1732ccc4 --- /dev/null +++ b/docs/_v19_x/5_batch.md @@ -0,0 +1,94 @@ +--- +title: Batch functionality +identifier: batch +layout: default +--- + +The batch API makes it possible for an organisation to manage sending of several messages, both to Digipost and Print, in a +batch. The batch will then be delivered all at the same time atomically. If it has not been sendt yet, the batch can +also be cancelled. + +## Start and get information about a batch + +A batch is identified by a UUID specified by you. To create a batch you send a uuid to the Digipost api. In return~~~~ +you get a batch object with a status and som links for complete and cancel. + +```java +// Create an UUID +final UUID batchUUID = UUID.randomUUID(); + +// Create the batch +final Batch batch = client.createBatch(batchUUID); + +// At any time, read information about the batch +final Batch batchInformation = client.getBatchInformation(batchUUID); + +``` + +A batch can have 4 states: +`CREATED`, `NOT_COMMITTED`, `COMMITTED`, `DONE` + +CREATED is an initial state. NOT_COMMITTED is the state given when there has been added messages to the batch. +COMMITTED is a state that can occur if the batch has to be processed asynchronously. DONE means that the batch has +been commited. Digipost messages are delivered at commit time and Print messages will be delivered on first +possible work day after commit time. + + +## Send messages with batch reference. + +You can send both Digipost and Print messages just as you normally would, but to attach them to a batch you add the +batch as a reference on the message. The IMPORTANT part is visible below. Without this, the message will be delivered as +otherwise specified. + +```java +// Create an UUID +UUID batchUUID = UUID.randomUUID(); + +// Create the batch +client.createBatch(batchUUID); + +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID documentUuid = UUID.randomUUID(); +Document primaryDocument = new Document(documentUuid, "Document subject", FileType.PDF); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .batch(batchUUID) // <- IMPORTANT + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +## Commit a batch + +After you have created a batch and sendt the messages with the batch you need to commit the batch. Without the commit +Digipost will never send the messages and might at a later time delete the incomplete batch and messages +referred to in the batch. + +To complete the batch, simply complete it: + +``` java +// [...] +// get the information and verify that the count of digipost/print messages are as expected +final Batch batchInformation = client.getBatchInformation(batchUUID); + +// complete the batch +final Batch completedBatch = client.completeBatch(batchInformation); +``` + +## Cancel a batch + +You can at any time before completion cancel a batch. Cancelling means that the batch will be removed and cannot +be processed futher. Digipost will immediately delete all documents, messages and other references to the batch. +Any further attempts to fetch information about the batch will throw a 404. + +``` java +// [...] +// get the batch information +final Batch batchInformation = client.getBatchInformation(batchUUID); + +// cancel the batch +client.cancelBatch(batchInformation); +``` diff --git a/docs/_v19_x/index.html b/docs/_v19_x/index.html new file mode 100644 index 00000000..a56e64ac --- /dev/null +++ b/docs/_v19_x/index.html @@ -0,0 +1,15 @@ +--- +identifier: index +layout: default +redirect_from: / +--- + + +{% for dok in site.v19_x %} + {% if dok.identifier != 'index' %} +
+

{{ dok.title }}

+ {{dok.content}} +
+ {% endif%} +{% endfor %} From 23035edc26aa19b9530af0bba2c0f2603125827c Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Mon, 27 Jul 2026 10:06:24 +0200 Subject: [PATCH 06/28] Redirect docs root only to current version Every versioned docs page carried redirect_from: /, so several versions claimed the site root and the target became ambiguous. Drop it from the older versions so only v19 (the current version) owns the root redirect. --- docs/_v10_x/index.html | 1 - docs/_v11_x/index.html | 1 - docs/_v13_x/index.html | 1 - docs/_v15_x/index.html | 1 - docs/_v16_x/index.html | 1 - 5 files changed, 5 deletions(-) diff --git a/docs/_v10_x/index.html b/docs/_v10_x/index.html index 7416013c..20f1f8de 100644 --- a/docs/_v10_x/index.html +++ b/docs/_v10_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v11_x/index.html b/docs/_v11_x/index.html index 4bc2a660..84e043fc 100644 --- a/docs/_v11_x/index.html +++ b/docs/_v11_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v13_x/index.html b/docs/_v13_x/index.html index fdaf342f..fae900df 100644 --- a/docs/_v13_x/index.html +++ b/docs/_v13_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v15_x/index.html b/docs/_v15_x/index.html index 8ad93408..1b6a09aa 100644 --- a/docs/_v15_x/index.html +++ b/docs/_v15_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v16_x/index.html b/docs/_v16_x/index.html index fef277ee..ab86afe0 100644 --- a/docs/_v16_x/index.html +++ b/docs/_v16_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- From 3e8b25023fbc557e11698832882a23aec96d42fc Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Tue, 28 Jul 2026 17:11:11 +0200 Subject: [PATCH 07/28] Fix scope structure --- .../api/client/security/jwt/MutualTlsTokenProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3c4d8ff7..2a8d4e77 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -176,7 +176,7 @@ private static List createOAuth2TokenEndpointParams(JwtAuthC return Arrays.asList( new BasicNameValuePair("grant_type", "client_credentials"), new BasicNameValuePair("client_id", config.clientId), - new BasicNameValuePair("scope", brokerId.stringValue() + ":dpost-api"), + new BasicNameValuePair("scope", "dpost-api:" + brokerId.stringValue()), new BasicNameValuePair("resource", config.resourceServerUri.toString()) ); } From 872aa894154437a2626697a261cc5ae29d7fce0c Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 14 Aug 2026 09:53:13 +0200 Subject: [PATCH 08/28] Derive OAuth resource from DigipostClientConfig The resource URI could be set both in JwtAuthConfig.apiUri and DigipostClientConfig.digipostApiUri, both defaulting to production. A client pointed at test through DigipostClientConfig alone got tokens for production. --- .../api/client/internal/ApiServiceImpl.java | 12 ++++--- .../client/security/jwt/JwtAuthConfig.java | 31 ++++++++++++------- .../security/jwt/MutualTlsTokenProvider.java | 11 ++++--- .../internal/ApiServiceImplAuthModeTest.java | 2 +- .../jwt/MutualTlsTokenProviderTest.java | 2 +- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index e2f6d4e9..1a6f3455 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -172,11 +172,11 @@ public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientB AuthMode authMode = resolveAuthMode(signer, jwtAuthConfig); switch (authMode) { case CERTIFICATE: - this.httpClient = createCertificateAuthenticatingHttpClient(httpClientBuilder, eventLogger, signer, config.clock); + this.httpClient = createCertificateAuthenticatingHttpClient(httpClientBuilder, eventLogger, signer, config); this.eventLogger.log("Initialiserte apache-klient (sertifikatmodus) mot " + config.digipostApiUri); break; case JWT_MTLS: - this.httpClient = createJwtAuthenticatingHttpClient(httpClientBuilder, eventLogger, jwtAuthConfig, brokerId, this::getEntryPoint, config.clock); + this.httpClient = createJwtAuthenticatingHttpClient(httpClientBuilder, eventLogger, jwtAuthConfig, brokerId, this::getEntryPoint, config); this.eventLogger.log("Initialiserte apache-klient (JWT/mTLS-modus) mot " + config.digipostApiUri); break; default: @@ -184,7 +184,8 @@ public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientB } } - private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, Signer signer, Clock clock) { + private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, Signer signer, DigipostClientConfig config) { + Clock clock = config.clock; return httpClientBuilder .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) @@ -197,8 +198,9 @@ private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClient .build(); } - private static CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, JwtAuthConfig jwtAuthConfig, BrokerId brokerId, Supplier entryPointSupplier, Clock clock) { - MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig, brokerId, clock); + private static CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, JwtAuthConfig jwtAuthConfig, BrokerId brokerId, Supplier entryPointSupplier, DigipostClientConfig config) { + Clock clock = config.clock; + MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig, brokerId, config.digipostApiUri, clock); return httpClientBuilder .setConnectionManager(HttpClientConnectionManagerFactory.createDefaultBuilder() 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 index d7c502f1..95fafc63 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java +++ b/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java @@ -25,31 +25,41 @@ 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 URI resourceServerUri; public final String clientId; final KeyStore keyStore; final char[] keyPassword; - public static Builder newConfig(URI tokenEndpointUri, URI resourceServerUri, String clientId) { - return new Builder(tokenEndpointUri, resourceServerUri, clientId); + public static Builder newConfig(String clientId) { + return new Builder(clientId); } public static class Builder { - private final URI tokenEndpointUri; - private final URI resourceServerUri; + private URI tokenEndpointUri = URI.create("https://midp.digipost.no/oauth2/token"); private final String clientId; private KeyStore keyStore; private char[] keyPassword; - private Builder(URI tokenEndpointUri, URI resourceServerUri, String clientId) { - this.tokenEndpointUri = requireNonNull(tokenEndpointUri, "tokenEndpointUri cannot be null"); - this.resourceServerUri = requireNonNull(resourceServerUri, "resourceServerUri cannot be null"); + 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"); @@ -72,13 +82,12 @@ public Builder keyStore(KeyStore keyStore, String keyPassword) { public JwtAuthConfig build() { requireNonNull(keyStore, "A keyStore is required. Call pkcs12KeyStore() or keyStore()."); - return new JwtAuthConfig(tokenEndpointUri, resourceServerUri, clientId, keyStore, keyPassword); + return new JwtAuthConfig(tokenEndpointUri, clientId, keyStore, keyPassword); } } - private JwtAuthConfig(URI tokenEndpointUri, URI resourceServerUri, String clientId, KeyStore keyStore, char[] keyPassword) { + private JwtAuthConfig(URI tokenEndpointUri, String clientId, KeyStore keyStore, char[] keyPassword) { this.tokenEndpointUri = tokenEndpointUri; - this.resourceServerUri = resourceServerUri; this.clientId = clientId; this.keyStore = keyStore; this.keyPassword = keyPassword; 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 index 2a8d4e77..7f5cfe48 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -32,6 +32,7 @@ import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; @@ -40,6 +41,8 @@ import java.util.Base64; import java.util.List; +import static java.util.Objects.requireNonNull; + public class MutualTlsTokenProvider { private static final Logger LOG = LoggerFactory.getLogger(MutualTlsTokenProvider.class); @@ -60,12 +63,12 @@ public class MutualTlsTokenProvider { private volatile Instant cacheValidUntil = Instant.MIN; private final Object refreshLock = new Object(); - public MutualTlsTokenProvider(JwtAuthConfig config, BrokerId brokerId, Clock clock) { + public MutualTlsTokenProvider(JwtAuthConfig config, BrokerId brokerId, URI resourceServerUri, Clock clock) { this.config = config; this.clock = clock; this.sslContext = buildSslContext(config); this.tokenClient = buildTokenClient(this.sslContext); - this.oAuthTokenEndpointParams = createOAuth2TokenEndpointParams(config, brokerId); + this.oAuthTokenEndpointParams = createOAuth2TokenEndpointParams(config, brokerId, resourceServerUri); } public String getToken() { @@ -172,12 +175,12 @@ private static CloseableHttpClient buildTokenClient(SSLContext sslContext) { .build()); } - private static List createOAuth2TokenEndpointParams(JwtAuthConfig config, BrokerId brokerId){ + 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", config.resourceServerUri.toString()) + new BasicNameValuePair("resource", requireNonNull(resourceServerUri, "resourceServerUri cannot be null").toString()) ); } } diff --git a/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java b/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java index 82c38d83..c28b5254 100644 --- a/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java +++ b/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java @@ -77,7 +77,7 @@ void bygger_klient_naar_kun_signer_er_konfigurert() { private static JwtAuthConfig jwtAuthConfig() { return JwtAuthConfig - .newConfig(URI.create("https://idp.example.com/token"), URI.create("https://api.digipost.no"), "test-client") + .newConfig("test-client") .pkcs12KeyStore(p12Stream(), P12_PASSWORD) .build(); } 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 index a5a97b7f..b6bdf127 100644 --- a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java +++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java @@ -65,7 +65,7 @@ void stopServer() { @Test void presenterer_klientsertifikat_i_mtls_handshake() throws Exception { JwtAuthConfig config = JwtAuthConfig - .newConfig(URI.create("https://localhost/token"), URI.create("api.localhost"), "test-client") + .newConfig("test-client") .pkcs12KeyStore(p12Stream(), P12_PASSWORD) .build(); From e9d8a4c85e04ae6861875b71046da9a4a4510819 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 14 Aug 2026 10:42:46 +0200 Subject: [PATCH 09/28] Align examples in docs with recent changes --- docs/_v19_x/1_client_config.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/_v19_x/1_client_config.md b/docs/_v19_x/1_client_config.md index 75cf4458..cc1c2c01 100644 --- a/docs/_v19_x/1_client_config.md +++ b/docs/_v19_x/1_client_config.md @@ -33,9 +33,9 @@ Before you can use the Digipost API using JWT/mTLS, you must register a client w Digipost OAuth 2 client authority. Contact the sales team at Digipost to get access to the client authority and register your client. -Configure a `JwtAuthConfig` with the OAuth 2.0 token endpoint, the resource server URI and -your client ID, together with the client certificate (as a `.p12` keystore) used for the -mutual-TLS handshake against the token endpoint. +Configure a `JwtAuthConfig` with your client ID and the client certificate (as a `.p12` +keystore) used for the mutual-TLS handshake against the token endpoint. The token endpoint +defaults to the production one, so it only has to be set for other environments. ```java SenderId senderId = SenderId.of(123456); @@ -43,10 +43,7 @@ SenderId senderId = SenderId.of(123456); JwtAuthConfig jwtAuthConfig; try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("client-cert.p12"))) { jwtAuthConfig = JwtAuthConfig - .newConfig( - URI.create("https://idp.example.com/oauth2/token"), // token endpoint - URI.create("https://api.digipost.no"), // resource server - "your-client-id") + .newConfig("your-client-id") .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword") .build(); } @@ -56,6 +53,8 @@ DigipostClient client = DigipostClient.withJwtMtlsAuthentication( ``` Access tokens are fetched lazily on first use and cached until shortly before they expire. +They are requested for the API given by `DigipostClientConfig.digipostApiUri`, so you do +not configure the API URI in two places. #### Certificate-based authentication @@ -88,6 +87,16 @@ URI apiUri = URI.create("https://api.test.digipost.no"); DigipostClientConfig config = DigipostClientConfig.newConfiguration().digipostApiUri(apiUri).build(); ``` +When using JWT/mTLS, also point `JwtAuthConfig` at the token endpoint of that environment: + +```java +JwtAuthConfig jwtAuthConfig = JwtAuthConfig + .newConfig("your-client-id") + .tokenEndpoint("https://midp.test.digipost.no/oauth2/token") + .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword") + .build(); +``` + #### Norsk Helsenett (NHN) The Digipost API is accessible from both internet and Norsk Helsenett (NHN). Both entry points use From 762968603a7cbe0d4c82945eac1b44a1c1a41144 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 14 Aug 2026 10:47:08 +0200 Subject: [PATCH 10/28] Fail fast when signing unhashed content --- .../RequestSignatureInterceptor.java | 12 +++ .../RequestSignatureInterceptorTest.java | 86 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptorTest.java diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java index c155d8e6..75cb44b0 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java @@ -19,6 +19,7 @@ import no.digipost.api.client.internal.http.Headers; import no.digipost.api.client.security.RequestMessageSignatureUtil; import no.digipost.api.client.security.Signer; +import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.EntityDetails; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; @@ -58,8 +59,19 @@ private void setSignatureHeader(HttpRequest httpRequest) { eventLogger.log(getClass().getSimpleName() + " satt headeren " + Headers.X_Digipost_Signature + "=" + signature); } + private static void verifyContentIsHashed(HttpRequest httpRequest) { + boolean hasContent = httpRequest instanceof ClassicHttpRequest && ((ClassicHttpRequest) httpRequest).getEntity() != null; + if (hasContent && !httpRequest.containsHeader(Headers.X_Content_SHA256)) { + throw new IllegalStateException( + "Refusing to sign a request with content, but without the " + Headers.X_Content_SHA256 + " header. " + + RequestContentHashInterceptor.class.getSimpleName() + " must be registered before " + + RequestSignatureInterceptor.class.getSimpleName() + "."); + } + } + @Override public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) throws IOException { + verifyContentIsHashed(httpRequest); setSignatureHeader(httpRequest); } } 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..07cece18 --- /dev/null +++ b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptorTest.java @@ -0,0 +1,86 @@ +/* + * 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; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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 nekter_aa_signere_innhold_som_ikke_er_hashet() { + HttpPost request = new HttpPost("https://api.digipost.no/api/documents"); + request.setEntity(new ByteArrayEntity("digipost".getBytes(StandardCharsets.UTF_8), ContentType.APPLICATION_OCTET_STREAM)); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> + signatureInterceptor.process(request, null, new BasicHttpContext())); + + assertThat(thrown.getMessage(), containsString(Headers.X_Content_SHA256)); + } + + @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()); + } +} From 80329d6eaebf02302ca509415113188d217eed7d Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 14 Aug 2026 11:47:56 +0200 Subject: [PATCH 11/28] DigipostClientException when token retrieval fails Token endpoint failures surfaced as IllegalStateException, so callers could not handle them like the rest of the client's errors. Also keep the cause when the response is not valid JSON. --- .../digipost/api/client/errorhandling/ErrorCode.java | 1 + .../client/security/jwt/MutualTlsTokenProvider.java | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java b/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java index 193c5185..4081ca5e 100644 --- a/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java +++ b/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java @@ -32,6 +32,7 @@ public enum ErrorCode { // Internal client errors CLIENT_ERROR(CLIENT_TECHNICAL), + FAILED_TO_OBTAIN_ACCESS_TOKEN(CLIENT_TECHNICAL), // Server errors GENERAL_ERROR(UNKNOWN), 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 index 7f5cfe48..739cf23c 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -18,6 +18,7 @@ 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; @@ -42,6 +43,7 @@ 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 { @@ -96,7 +98,7 @@ private String fetchAndCacheToken() { int statusCode = response.getCode(); if (statusCode != 200) { String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - throw new IllegalStateException("Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri + ": " + body); + throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri + ": " + body); } String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); @@ -111,7 +113,7 @@ private String fetchAndCacheToken() { return token; }); } catch (IOException e) { - throw new IllegalStateException("Failed to fetch access token from " + config.tokenEndpointUri, e); + throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Failed to fetch access token from " + config.tokenEndpointUri, e); } } @@ -119,14 +121,14 @@ private static JsonNode parseTokenResponse(String responseBody) { try { return JSON.readTree(responseBody); } catch (IOException e) { - throw new IllegalStateException("Could not parse token endpoint response as JSON"); + 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 IllegalStateException("Token endpoint response did not contain an 'access_token' field"); + throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint response did not contain an 'access_token' field"); } return accessToken.asText(); } From f143616878fc7687401b8c5d2bf7db65fde634d1 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 14 Aug 2026 13:13:00 +0200 Subject: [PATCH 12/28] Clamp token cache to a positive duration --- .../security/jwt/MutualTlsTokenProvider.java | 14 ++++- .../jwt/MutualTlsTokenProviderCacheTest.java | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderCacheTest.java 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 index 739cf23c..ee1490d7 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -50,6 +50,7 @@ public class MutualTlsTokenProvider { 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(); @@ -107,9 +108,9 @@ private String fetchAndCacheToken() { Instant expiry = resolveExpiry(token, tokenResponse); cachedToken = token; - cacheValidUntil = expiry.minus(REFRESH_MARGIN); + cacheValidUntil = resolveCacheValidUntil(Instant.now(clock), expiry); - LOG.debug("Fetched new access token from {}, valid until {}", config.tokenEndpointUri, expiry); + LOG.debug("Fetched new access token from {}, valid until {}, cached until {}", config.tokenEndpointUri, expiry, cacheValidUntil); return token; }); } catch (IOException e) { @@ -155,6 +156,15 @@ private Instant resolveExpiry(String accessToken, JsonNode tokenResponse) { 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) { try { KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); 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)); + } +} From 09b16e3dde637d8e0949ea3a6e554a7486de75e1 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 21 Aug 2026 16:10:23 +0200 Subject: [PATCH 13/28] Test MutualTlsTokenProvider with local endpoint The test built its own SSLContext and HTTP client, so it verified the test's handshake rather than the provider's. It passed even with the provider's logic untouched. Let the provider's trust managers be overridden, so the test can drive the real getToken() path: mTLS handshake, request parameters, caching, expiry from the exp claim, and error mapping. --- .../security/jwt/MutualTlsTokenProvider.java | 11 +- .../jwt/MutualTlsTokenProviderTest.java | 238 ++++++++++-------- .../client/security/jwt/SettableClock.java | 50 ++++ .../security/jwt/TokenEndpointStub.java | 201 +++++++++++++++ 4 files changed, 393 insertions(+), 107 deletions(-) create mode 100644 src/test/java/no/digipost/api/client/security/jwt/SettableClock.java create mode 100644 src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java 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 index ee1490d7..bfa1f3cc 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -32,6 +32,7 @@ import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -67,9 +68,13 @@ public class MutualTlsTokenProvider { 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); + this.sslContext = buildSslContext(config, trustManagers); this.tokenClient = buildTokenClient(this.sslContext); this.oAuthTokenEndpointParams = createOAuth2TokenEndpointParams(config, brokerId, resourceServerUri); } @@ -165,13 +170,13 @@ static Instant resolveCacheValidUntil(Instant now, Instant expiry) { return minimum.isBefore(expiry) ? minimum : expiry; } - private static SSLContext buildSslContext(JwtAuthConfig config) { + 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(), null, null); + 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); 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 index b6bdf127..beb6fc7a 100644 --- a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java +++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java @@ -15,150 +15,180 @@ */ package no.digipost.api.client.security.jwt; -import com.sun.net.httpserver.HttpsConfigurator; -import com.sun.net.httpserver.HttpsParameters; -import com.sun.net.httpserver.HttpsServer; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; -import org.apache.hc.core5.http.io.entity.EntityUtils; -import org.apache.hc.core5.http.ssl.TLS; +import no.digipost.api.client.BrokerId; +import no.digipost.api.client.errorhandling.DigipostClientException; +import org.apache.hc.core5.http.NameValuePair; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLSession; -import javax.net.ssl.SSLPeerUnverifiedException; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; import java.io.InputStream; -import java.net.InetSocketAddress; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.security.cert.Certificate; import java.security.cert.X509Certificate; -import java.util.concurrent.atomic.AtomicReference; +import java.time.Duration; +import java.time.Instant; +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.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 HttpsServer server; + 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; + + @BeforeEach + void startTokenEndpoint() throws Exception { + tokenEndpoint = new TokenEndpointStub(); + clock = new SettableClock(NOW); + } @AfterEach - void stopServer() { - if (server != null) { - server.stop(0); + void stopTokenEndpoint() { + if (tokenEndpoint != null) { + tokenEndpoint.close(); } } @Test - void presenterer_klientsertifikat_i_mtls_handshake() throws Exception { - JwtAuthConfig config = JwtAuthConfig - .newConfig("test-client") - .pkcs12KeyStore(p12Stream(), P12_PASSWORD) - .build(); + void henter_token_og_presenterer_klientsertifikatet_i_handshaken() throws Exception { + tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}"); - AtomicReference presentedByClient = new AtomicReference<>(); - URI tokenEndpoint = startTokenServer(config, presentedByClient); + assertThat(tokenProvider().getToken(), is("the-token")); - try (CloseableHttpClient client = clientPresentingConfiguredCertificate(config)) { - client.execute(new HttpGet(tokenEndpoint), response -> { - EntityUtils.consume(response.getEntity()); - return null; - }); - } - - Certificate[] presented = presentedByClient.get(); + 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")); } - private CloseableHttpClient clientPresentingConfiguredCertificate(JwtAuthConfig config) throws Exception { - SSLContext clientContext = SSLContext.getInstance("TLS"); - clientContext.init(keyManagers(config), new TrustManager[]{ TRUST_ALL }, null); + @Test + void sender_client_credentials_parametrene_til_token_endepunktet() throws Exception { + tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}"); - PoolingHttpClientConnectionManagerBuilder connectionManager = PoolingHttpClientConnectionManagerBuilder.create() - .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() - .setSslContext(clientContext) - .setTlsVersions(TLS.V_1_2) - .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) - .build()); + tokenProvider().getToken(); - return HttpClients.custom() - .setConnectionManager(connectionManager.build()) - .build(); + 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())); } - private URI startTokenServer(JwtAuthConfig config, AtomicReference presentedByClient) throws Exception { - SSLContext serverContext = SSLContext.getInstance("TLS"); - serverContext.init( - keyManagers(config), // server presents the .p12 cert - new TrustManager[]{ TRUST_ALL }, - null - ); - - server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.setHttpsConfigurator(new HttpsConfigurator(serverContext) { - @Override - public void configure(HttpsParameters params) { - SSLParameters sslParameters = serverContext.getDefaultSSLParameters(); - sslParameters.setProtocols(new String[]{ "TLSv1.2" }); - sslParameters.setWantClientAuth(true); - params.setSSLParameters(sslParameters); - } - }); - server.createContext("/token", exchange -> { - SSLSession sslSession = ((com.sun.net.httpserver.HttpsExchange) exchange).getSSLSession(); - try { - presentedByClient.set(sslSession.getPeerCertificates()); - } catch (SSLPeerUnverifiedException e) { - presentedByClient.set(null); - } - byte[] body = "{\"access_token\":\"t\",\"expires_in\":300}".getBytes(); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - - return URI.create("https://127.0.0.1:" + server.getAddress().getPort() + "/token"); + @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)); } - private static KeyManager[] keyManagers(JwtAuthConfig config) throws Exception { - KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - keyManagerFactory.init(config.keyStore, config.keyPassword); - return keyManagerFactory.getKeyManagers(); + @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)); } - private static final X509TrustManager TRUST_ALL = new X509TrustManager() { - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) { } + @Test + void feil_fra_token_endepunktet_gir_DigipostClientException() throws Exception { + tokenEndpoint.respondWith(503, "{\"error\":\"temporarily_unavailable\"}"); - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) { } + DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider().getToken()); - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - }; + assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN)); + assertThat(thrown.getMessage(), containsString("503")); + } + + @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")); + } + + private MutualTlsTokenProvider tokenProvider() throws Exception { + JwtAuthConfig config = JwtAuthConfig + .newConfig(CLIENT_ID) + .tokenEndpoint(tokenEndpoint.tokenEndpointUri().toString()) + .pkcs12KeyStore(p12Stream(), P12_PASSWORD) + .build(); + + return new MutualTlsTokenProvider(config, BROKER_ID, RESOURCE_SERVER_URI, clock, tokenEndpoint.trustManagers()); + } + + 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 InputStream p12Stream() { - InputStream stream = getClass().getResourceAsStream(P12_RESOURCE); + private static InputStream p12Stream() { + InputStream stream = MutualTlsTokenProviderTest.class.getResourceAsStream(P12_RESOURCE); if (stream == null) { - throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE + " – legg den vedlagte .p12-filen under src/test/resources/no/digipost/api/client/security/jwt/"); + 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..8a2e9991 --- /dev/null +++ b/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java @@ -0,0 +1,201 @@ +/* + * 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.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 = "{}"; + + 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)); + } + + byte[] body = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(responseStatus, body.length); + exchange.getResponseBody().write(body); + 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; + } + + 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 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]; + } + } }; + } +} From 94add03928f8d9990c4503d05fbe89cb214d8491 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 21 Aug 2026 16:30:12 +0200 Subject: [PATCH 14/28] Replace AuthMode with two factory methods The enum and the unreachable switch default guarded against states only the internal constructor could create. The factories make the mode a property of the call, so no argument has to be null. --- .../digipost/api/client/DigipostClient.java | 5 +- .../api/client/internal/ApiServiceImpl.java | 64 +++++++------------ ...hModeTest.java => ApiServiceImplTest.java} | 35 ++++------ 3 files changed, 39 insertions(+), 65 deletions(-) rename src/test/java/no/digipost/api/client/internal/{ApiServiceImplAuthModeTest.java => ApiServiceImplTest.java} (62%) diff --git a/src/main/java/no/digipost/api/client/DigipostClient.java b/src/main/java/no/digipost/api/client/DigipostClient.java index 2741cde6..703375ce 100644 --- a/src/main/java/no/digipost/api/client/DigipostClient.java +++ b/src/main/java/no/digipost/api/client/DigipostClient.java @@ -69,7 +69,6 @@ import java.time.ZonedDateTime; import java.util.UUID; -import static java.util.Objects.requireNonNull; import static no.digipost.api.client.internal.http.response.HttpResponseUtils.checkResponse; import static no.digipost.api.client.util.JAXBContextUtils.jaxbContext; @@ -115,7 +114,7 @@ public static DigipostClient withCertificateAuthentication(DigipostClientConfig * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. connection manager, timeouts and proxy settings */ public static DigipostClient withCertificateAuthentication(DigipostClientConfig config, BrokerId brokerId, Signer signer, HttpClientBuilder clientBuilder) { - return new DigipostClient(config, new ApiServiceImpl(config, clientBuilder, brokerId, requireNonNull(signer, "signer cannot be null"), null)); + return new DigipostClient(config, ApiServiceImpl.withCertificateAuthentication(config, clientBuilder, brokerId, signer)); } /** @@ -135,7 +134,7 @@ public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig conf * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. timeouts and proxy settings. Note that its connection manager is replaced with one configured for the mTLS handshake. */ public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig config, BrokerId brokerId, JwtAuthConfig jwtAuthConfig, HttpClientBuilder clientBuilder) { - return new DigipostClient(config, new ApiServiceImpl(config, clientBuilder, brokerId, null, requireNonNull(jwtAuthConfig, "jwtAuthConfig cannot be null"))); + return new DigipostClient(config, ApiServiceImpl.withJwtMtlsAuthentication(config, clientBuilder, brokerId, jwtAuthConfig)); } private DigipostClient(DigipostClientConfig config, ApiServiceImpl apiService) { diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index 1a6f3455..b2c5bcf5 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -108,9 +108,10 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; -import java.util.function.Supplier; +import java.util.function.Function; import static jakarta.xml.bind.JAXB.unmarshal; +import static java.util.Objects.requireNonNull; import static java.util.Optional.ofNullable; import static no.digipost.api.client.internal.ExceptionUtils.asUnchecked; import static no.digipost.api.client.internal.ExceptionUtils.exceptionNameAndMessage; @@ -141,52 +142,27 @@ public class ApiServiceImpl implements MessageDeliveryApi, InboxApi, DocumentApi // which was the case for the pattern "yyyy-MM-dd'T'HH:mm:ss.SSSZZ". See commit messages for 59caeb5737e45a15 and dcf41785a84f42caf935 for details. private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); - /** - * The authentication mechanism the client uses when communicating with the Digipost API. - */ - private enum AuthMode { - /** Certificate-based authentication: requests are signed with a {@link Signer}. */ - CERTIFICATE, - /** OAuth 2.0 authentication where tokens are obtained over a mutual-TLS channel. */ - JWT_MTLS - } - - private static AuthMode resolveAuthMode(Signer signer, JwtAuthConfig jwtAuthConfig) { - if (signer != null && jwtAuthConfig != null) { - throw new IllegalArgumentException("Klienten kan ikke konfigureres med både en Signer og JwtAuthConfig – velg enten sertifikatbasert autentisering eller OAuth 2.0 mTLS-basert autentisering"); - } else if (signer != null) { - return AuthMode.CERTIFICATE; - } else if (jwtAuthConfig != null) { - return AuthMode.JWT_MTLS; - } else { - throw new IllegalArgumentException("Klienten må konfigureres med en Signer for sertifikatbasert autentisering, eller JwtAuthConfig for OAuth 2.0 mTLS-basert autentisering"); - } + public static ApiServiceImpl withCertificateAuthentication(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer) { + requireNonNull(signer, "signer cannot be null"); + return new ApiServiceImpl(config, brokerId, apiService -> apiService.createCertificateAuthenticatingHttpClient(httpClientBuilder, signer, config)); } - public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer, JwtAuthConfig jwtAuthConfig) { + public static ApiServiceImpl withJwtMtlsAuthentication(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, JwtAuthConfig jwtAuthConfig) { + requireNonNull(jwtAuthConfig, "jwtAuthConfig cannot be null"); + return new ApiServiceImpl(config, brokerId, apiService -> apiService.createJwtAuthenticatingHttpClient(httpClientBuilder, jwtAuthConfig, config)); + } + + private ApiServiceImpl(DigipostClientConfig config, BrokerId brokerId, Function httpClientFactory) { this.brokerId = brokerId; this.eventLogger = config.eventLogger.withDebugLogTo(LOG); this.digipostUrl = config.digipostApiUri; this.cached = new Cached(() -> fetchEntryPoint(Optional.empty())); - - AuthMode authMode = resolveAuthMode(signer, jwtAuthConfig); - switch (authMode) { - case CERTIFICATE: - this.httpClient = createCertificateAuthenticatingHttpClient(httpClientBuilder, eventLogger, signer, config); - this.eventLogger.log("Initialiserte apache-klient (sertifikatmodus) mot " + config.digipostApiUri); - break; - case JWT_MTLS: - this.httpClient = createJwtAuthenticatingHttpClient(httpClientBuilder, eventLogger, jwtAuthConfig, brokerId, this::getEntryPoint, config); - this.eventLogger.log("Initialiserte apache-klient (JWT/mTLS-modus) mot " + config.digipostApiUri); - break; - default: - throw new IllegalStateException("Ukjent autentiseringsmodus: " + authMode); - } + this.httpClient = httpClientFactory.apply(this); } - private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, Signer signer, DigipostClientConfig config) { + private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, Signer signer, DigipostClientConfig config) { Clock clock = config.clock; - return httpClientBuilder + CloseableHttpClient httpClient = httpClientBuilder .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) @@ -196,13 +172,16 @@ private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClient .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) .build(); + + eventLogger.log("Initialiserte apache-klient (sertifikatmodus) mot " + config.digipostApiUri); + return httpClient; } - private static CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, EventLogger eventLogger, JwtAuthConfig jwtAuthConfig, BrokerId brokerId, Supplier entryPointSupplier, DigipostClientConfig config) { + private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, JwtAuthConfig jwtAuthConfig, DigipostClientConfig config) { Clock clock = config.clock; MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig, brokerId, config.digipostApiUri, clock); - return httpClientBuilder + CloseableHttpClient httpClient = httpClientBuilder .setConnectionManager(HttpClientConnectionManagerFactory.createDefaultBuilder() .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() .setSslContext(tokenProvider.getSslContext()) @@ -215,8 +194,11 @@ private static CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientB .addRequestInterceptorLast(new RequestContentHashInterceptor(eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) - .addResponseInterceptorLast(new ResponseSignatureInterceptor(entryPointSupplier)) + .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) .build(); + + eventLogger.log("Initialiserte apache-klient (JWT/mTLS-modus) mot " + config.digipostApiUri); + return httpClient; } //Kan sende inn null. Man får da det samme som getEntryPoint() diff --git a/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java similarity index 62% rename from src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java rename to src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java index c28b5254..a5aecc82 100644 --- a/src/test/java/no/digipost/api/client/internal/ApiServiceImplAuthModeTest.java +++ b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java @@ -23,15 +23,12 @@ import org.junit.jupiter.api.Test; import java.io.InputStream; -import java.net.URI; import static no.digipost.api.client.DigipostClientConfig.newConfiguration; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; -public class ApiServiceImplAuthModeTest { +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"; @@ -40,39 +37,35 @@ public class ApiServiceImplAuthModeTest { private static final Signer DUMMY_SIGNER = dataToSign -> new byte[0]; @Test - void kaster_feil_naar_verken_signer_eller_jwtAuthConfig_er_konfigurert() { + void bygger_jwt_autentiserende_klient() { DigipostClientConfig config = newConfiguration().build(); - IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> - new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null, null)); - - assertThat(thrown.getMessage(), containsString("må konfigureres")); + assertDoesNotThrow(() -> + ApiServiceImpl.withJwtMtlsAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, jwtAuthConfig())); } @Test - void kaster_feil_naar_baade_signer_og_jwtAuthConfig_er_konfigurert() { + void bygger_sertifikat_autentiserende_klient() { DigipostClientConfig config = newConfiguration().build(); - IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> - new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, DUMMY_SIGNER, jwtAuthConfig())); - - assertThat(thrown.getMessage(), containsString("kan ikke konfigureres med både")); + assertDoesNotThrow(() -> + ApiServiceImpl.withCertificateAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, DUMMY_SIGNER)); } @Test - void bygger_klient_naar_kun_jwtAuthConfig_er_konfigurert() { + void krever_signer_for_sertifikatbasert_autentisering() { DigipostClientConfig config = newConfiguration().build(); - assertDoesNotThrow(() -> - new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null, jwtAuthConfig())); + assertThrows(NullPointerException.class, () -> + ApiServiceImpl.withCertificateAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null)); } @Test - void bygger_klient_naar_kun_signer_er_konfigurert() { + void krever_jwtAuthConfig_for_jwt_basert_autentisering() { DigipostClientConfig config = newConfiguration().build(); - assertDoesNotThrow(() -> - new ApiServiceImpl(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, DUMMY_SIGNER, null)); + assertThrows(NullPointerException.class, () -> + ApiServiceImpl.withJwtMtlsAuthentication(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null)); } private static JwtAuthConfig jwtAuthConfig() { @@ -83,7 +76,7 @@ private static JwtAuthConfig jwtAuthConfig() { } private static InputStream p12Stream() { - InputStream stream = ApiServiceImplAuthModeTest.class.getResourceAsStream(P12_RESOURCE); + InputStream stream = ApiServiceImplTest.class.getResourceAsStream(P12_RESOURCE); if (stream == null) { throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE); } From 1f55854cb8d106da8077faf187d37f22ce965122 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 21 Aug 2026 16:32:18 +0200 Subject: [PATCH 15/28] Log interceptor events under one logger The interceptors were handed the eventLogger already wrapped for ApiServiceImpl's logger, so every message reached slf4j twice. --- .../digipost/api/client/internal/ApiServiceImpl.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index b2c5bcf5..0defbe01 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -163,11 +163,11 @@ private ApiServiceImpl(DigipostClientConfig config, BrokerId brokerId, Function< private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, Signer signer, DigipostClientConfig config) { Clock clock = config.clock; CloseableHttpClient httpClient = httpClientBuilder - .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) + .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) - .addRequestInterceptorLast(new RequestContentHashInterceptor(eventLogger, Digester.sha256, Headers.X_Content_SHA256)) - .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, eventLogger)) + .addRequestInterceptorLast(new RequestContentHashInterceptor(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256)) + .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, config.eventLogger)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) @@ -187,11 +187,11 @@ private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder .setSslContext(tokenProvider.getSslContext()) .build()) .build()) - .addRequestInterceptorLast(new RequestDateInterceptor(eventLogger, clock)) + .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider)) - .addRequestInterceptorLast(new RequestContentHashInterceptor(eventLogger, Digester.sha256, Headers.X_Content_SHA256)) + .addRequestInterceptorLast(new RequestContentHashInterceptor(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) From 3fc1380b501892630a9f49f7e3e930bc7c23e11a Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 21 Aug 2026 16:47:26 +0200 Subject: [PATCH 16/28] Decouple RequestBearerTokenInterceptor from the token provider Taking a Supplier instead of MutualTlsTokenProvider lets the interceptor be tested without a keystore and a TLS handshake. --- .../api/client/internal/ApiServiceImpl.java | 2 +- .../RequestBearerTokenInterceptor.java | 12 ++-- .../RequestBearerTokenInterceptorTest.java | 64 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.java diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index 0defbe01..766ec93c 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -190,7 +190,7 @@ private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) - .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider)) + .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider::getToken)) .addRequestInterceptorLast(new RequestContentHashInterceptor(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java index 51a7ebaa..5d387402 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java @@ -15,22 +15,24 @@ */ package no.digipost.api.client.internal.http.request.interceptor; -import no.digipost.api.client.security.jwt.MutualTlsTokenProvider; import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; import org.apache.hc.core5.http.protocol.HttpContext; +import java.util.function.Supplier; + public class RequestBearerTokenInterceptor implements HttpRequestInterceptor { - private final MutualTlsTokenProvider tokenProvider; + private final Supplier accessToken; - public RequestBearerTokenInterceptor(MutualTlsTokenProvider tokenProvider) { - this.tokenProvider = tokenProvider; + public RequestBearerTokenInterceptor(Supplier accessToken) { + this.accessToken = accessToken; } @Override public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) { - request.setHeader("Authorization", "Bearer " + tokenProvider.getToken()); + request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.get()); } } 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")); + } +} From b99f529213782a67c934d2a2d216a04db6040cc1 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 21 Aug 2026 16:51:09 +0200 Subject: [PATCH 17/28] Req.HttpReq.PathInterceptor to Req.PathInterceptor The name stuttered "Request" twice. --- .../no/digipost/api/client/internal/ApiServiceImpl.java | 6 +++--- ...uestPathInterceptor.java => RequestPathInterceptor.java} | 2 +- .../response/interceptor/ApacheHttpResponseToVerify.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/main/java/no/digipost/api/client/internal/http/request/interceptor/{RequestHttpRequestPathInterceptor.java => RequestPathInterceptor.java} (93%) diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index 766ec93c..4d188bcc 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -31,7 +31,7 @@ import no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestContentHashInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestDateInterceptor; -import no.digipost.api.client.internal.http.request.interceptor.RequestHttpRequestPathInterceptor; +import no.digipost.api.client.internal.http.request.interceptor.RequestPathInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestSignatureInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestUserAgentInterceptor; import no.digipost.api.client.internal.http.response.interceptor.ResponseContentSHA256Interceptor; @@ -165,7 +165,7 @@ private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClient CloseableHttpClient httpClient = httpClientBuilder .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) - .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) + .addRequestInterceptorLast(new RequestPathInterceptor()) .addRequestInterceptorLast(new RequestContentHashInterceptor(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, config.eventLogger)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) @@ -189,7 +189,7 @@ private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder .build()) .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) - .addRequestInterceptorLast(new RequestHttpRequestPathInterceptor()) + .addRequestInterceptorLast(new RequestPathInterceptor()) .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider::getToken)) .addRequestInterceptorLast(new RequestContentHashInterceptor(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256)) .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestHttpRequestPathInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestPathInterceptor.java similarity index 93% rename from src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestHttpRequestPathInterceptor.java rename to src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestPathInterceptor.java index 67db746f..537a4783 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestHttpRequestPathInterceptor.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestPathInterceptor.java @@ -20,7 +20,7 @@ import org.apache.hc.core5.http.HttpRequestInterceptor; import org.apache.hc.core5.http.protocol.HttpContext; -public class RequestHttpRequestPathInterceptor implements HttpRequestInterceptor { +public class RequestPathInterceptor implements HttpRequestInterceptor { public static final String REQUEST_PATH_ATTRIBUTE = "request-path"; diff --git a/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java b/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java index c593dbc0..6cffd55b 100644 --- a/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java +++ b/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java @@ -23,7 +23,7 @@ import java.util.SortedMap; import java.util.TreeMap; -import static no.digipost.api.client.internal.http.request.interceptor.RequestHttpRequestPathInterceptor.REQUEST_PATH_ATTRIBUTE; +import static no.digipost.api.client.internal.http.request.interceptor.RequestPathInterceptor.REQUEST_PATH_ATTRIBUTE; final class ApacheHttpResponseToVerify implements ResponseToVerify { From b8267da69a5c2a9f9aedcd25a24d75ba1fb0450b Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Fri, 21 Aug 2026 16:53:34 +0200 Subject: [PATCH 18/28] Document params on the factory methods Also fixes "certificate-base" -> "certificate-based", and states on the JWT methods that tokens are requested for the API given by config. --- .../java/no/digipost/api/client/DigipostClient.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/no/digipost/api/client/DigipostClient.java b/src/main/java/no/digipost/api/client/DigipostClient.java index 703375ce..d13eaa4d 100644 --- a/src/main/java/no/digipost/api/client/DigipostClient.java +++ b/src/main/java/no/digipost/api/client/DigipostClient.java @@ -99,8 +99,10 @@ public class DigipostClient { /** - * Creates a client that authenticates with the Digipost API using certificate-base request signing. + * Creates a client that authenticates with the Digipost API using certificate-based request signing. * + * @param config the client configuration, e.g. which API to communicate with + * @param brokerId the broker permitted to integrate with the Digipost API * @param signer signs each request with the broker's private key */ public static DigipostClient withCertificateAuthentication(DigipostClientConfig config, BrokerId brokerId, Signer signer) { @@ -108,8 +110,10 @@ public static DigipostClient withCertificateAuthentication(DigipostClientConfig } /** - * Creates a client that authenticates with the Digipost API using certificate-base request signing. + * Creates a client that authenticates with the Digipost API using certificate-based request signing. * + * @param config the client configuration, e.g. which API to communicate with + * @param brokerId the broker permitted to integrate with the Digipost API * @param signer signs each request with the broker's private key * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. connection manager, timeouts and proxy settings */ @@ -121,6 +125,8 @@ public static DigipostClient withCertificateAuthentication(DigipostClientConfig * Creates a client that authenticates with the Digipost API using OAuth 2.0 access tokens * obtained over a mutual-TLS channel. * + * @param config the client configuration, e.g. which API to communicate with. The access tokens are requested for that same API + * @param brokerId the broker permitted to integrate with the Digipost API * @param jwtAuthConfig configures the token endpoint and the client certificate used for mTLS */ public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig config, BrokerId brokerId, JwtAuthConfig jwtAuthConfig) { @@ -130,6 +136,8 @@ public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig conf /** * Creates a client that authenticates with the Digipost API using OAuth 2.0 access tokens obtained over a mutual-TLS channel. * + * @param config the client configuration, e.g. which API to communicate with. The access tokens are requested for that same API + * @param brokerId the broker permitted to integrate with the Digipost API * @param jwtAuthConfig configures the token endpoint and the client certificate used for mTLS * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. timeouts and proxy settings. Note that its connection manager is replaced with one configured for the mTLS handshake. */ From 57dae04e69fa6a6db0d39f94dd0bd547a5f2f37f Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 2 Sep 2026 19:12:15 +0200 Subject: [PATCH 19/28] Mention Nyva in docs and link to API-docs --- docs/_v19_x/1_client_config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_v19_x/1_client_config.md b/docs/_v19_x/1_client_config.md index cc1c2c01..4c7d958f 100644 --- a/docs/_v19_x/1_client_config.md +++ b/docs/_v19_x/1_client_config.md @@ -30,8 +30,8 @@ The chosen method is stated explicitly in the factory method you call. #### JWT/mTLS authentication Before you can use the Digipost API using JWT/mTLS, you must register a client with the -Digipost OAuth 2 client authority. Contact the sales team at Digipost to get access to -the client authority and register your client. +(Digipost OAuth 2 client authority (Nyva))[https://nyva.digipost.no]. Contact the sales team at Digipost to get access to +the client authority and register your client. More information can be found in the (Digipost API Documentation)[https://digipost.github.io/digipost-technical-docs/]. Configure a `JwtAuthConfig` with your client ID and the client certificate (as a `.p12` keystore) used for the mutual-TLS handshake against the token endpoint. The token endpoint From 010a04b1edfb3ad01bc2ff1e46d509c46b6ab004 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 9 Sep 2026 08:45:42 +0200 Subject: [PATCH 20/28] Handle token endpoint responses without body --- .../security/jwt/MutualTlsTokenProvider.java | 10 +++++-- .../jwt/MutualTlsTokenProviderTest.java | 27 +++++++++++++++++++ .../security/jwt/TokenEndpointStub.java | 19 ++++++++++--- 3 files changed, 50 insertions(+), 6 deletions(-) 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 index bfa1f3cc..ad860637 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -25,6 +25,7 @@ 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; @@ -103,8 +104,13 @@ private String fetchAndCacheToken() { return tokenClient.execute(request, response -> { int statusCode = response.getCode(); if (statusCode != 200) { - String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); - throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri + ": " + body); + 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); 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 index beb6fc7a..84caad90 100644 --- a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java +++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java @@ -17,10 +17,14 @@ import no.digipost.api.client.BrokerId; import no.digipost.api.client.errorhandling.DigipostClientException; +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.URI; @@ -38,6 +42,7 @@ 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; @@ -140,6 +145,28 @@ void feil_fra_token_endepunktet_gir_DigipostClientException() throws Exception { 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"); 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 index 8a2e9991..b1d63947 100644 --- a/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java +++ b/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java @@ -96,10 +96,15 @@ public void configure(HttpsParameters params) { receivedForms.add(WWWFormCodec.parse(form, StandardCharsets.UTF_8)); } - byte[] body = responseBody.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(responseStatus, body.length); - exchange.getResponseBody().write(body); + 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(); @@ -116,6 +121,12 @@ void respondWith(int status, String body) { this.responseBody = body; } + /** 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(); From effc05d5ef616fa3639c77c252bbe92bcafa1604 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 9 Sep 2026 08:57:17 +0200 Subject: [PATCH 21/28] MutualTlsTokenProvider implements Closeable --- .../client/security/jwt/MutualTlsTokenProvider.java | 13 ++++++++++++- .../security/jwt/MutualTlsTokenProviderTest.java | 10 ++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) 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 index ad860637..07e0d98f 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -34,7 +34,9 @@ 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; @@ -47,7 +49,7 @@ import static java.util.Objects.requireNonNull; import static no.digipost.api.client.errorhandling.ErrorCode.FAILED_TO_OBTAIN_ACCESS_TOKEN; -public class MutualTlsTokenProvider { +public class MutualTlsTokenProvider implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(MutualTlsTokenProvider.class); @@ -96,6 +98,15 @@ 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)); 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 index 84caad90..14e4acf3 100644 --- a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java +++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java @@ -33,6 +33,7 @@ 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; @@ -57,6 +58,7 @@ public class MutualTlsTokenProviderTest { private TokenEndpointStub tokenEndpoint; private SettableClock clock; + private final List tokenProviders = new ArrayList<>(); @BeforeEach void startTokenEndpoint() throws Exception { @@ -65,7 +67,9 @@ void startTokenEndpoint() throws Exception { } @AfterEach - void stopTokenEndpoint() { + void closeTokenProvidersAndStopTokenEndpoint() { + tokenProviders.forEach(MutualTlsTokenProvider::close); + tokenProviders.clear(); if (tokenEndpoint != null) { tokenEndpoint.close(); } @@ -193,7 +197,9 @@ private MutualTlsTokenProvider tokenProvider() throws Exception { .pkcs12KeyStore(p12Stream(), P12_PASSWORD) .build(); - return new MutualTlsTokenProvider(config, BROKER_ID, RESOURCE_SERVER_URI, clock, tokenEndpoint.trustManagers()); + MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(config, BROKER_ID, RESOURCE_SERVER_URI, clock, tokenEndpoint.trustManagers()); + tokenProviders.add(tokenProvider); + return tokenProvider; } private String parameter(String name) { From 0a72b82ce2a86b3f126ccd75771ceae02c2ba350 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 9 Sep 2026 13:38:13 +0200 Subject: [PATCH 22/28] Configurable timeouts for MutualTlsTokenProvider --- docs/_v19_x/1_client_config.md | 19 ++++++++++ .../client/security/jwt/JwtAuthConfig.java | 37 ++++++++++++++++++- .../security/jwt/MutualTlsTokenProvider.java | 15 ++++---- .../jwt/MutualTlsTokenProviderTest.java | 33 ++++++++++++++--- .../security/jwt/TokenEndpointStub.java | 20 ++++++++++ 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/docs/_v19_x/1_client_config.md b/docs/_v19_x/1_client_config.md index 4c7d958f..a53968ee 100644 --- a/docs/_v19_x/1_client_config.md +++ b/docs/_v19_x/1_client_config.md @@ -56,6 +56,25 @@ Access tokens are fetched lazily on first use and cached until shortly before th They are requested for the API given by `DigipostClientConfig.digipostApiUri`, so you do not configure the API URI in two places. +The access tokens are fetched with a separate HTTP client, as it has to present the client +certificate configured above in the TLS handshake against the token endpoint. Its timeouts +(and proxy, connection pool, ...) can be configured with `tokenEndpointHttpSettings(..)`: + +```java +JwtAuthConfig jwtAuthConfig = JwtAuthConfig + .newConfig("your-client-id") + .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword") + .tokenEndpointHttpSettings( + HttpClientSettings.DEFAULT.timeouts(HttpClientDefaults.DEFAULT_TIMEOUTS_MS.connect(2000).connectionRequest(1000)), + HttpClientConnectionSettings.DEFAULT.socketTimeout(5000)) + .build(); +``` + +Both parameters have sensible defaults, so pass `HttpClientSettings.DEFAULT` or +`HttpClientConnectionSettings.DEFAULT` for the one you do not need to change. The timeouts of +the client talking to the Digipost API itself are configured separately, with the +`HttpClientBuilder` accepted by `DigipostClient.withJwtMtlsAuthentication(..)`. + #### Certificate-based authentication 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 index 95fafc63..c44af0d3 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java +++ b/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java @@ -15,6 +15,9 @@ */ 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; @@ -40,6 +43,8 @@ public final class JwtAuthConfig { 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); @@ -50,6 +55,8 @@ public static class Builder { 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"); @@ -80,16 +87,42 @@ public Builder keyStore(KeyStore keyStore, String keyPassword) { 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); + return new JwtAuthConfig(tokenEndpointUri, clientId, keyStore, keyPassword, httpClientSettings, httpClientConnectionSettings); } } - private JwtAuthConfig(URI tokenEndpointUri, String clientId, KeyStore keyStore, char[] keyPassword) { + 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 index 07e0d98f..a6cacff8 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -78,7 +78,7 @@ public MutualTlsTokenProvider(JwtAuthConfig config, BrokerId brokerId, URI resou this.config = config; this.clock = clock; this.sslContext = buildSslContext(config, trustManagers); - this.tokenClient = buildTokenClient(this.sslContext); + this.tokenClient = buildTokenClient(config, this.sslContext); this.oAuthTokenEndpointParams = createOAuth2TokenEndpointParams(config, brokerId, resourceServerUri); } @@ -200,13 +200,14 @@ private static SSLContext buildSslContext(JwtAuthConfig config, TrustManager[] t } } - private static CloseableHttpClient buildTokenClient(SSLContext sslContext) { + private static CloseableHttpClient buildTokenClient(JwtAuthConfig config, SSLContext sslContext) { - return HttpClientFactory.create(HttpClientConnectionManagerFactory.createDefaultBuilder() - .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() - .setSslContext(sslContext) - .build()) - .build()); + 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){ 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 index 14e4acf3..e8f03afd 100644 --- a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java +++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java @@ -17,6 +17,8 @@ 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; @@ -27,6 +29,7 @@ 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; @@ -190,18 +193,38 @@ void svar_uten_access_token_gir_DigipostClientException() throws Exception { assertThat(thrown.getMessage(), containsString("access_token")); } - private MutualTlsTokenProvider tokenProvider() throws Exception { - JwtAuthConfig config = JwtAuthConfig - .newConfig(CLIENT_ID) - .tokenEndpoint(tokenEndpoint.tokenEndpointUri().toString()) - .pkcs12KeyStore(p12Stream(), P12_PASSWORD) + @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() 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 index b1d63947..89226ae5 100644 --- a/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java +++ b/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java @@ -47,6 +47,7 @@ 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; @@ -67,6 +68,7 @@ final class TokenEndpointStub implements Closeable { private volatile int responseStatus = 200; private volatile String responseBody = "{}"; + private volatile Duration responseDelay = Duration.ZERO; TokenEndpointStub() throws Exception { KeyPair keyPair = generateKeyPair(); @@ -96,6 +98,8 @@ public void configure(HttpsParameters params) { receivedForms.add(WWWFormCodec.parse(form, StandardCharsets.UTF_8)); } + sleep(responseDelay); + String body = responseBody; if (body == null) { exchange.sendResponseHeaders(responseStatus, -1); @@ -121,6 +125,11 @@ void respondWith(int status, String body) { 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; @@ -159,6 +168,17 @@ 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(); From 79ecfdaa9ee5eb94a4e465a182bddd46369e44b9 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 9 Sep 2026 13:39:22 +0200 Subject: [PATCH 23/28] Fix botched markdown-links --- docs/_v19_x/1_client_config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_v19_x/1_client_config.md b/docs/_v19_x/1_client_config.md index a53968ee..0af512a6 100644 --- a/docs/_v19_x/1_client_config.md +++ b/docs/_v19_x/1_client_config.md @@ -30,8 +30,8 @@ The chosen method is stated explicitly in the factory method you call. #### JWT/mTLS authentication Before you can use the Digipost API using JWT/mTLS, you must register a client with the -(Digipost OAuth 2 client authority (Nyva))[https://nyva.digipost.no]. Contact the sales team at Digipost to get access to -the client authority and register your client. More information can be found in the (Digipost API Documentation)[https://digipost.github.io/digipost-technical-docs/]. +[Digipost OAuth 2 client authority (Nyva)](https://nyva.digipost.no). Contact the sales team at Digipost to get access to +the client authority and register your client. More information can be found in the [Digipost API Documentation](https://digipost.github.io/digipost-technical-docs/). Configure a `JwtAuthConfig` with your client ID and the client certificate (as a `.p12` keystore) used for the mutual-TLS handshake against the token endpoint. The token endpoint From 19318f178d063e6bf84e847748cc5bea97dff186 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 9 Sep 2026 13:39:29 +0200 Subject: [PATCH 24/28] Remove hash-check in RequestSignatureInterceptor In practice, this check was only a check for the order of the request-interceptors, which are hard-coded anyways. --- .../interceptor/RequestSignatureInterceptor.java | 12 ------------ .../interceptor/RequestSignatureInterceptorTest.java | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java index 75cb44b0..c155d8e6 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java @@ -19,7 +19,6 @@ import no.digipost.api.client.internal.http.Headers; import no.digipost.api.client.security.RequestMessageSignatureUtil; import no.digipost.api.client.security.Signer; -import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.EntityDetails; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; @@ -59,19 +58,8 @@ private void setSignatureHeader(HttpRequest httpRequest) { eventLogger.log(getClass().getSimpleName() + " satt headeren " + Headers.X_Digipost_Signature + "=" + signature); } - private static void verifyContentIsHashed(HttpRequest httpRequest) { - boolean hasContent = httpRequest instanceof ClassicHttpRequest && ((ClassicHttpRequest) httpRequest).getEntity() != null; - if (hasContent && !httpRequest.containsHeader(Headers.X_Content_SHA256)) { - throw new IllegalStateException( - "Refusing to sign a request with content, but without the " + Headers.X_Content_SHA256 + " header. " + - RequestContentHashInterceptor.class.getSimpleName() + " must be registered before " + - RequestSignatureInterceptor.class.getSimpleName() + "."); - } - } - @Override public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) throws IOException { - verifyContentIsHashed(httpRequest); setSignatureHeader(httpRequest); } } 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 index 07cece18..1dc47b26 100644 --- 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 @@ -36,7 +36,6 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; public class RequestSignatureInterceptorTest { @@ -64,17 +63,6 @@ public void signerer_over_innholdshashen_naar_interceptorene_kjoerer_i_registrer assertThat(request.getFirstHeader(Headers.X_Digipost_Signature), notNullValue()); } - @Test - public void nekter_aa_signere_innhold_som_ikke_er_hashet() { - HttpPost request = new HttpPost("https://api.digipost.no/api/documents"); - request.setEntity(new ByteArrayEntity("digipost".getBytes(StandardCharsets.UTF_8), ContentType.APPLICATION_OCTET_STREAM)); - - IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> - signatureInterceptor.process(request, null, new BasicHttpContext())); - - assertThat(thrown.getMessage(), containsString(Headers.X_Content_SHA256)); - } - @Test public void signerer_request_uten_innhold() { HttpGet request = new HttpGet("https://api.digipost.no/api/documents"); From e75fbfc50d8c99bc0edd7e2ea9953ea24b8a915b Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 9 Sep 2026 13:46:21 +0200 Subject: [PATCH 25/28] Mark cert-auth as legacy in docs --- docs/_v19_x/1_client_config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_v19_x/1_client_config.md b/docs/_v19_x/1_client_config.md index 0af512a6..7b8e171c 100644 --- a/docs/_v19_x/1_client_config.md +++ b/docs/_v19_x/1_client_config.md @@ -21,7 +21,7 @@ an authentication method. The client supports two: - **OAuth 2.0 over mutual TLS (JWT/mTLS):** the client obtains access tokens over an mTLS-secured channel and sends them as bearer tokens. Use `DigipostClient.withJwtMtlsAuthentication(...)`. -- **Certificate-based signing:** each request is signed with a private key. Use +- **Certificate-based signing (legacy):** each request is signed with a private key. Use `DigipostClient.withCertificateAuthentication(...)`. The chosen method is stated explicitly in the factory method you call. @@ -76,7 +76,7 @@ the client talking to the Digipost API itself are configured separately, with th `HttpClientBuilder` accepted by `DigipostClient.withJwtMtlsAuthentication(..)`. -#### Certificate-based authentication +#### Certificate-based authentication (legacy) Create a `Signer` instance, e.g. by using a `.p12` file to read the private key used to sign the API requests. From 1cb8363bfaee6dbcaa1beebe3017fc533d1f7dd3 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Wed, 9 Sep 2026 15:29:22 +0200 Subject: [PATCH 26/28] Use TlsSocketStrategy, not SslConnectionFactory SslConnectionFactory-methods are deprecated. --- .../no/digipost/api/client/internal/ApiServiceImpl.java | 6 +++--- .../api/client/security/jwt/MutualTlsTokenProvider.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index 4d188bcc..8cef385c 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -82,7 +82,7 @@ import no.digipost.http.client.HttpClientConnectionManagerFactory; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; @@ -183,9 +183,9 @@ private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder CloseableHttpClient httpClient = httpClientBuilder .setConnectionManager(HttpClientConnectionManagerFactory.createDefaultBuilder() - .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() + .setTlsSocketStrategy(ClientTlsStrategyBuilder.create() .setSslContext(tokenProvider.getSslContext()) - .build()) + .buildClassic()) .build()) .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) .addRequestInterceptorLast(new RequestUserAgentInterceptor()) 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 index a6cacff8..2387f9aa 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -24,7 +24,7 @@ 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.client5.http.ssl.ClientTlsStrategyBuilder; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.message.BasicNameValuePair; @@ -204,9 +204,9 @@ private static CloseableHttpClient buildTokenClient(JwtAuthConfig config, SSLCon return HttpClientFactory.create(config.httpClientSettings, HttpClientConnectionManagerFactory.createBuilder(config.httpClientConnectionSettings) - .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() + .setTlsSocketStrategy(ClientTlsStrategyBuilder.create() .setSslContext(sslContext) - .build()) + .buildClassic()) .build()); } From d57e4ec3bf4fa9221b8ac5632ff3dfb5221ed5da Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Thu, 10 Sep 2026 07:20:38 +0200 Subject: [PATCH 27/28] Close all http resources owned by the client ApiServiceImpl only closed its own http client. In JWT/mTLS mode the MutualTlsTokenProvider holds a separate client with its own connection pool, and nothing ever closed it. Keep the token provider as a field and close it together with the API-service. Since ApiServiceImpl is internal, its close()-method was unreachable from the public API. Therefore, we also implement AutoCloseable in the DigipostClient, which closes the API-service if, and only if, it created the API-service itself. --- .../digipost/api/client/DigipostClient.java | 20 +++++++++++-- .../api/client/internal/ApiServiceImpl.java | 28 ++++++++++++++----- .../security/jwt/MutualTlsTokenProvider.java | 3 +- .../client/internal/ApiServiceImplTest.java | 23 +++++++++++++++ 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/main/java/no/digipost/api/client/DigipostClient.java b/src/main/java/no/digipost/api/client/DigipostClient.java index d13eaa4d..c50e9715 100644 --- a/src/main/java/no/digipost/api/client/DigipostClient.java +++ b/src/main/java/no/digipost/api/client/DigipostClient.java @@ -78,7 +78,7 @@ * er opprettet med et fungerende sertifikat og tilhørende passord, kan man * gjøre søk og sende brev gjennom Digipost. */ -public class DigipostClient { +public class DigipostClient implements AutoCloseable { static { CryptoUtil.addBouncyCastleProviderAndVerify_AES256_CBC_Support(); @@ -87,6 +87,7 @@ public class DigipostClient { private static final Logger LOG = LoggerFactory.getLogger(DigipostClient.class); private final EventLogger eventLogger; + private final ApiServiceImpl ownedApiService; private final MessageDeliveryApi messageApi; private final MessageDeliverer messageSender; private final ArchiveDeliverer archiveSender; @@ -146,10 +147,18 @@ public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig conf } private DigipostClient(DigipostClientConfig config, ApiServiceImpl apiService) { - this(config, apiService, apiService, apiService, apiService, apiService, apiService, apiService); + this(config, apiService, apiService, apiService, apiService, apiService, apiService, apiService, apiService); } public DigipostClient(DigipostClientConfig config, MessageDeliveryApi apiService, InboxApi inboxApiService, DocumentApi documentApi, ArchiveApi archiveApi, BatchApi batchApi, TagApi tagApi, SharedDocumentsApi sharedDocumentsApi) { + this(config, null, apiService, inboxApiService, documentApi, archiveApi, batchApi, tagApi, sharedDocumentsApi); + } + + /** + * @param ownedApiService the api service this client created itself, and is therefore responsible for {@link ApiServiceImpl#close() closing}, or {@code null} if the api services were provided from the outside and their lifecycle is managed by the caller + */ + private DigipostClient(DigipostClientConfig config, ApiServiceImpl ownedApiService, MessageDeliveryApi apiService, InboxApi inboxApiService, DocumentApi documentApi, ArchiveApi archiveApi, BatchApi batchApi, TagApi tagApi, SharedDocumentsApi sharedDocumentsApi) { + this.ownedApiService = ownedApiService; this.messageApi = apiService; this.inboxApiService = inboxApiService; this.documentApi = documentApi; @@ -432,4 +441,11 @@ public Batch completeBatch(Batch batch) { public void cancelBatch(Batch batch) { batchApi.cancelBatch(batch); } + + @Override + public void close() { + if (ownedApiService != null) { + ownedApiService.close(); + } + } } diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index 8cef385c..228db32f 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -99,6 +99,7 @@ import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URI; import java.net.URISyntaxException; import java.time.Clock; @@ -126,13 +127,14 @@ import static no.digipost.api.client.util.JAXBContextUtils.marshal; import static no.digipost.api.client.util.JAXBContextUtils.unmarshal; -public class ApiServiceImpl implements MessageDeliveryApi, InboxApi, DocumentApi, ArchiveApi, BatchApi, TagApi, SharedDocumentsApi { +public class ApiServiceImpl implements AutoCloseable, MessageDeliveryApi, InboxApi, DocumentApi, ArchiveApi, BatchApi, TagApi, SharedDocumentsApi { private static final Logger LOG = LoggerFactory.getLogger(ApiServiceImpl.class); private static final String ENTRY_POINT = "/"; private final BrokerId brokerId; private final CloseableHttpClient httpClient; + private final MutualTlsTokenProvider tokenProvider; private final URI digipostUrl; private final Cached cached; @@ -144,19 +146,24 @@ public class ApiServiceImpl implements MessageDeliveryApi, InboxApi, DocumentApi public static ApiServiceImpl withCertificateAuthentication(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer) { requireNonNull(signer, "signer cannot be null"); - return new ApiServiceImpl(config, brokerId, apiService -> apiService.createCertificateAuthenticatingHttpClient(httpClientBuilder, signer, config)); + return new ApiServiceImpl(config, brokerId, null, apiService -> apiService.createCertificateAuthenticatingHttpClient(httpClientBuilder, signer, config)); } public static ApiServiceImpl withJwtMtlsAuthentication(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, JwtAuthConfig jwtAuthConfig) { requireNonNull(jwtAuthConfig, "jwtAuthConfig cannot be null"); - return new ApiServiceImpl(config, brokerId, apiService -> apiService.createJwtAuthenticatingHttpClient(httpClientBuilder, jwtAuthConfig, config)); + return withMutualTlsTokenProvider(config, httpClientBuilder, brokerId, new MutualTlsTokenProvider(jwtAuthConfig, brokerId, config.digipostApiUri, config.clock)); } - private ApiServiceImpl(DigipostClientConfig config, BrokerId brokerId, Function httpClientFactory) { + static ApiServiceImpl withMutualTlsTokenProvider(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, MutualTlsTokenProvider tokenProvider) { + return new ApiServiceImpl(config, brokerId, tokenProvider, apiService -> apiService.createJwtAuthenticatingHttpClient(httpClientBuilder, tokenProvider, config)); + } + + private ApiServiceImpl(DigipostClientConfig config, BrokerId brokerId, MutualTlsTokenProvider tokenProvider, Function httpClientFactory) { this.brokerId = brokerId; this.eventLogger = config.eventLogger.withDebugLogTo(LOG); this.digipostUrl = config.digipostApiUri; this.cached = new Cached(() -> fetchEntryPoint(Optional.empty())); + this.tokenProvider = tokenProvider; this.httpClient = httpClientFactory.apply(this); } @@ -177,10 +184,8 @@ private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClient return httpClient; } - private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, JwtAuthConfig jwtAuthConfig, DigipostClientConfig config) { + private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, MutualTlsTokenProvider tokenProvider, DigipostClientConfig config) { Clock clock = config.clock; - MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig, brokerId, config.digipostApiUri, clock); - CloseableHttpClient httpClient = httpClientBuilder .setConnectionManager(HttpClientConnectionManagerFactory.createDefaultBuilder() .setTlsSocketStrategy(ClientTlsStrategyBuilder.create() @@ -652,4 +657,13 @@ private ClassicHttpResponse sendDigipostMedia(Object data, String uri) { httpPost.setEntity(new ByteArrayEntity(bao.toByteArray(), ContentType.create(DIGIPOST_MEDIA_TYPE_V8))); return send(httpPost); } + + @Override + public void close() { + try (MutualTlsTokenProvider closedTokenProvider = tokenProvider) { + httpClient.close(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to close the http client used for " + digipostUrl, e); + } + } } 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 index 2387f9aa..75f62ffe 100644 --- a/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java +++ b/src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java @@ -34,7 +34,6 @@ 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; @@ -49,7 +48,7 @@ import static java.util.Objects.requireNonNull; import static no.digipost.api.client.errorhandling.ErrorCode.FAILED_TO_OBTAIN_ACCESS_TOKEN; -public class MutualTlsTokenProvider implements Closeable { +public class MutualTlsTokenProvider implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(MutualTlsTokenProvider.class); diff --git a/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java index a5aecc82..beda0dea 100644 --- a/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java +++ b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java @@ -19,6 +19,7 @@ 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; @@ -68,9 +69,31 @@ void krever_jwtAuthConfig_for_jwt_basert_autentisering() { 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(); } From 0a92a2f17800dff37573f566a8aacdc508d4c476 Mon Sep 17 00:00:00 2001 From: Fredrik Busklein Date: Thu, 10 Sep 2026 10:31:31 +0200 Subject: [PATCH 28/28] Use a no-op AutoCloseable, not a nullable field DigipostClient kept the API-service it created itself in a nullable field, where null meant "the caller provided the API-services, and owns them". A no-op AutoCloseable states the same intent without the null check, and close() no longer needs to know what it is closing. --- .../no/digipost/api/client/DigipostClient.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/java/no/digipost/api/client/DigipostClient.java b/src/main/java/no/digipost/api/client/DigipostClient.java index c50e9715..5c9cd0a4 100644 --- a/src/main/java/no/digipost/api/client/DigipostClient.java +++ b/src/main/java/no/digipost/api/client/DigipostClient.java @@ -65,6 +65,7 @@ import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URI; import java.time.ZonedDateTime; import java.util.UUID; @@ -87,7 +88,7 @@ public class DigipostClient implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(DigipostClient.class); private final EventLogger eventLogger; - private final ApiServiceImpl ownedApiService; + private final AutoCloseable closeableResources; private final MessageDeliveryApi messageApi; private final MessageDeliverer messageSender; private final ArchiveDeliverer archiveSender; @@ -151,14 +152,14 @@ private DigipostClient(DigipostClientConfig config, ApiServiceImpl apiService) { } public DigipostClient(DigipostClientConfig config, MessageDeliveryApi apiService, InboxApi inboxApiService, DocumentApi documentApi, ArchiveApi archiveApi, BatchApi batchApi, TagApi tagApi, SharedDocumentsApi sharedDocumentsApi) { - this(config, null, apiService, inboxApiService, documentApi, archiveApi, batchApi, tagApi, sharedDocumentsApi); + this(config, () -> {}, apiService, inboxApiService, documentApi, archiveApi, batchApi, tagApi, sharedDocumentsApi); } /** - * @param ownedApiService the api service this client created itself, and is therefore responsible for {@link ApiServiceImpl#close() closing}, or {@code null} if the api services were provided from the outside and their lifecycle is managed by the caller + * @param closeableResources the api service this client created itself, and is therefore responsible for {@link ApiServiceImpl#close() closing}, or a no-op if the api services were provided from the outside and their lifecycle is managed by the caller */ - private DigipostClient(DigipostClientConfig config, ApiServiceImpl ownedApiService, MessageDeliveryApi apiService, InboxApi inboxApiService, DocumentApi documentApi, ArchiveApi archiveApi, BatchApi batchApi, TagApi tagApi, SharedDocumentsApi sharedDocumentsApi) { - this.ownedApiService = ownedApiService; + private DigipostClient(DigipostClientConfig config, AutoCloseable closeableResources, MessageDeliveryApi apiService, InboxApi inboxApiService, DocumentApi documentApi, ArchiveApi archiveApi, BatchApi batchApi, TagApi tagApi, SharedDocumentsApi sharedDocumentsApi) { + this.closeableResources = closeableResources; this.messageApi = apiService; this.inboxApiService = inboxApiService; this.documentApi = documentApi; @@ -444,8 +445,10 @@ public void cancelBatch(Batch batch) { @Override public void close() { - if (ownedApiService != null) { - ownedApiService.close(); + try { + closeableResources.close(); + } catch (Exception e) { + throw new UncheckedIOException(new IOException("Failed to close resources used by DigipostClient", e)); } } }