oAuthTokenEndpointParams;
+
+ private volatile String cachedToken;
+ private volatile Instant cacheValidUntil = Instant.MIN;
+ private final Object refreshLock = new Object();
+
+ public MutualTlsTokenProvider(JwtAuthConfig config, BrokerId brokerId, URI resourceServerUri, Clock clock) {
+ this(config, brokerId, resourceServerUri, clock, null);
+ }
+
+ MutualTlsTokenProvider(JwtAuthConfig config, BrokerId brokerId, URI resourceServerUri, Clock clock, TrustManager[] trustManagers) {
+ this.config = config;
+ this.clock = clock;
+ this.tokenClient = buildTokenClient(config, buildSslContext(config, trustManagers));
+ this.oAuthTokenEndpointParams = createOAuth2TokenEndpointParams(config, brokerId, resourceServerUri);
+ }
+
+ public String getToken() {
+ if (Instant.now(clock).isBefore(cacheValidUntil)) {
+ return cachedToken;
+ }
+ synchronized (refreshLock) {
+ if (Instant.now(clock).isBefore(cacheValidUntil)) {
+ return cachedToken;
+ }
+ return fetchAndCacheToken();
+ }
+ }
+
+ /**
+ * Discards the cached access token, so that the next {@link #getToken()} fetches a new one
+ * from the token endpoint. This is meant for the case where the resource server rejects a
+ * token the client still considers valid, e.g. because it was revoked before it expired.
+ *
+ * Only the given token is discarded. Another thread may already have replaced it with a
+ * newly fetched one, and that replacement must survive the late rejection of its predecessor.
+ *
+ * @param rejectedToken the access token that was rejected
+ */
+ public void invalidate(String rejectedToken) {
+ synchronized (refreshLock) {
+ if (Objects.equals(cachedToken, rejectedToken)) {
+ cacheValidUntil = Instant.MIN;
+ LOG.debug("Discarded the cached access token from {} after it was rejected", config.tokenEndpointUri);
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ try {
+ tokenClient.close();
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to close the http client used for " + config.tokenEndpointUri, e);
+ }
+ }
+
+ private String fetchAndCacheToken() {
+ HttpPost request = new HttpPost(config.tokenEndpointUri);
+ request.setEntity(new UrlEncodedFormEntity(oAuthTokenEndpointParams, StandardCharsets.UTF_8));
+
+ try {
+ return tokenClient.execute(request, response -> {
+ int statusCode = response.getCode();
+ if (statusCode != 200) {
+ HttpEntity responseEntity = response.getEntity();
+ if (responseEntity != null) {
+ String body = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8);
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri + ": " + body);
+ } else {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint returned HTTP " + statusCode + " for " + config.tokenEndpointUri);
+ }
+ }
+
+ String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+ JsonNode tokenResponse = parseTokenResponse(responseBody);
+ String token = extractAccessToken(tokenResponse);
+ Instant expiry = resolveExpiry(token, tokenResponse);
+
+ cachedToken = token;
+ cacheValidUntil = resolveCacheValidUntil(Instant.now(clock), expiry);
+
+ LOG.debug("Fetched new access token from {}, valid until {}, cached until {}", config.tokenEndpointUri, expiry, cacheValidUntil);
+ return token;
+ });
+ } catch (IOException e) {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Failed to fetch access token from " + config.tokenEndpointUri, e);
+ }
+ }
+
+ private static JsonNode parseTokenResponse(String responseBody) {
+ try {
+ return JSON.readTree(responseBody);
+ } catch (IOException e) {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Could not parse token endpoint response as JSON", e);
+ }
+ }
+
+ private static String extractAccessToken(JsonNode tokenResponse) {
+ JsonNode accessToken = tokenResponse.get("access_token");
+ if (accessToken == null || !accessToken.isTextual() || accessToken.asText().isEmpty()) {
+ throw new DigipostClientException(FAILED_TO_OBTAIN_ACCESS_TOKEN, "Token endpoint response did not contain an 'access_token' field");
+ }
+ return accessToken.asText();
+ }
+
+ private Instant resolveExpiry(String accessToken, JsonNode tokenResponse) {
+ JsonNode expiresIn = tokenResponse.get("expires_in");
+ if (expiresIn != null && expiresIn.canConvertToLong()) {
+ return Instant.now(clock).plusSeconds(expiresIn.asLong());
+ }
+
+ try {
+ String[] parts = accessToken.split("\\.");
+ if (parts.length >= 2) {
+ JsonNode payload = JSON.readTree(Base64.getUrlDecoder().decode(parts[1]));
+ JsonNode exp = payload.get("exp");
+ if (exp != null && exp.canConvertToLong()) {
+ return Instant.ofEpochSecond(exp.asLong());
+ }
+ }
+ } catch (Exception e) {
+ LOG.warn("Could not determine token expiry; caching for {} only. Reason: {}", FALLBACK_TOKEN_LIFETIME, e.getMessage());
+ }
+
+ return Instant.now(clock).plus(FALLBACK_TOKEN_LIFETIME);
+ }
+
+ static Instant resolveCacheValidUntil(Instant now, Instant expiry) {
+ Instant refreshAt = expiry.minus(REFRESH_MARGIN);
+ Instant minimum = now.plus(MINIMUM_CACHE_TIME);
+ if (refreshAt.isAfter(minimum)) {
+ return refreshAt;
+ }
+ return minimum.isBefore(expiry) ? minimum : expiry;
+ }
+
+ private static SSLContext buildSslContext(JwtAuthConfig config, TrustManager[] trustManagers) {
+ try {
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(config.keyStore, config.keyPassword);
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagerFactory.getKeyManagers(), trustManagers, null);
+ return sslContext;
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not build SSL context from keystore for " + config.tokenEndpointUri, e);
+ }
+ }
+
+ private static CloseableHttpClient buildTokenClient(JwtAuthConfig config, SSLContext sslContext) {
+
+ return HttpClientFactory.create(config.httpClientSettings,
+ HttpClientConnectionManagerFactory.createBuilder(config.httpClientConnectionSettings)
+ .setTlsSocketStrategy(ClientTlsStrategyBuilder.create()
+ .setSslContext(sslContext)
+ .buildClassic())
+ .build());
+ }
+
+ private static List createOAuth2TokenEndpointParams(JwtAuthConfig config, BrokerId brokerId, URI resourceServerUri){
+ return Arrays.asList(
+ new BasicNameValuePair("grant_type", "client_credentials"),
+ new BasicNameValuePair("client_id", config.clientId),
+ new BasicNameValuePair("scope", "dpost-api:" + brokerId.stringValue()),
+ new BasicNameValuePair("resource", requireNonNull(resourceServerUri, "resourceServerUri cannot be null").toString())
+ );
+ }
+}
diff --git a/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java b/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java
index bcc939b3..d5e4f4b2 100644
--- a/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java
+++ b/src/main/java/no/digipost/api/client/swing/DigipostSwingClient.java
@@ -33,7 +33,7 @@
import no.digipost.api.client.representations.PrintDetails;
import no.digipost.api.client.representations.PrintRecipient;
import no.digipost.api.client.representations.SmsNotification;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import javax.swing.Box;
import javax.swing.ButtonGroup;
@@ -85,6 +85,8 @@ public class DigipostSwingClient {
private JFrame frmDigipostApiClient;
private JTextField certField;
private JPasswordField passwordField;
+ private JTextField clientIdField;
+ private JTextField tokenEndpointField;
private JTextField senderField;
private JTextField subjectField;
private JTextField recipientDigipostAddressField;
@@ -558,27 +560,26 @@ public void actionPerformed(final ActionEvent e) {
velgCertPanel.add(helpPanel, BorderLayout.NORTH);
helpPanel.setLayout(new BorderLayout(0, 0));
- JLabel steg1Label = new JLabel("Steg 1: Velg Sertifikat");
+ JLabel steg1Label = new JLabel("Steg 1: Velg klientsertifikat");
steg1Label.setFont(new Font("Dialog", Font.BOLD, 16));
helpPanel.add(steg1Label);
JLabel steg1SubLabel = new JLabel(
- "
Før du kan sende brev, må du laste inn sertifikatet som er knyttet til din virksomhets Digipost-konto."
- + " Dette må være på .p12-formatet.
Hvis dette er et Buypass-sertifikat, og du enda ikke har lastet "
- + "det opp til Digipost, kan du gjøre dette på "
- + "https://www.digipost.no/virksomhet. Les mer om dette i dokumentasjonen.");
+ "
Før du kan sende brev, må du laste inn klientsertifikatet som brukes i mTLS-handshaken mot "
+ + "token-endepunktet. Dette må være på .p12-formatet.
Klient-IDen og klientsertifikatet får du ved å "
+ + "registrere en klient i Digipost sin OAuth 2-klientautoritet. Les mer om dette i dokumentasjonen.");
helpPanel.add(steg1SubLabel, BorderLayout.SOUTH);
JPanel certPanel = new JPanel();
velgCertPanel.add(certPanel, BorderLayout.CENTER);
GridBagLayout gbl_certPanel = new GridBagLayout();
gbl_certPanel.columnWidths = new int[] { 0, 0, 0, 0 };
- gbl_certPanel.rowHeights = new int[] { 0, 0, 0, 0, 0, 0, 0 };
+ gbl_certPanel.rowHeights = new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0 };
gbl_certPanel.columnWeights = new double[] { 0.0, 1.0, 0.0, Double.MIN_VALUE };
- gbl_certPanel.rowWeights = new double[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, Double.MIN_VALUE };
+ gbl_certPanel.rowWeights = new double[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, Double.MIN_VALUE };
certPanel.setLayout(gbl_certPanel);
- JLabel certLabel = new JLabel("Sertifikatfil (.p12)");
+ JLabel certLabel = new JLabel("Klientsertifikat (.p12)");
certPanel.add(certLabel, createGridBagConstraintsForLabel(0, 0));
certField = new JTextField();
@@ -609,18 +610,32 @@ public void actionPerformed(final ActionEvent e) {
certPanel.add(passwordField, createGridBagConstraintsForField(1, 1, 1));
passwordField.setColumns(10);
+ JLabel clientIdLabel = new JLabel("Klient-ID");
+ certPanel.add(clientIdLabel, createGridBagConstraintsForLabel(0, 2));
+
+ clientIdField = new JTextField();
+ certPanel.add(clientIdField, createGridBagConstraintsForField(1, 2, 1));
+ clientIdField.setColumns(10);
+
+ JLabel tokenEndpointLabel = new JLabel("Token-endepunkt");
+ certPanel.add(tokenEndpointLabel, createGridBagConstraintsForLabel(0, 3));
+
+ tokenEndpointField = new JTextField("https://midp.digipost.no/oauth2/token");
+ certPanel.add(tokenEndpointField, createGridBagConstraintsForField(1, 3, 1));
+ tokenEndpointField.setColumns(10);
+
JLabel avsenderLabel = new JLabel("Avsenders ID");
- certPanel.add(avsenderLabel, createGridBagConstraintsForLabel(0, 2));
+ certPanel.add(avsenderLabel, createGridBagConstraintsForLabel(0, 4));
senderField = new JTextField();
- certPanel.add(senderField, createGridBagConstraintsForField(1, 2, 1));
+ certPanel.add(senderField, createGridBagConstraintsForField(1, 4, 1));
senderField.setColumns(10);
JLabel endpointLabel = new JLabel("API-endpoint URL");
- certPanel.add(endpointLabel, createGridBagConstraintsForLabel(0, 3));
+ certPanel.add(endpointLabel, createGridBagConstraintsForLabel(0, 5));
endpointField = new JTextField("https://api.digipost.no");
- certPanel.add(endpointField, createGridBagConstraintsForField(1, 3, 1));
+ certPanel.add(endpointField, createGridBagConstraintsForField(1, 5, 1));
endpointField.setColumns(10);
JButton nesteButton = new JButton("Neste");
@@ -636,15 +651,21 @@ 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())),
- Signer.usingKeyFromPKCS12KeyStore(certStream, new String(passwordField.getPassword())));
+ JwtAuthConfig jwtAuthConfig = JwtAuthConfig
+ .newConfig(clientIdField.getText())
+ .tokenEndpoint(tokenEndpointField.getText())
+ .pkcs12KeyStore(certStream, new String(passwordField.getPassword()))
+ .build();
+ client = DigipostClient.create(clientConfig, BrokerId.of(Long.parseLong(senderField.getText())),
+ jwtAuthConfig);
} catch (NumberFormatException e1) {
eventLogger.log("FEIL: Avsenders ID må være et tall > 0");
} catch (IOException e1) {
- eventLogger.log("FEIL: Klarte ikke å lese sertifikatfil:\n" + e1);
+ eventLogger.log("FEIL: Klarte ikke å lese klientsertifikatet:\n" + e1);
} catch (Exception e1) {
eventLogger.log("FEIL: Kunne ikke initialisere Digipost-API-klienten. Dette kan f.eks skyldes at"
- + " sertifikatfilen var ugyldig, eller at du skrev inn feil passord. Feilmelding var:\n" + e1.getMessage());
+ + " klientsertifikatet var ugyldig, at du skrev inn feil passord, eller at klient-IDen er ukjent."
+ + " Feilmelding var:\n" + e1.getMessage());
}
}
});
@@ -653,11 +674,11 @@ public void actionPerformed(final ActionEvent e) {
GridBagConstraints gbc_verticalStrut = new GridBagConstraints();
gbc_verticalStrut.insets = new Insets(0, 0, 5, 0);
gbc_verticalStrut.gridx = 2;
- gbc_verticalStrut.gridy = 4;
+ gbc_verticalStrut.gridy = 6;
certPanel.add(verticalStrut, gbc_verticalStrut);
GridBagConstraints gbc_nesteButton = new GridBagConstraints();
gbc_nesteButton.gridx = 2;
- gbc_nesteButton.gridy = 5;
+ gbc_nesteButton.gridy = 7;
certPanel.add(nesteButton, gbc_nesteButton);
CardLayout l = (CardLayout) contentPane.getLayout();
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..ba9608ae 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/AddTagEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/AddTagEksempel.java
@@ -21,7 +21,7 @@
import no.digipost.api.client.representations.PersonalIdentificationNumber;
import no.digipost.api.client.representations.accounts.PublicMailboxTag;
import no.digipost.api.client.representations.accounts.Tag;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -33,20 +33,26 @@ public class AddTagEksempel {
// Din virksomhets Digipost-kontoid
private static final BrokerId AVSENDERS_KONTOID = BrokerId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, jwtAuthConfig);
// 3. Vi oppretter et fødselsnummerobjekt
PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787");
@@ -61,13 +67,13 @@ public static void main(final String[] args) throws IOException {
client.addTag(tag);
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
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..14cc513d 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ArkiverDokumenterEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ArkiverDokumenterEksempel.java
@@ -20,7 +20,7 @@
import no.digipost.api.client.SenderId;
import no.digipost.api.client.representations.archive.Archive;
import no.digipost.api.client.representations.archive.ArchiveDocument;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -35,21 +35,27 @@ public class ArkiverDokumenterEksempel {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
- AVSENDERS_KONTOID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 3. Vi beskriver to dokumenter du ønsker å arkivere i ditt arkiv.
// Merk at det settes et slettetidspunkt på vedleggsdokumentet, men ikke fakturadokumentet.
@@ -86,13 +92,13 @@ private static InputStream readFileFromDisk(String filename) {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..5063324c 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/AutocompleteEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/AutocompleteEksempel.java
@@ -19,7 +19,7 @@
import no.digipost.api.client.DigipostClientConfig;
import no.digipost.api.client.SenderId;
import no.digipost.api.client.representations.Suggestion;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -37,21 +37,27 @@ public class AutocompleteEksempel {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
- AVSENDERS_KONTOID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 3. Vi ber om forslag til autofullføring
List suggestions = client.getAutocompleteSuggestions("Gunn").getSuggestions();
@@ -63,13 +69,13 @@ private static InputStream getMessageContent() {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..985beba7 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/BatchSendMessagesEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/BatchSendMessagesEksempel.java
@@ -23,12 +23,7 @@
import no.digipost.api.client.representations.Message;
import no.digipost.api.client.representations.SmsNotification;
import no.digipost.api.client.representations.batch.Batch;
-import no.digipost.api.client.security.Signer;
-import org.apache.hc.client5.http.config.ConnectionConfig;
-import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
-import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
-import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
-import org.apache.hc.core5.util.TimeValue;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -47,29 +42,28 @@ public class BatchSendMessagesEksempel {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- ConnectionConfig config = ConnectionConfig.custom()
- .setTimeToLive(TimeValue.ofMinutes(2))
- .build();
- DigipostClient client;
- try (PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
- .setDefaultConnectionConfig(config)
- .build()) {
- client = new DigipostClient(DigipostClientConfig.newConfiguration().digipostApiUri(URI.create("http://localhost:8282")).build(),
- AVSENDERS_KONTOID.asBrokerId(), signer, HttpClientBuilder.create().setConnectionManager(connectionManager));
- }
+ DigipostClient client = DigipostClient.create(
+ DigipostClientConfig.newConfiguration().digipostApiUri(URI.create("http://localhost:8282")).build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 3. Vi må ha en unik id for batchen som skal gå gjennom helle prosessen. Lag deg en og ta var på den!
final UUID batchUUID = UUID.randomUUID();
@@ -116,13 +110,13 @@ private static InputStream readFileFromDisk(String filename) {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..e8132247 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/FallbackTilPrintEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/FallbackTilPrintEksempel.java
@@ -26,7 +26,7 @@
import no.digipost.api.client.representations.PrintDetails;
import no.digipost.api.client.representations.PrintRecipient;
import no.digipost.api.client.representations.SmsNotification;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import org.apache.commons.io.FileUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
@@ -50,7 +50,10 @@ public class FallbackTilPrintEksempel {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
@@ -59,16 +62,19 @@ public static void main(final String[] args) throws IOException {
// BouncyCastle
Security.addProvider(new BouncyCastleProvider());
- // 2. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 2. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 3. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
- AVSENDERS_KONTOID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 4. Vi oppretter et fødselsnummerobjekt som skal brukes til å
// identifisere mottaker i Digipost
@@ -109,13 +115,13 @@ private static InputStream getPrintContent() {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet selv med Apache Commons FileUtils.
- return FileUtils.openInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet selv med Apache Commons FileUtils.
+ return FileUtils.openInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (IOException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil");
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet");
}
}
}
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..f2d9b4fa 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempel.java
@@ -22,7 +22,7 @@
import no.digipost.api.client.representations.Message;
import no.digipost.api.client.representations.PersonalIdentificationNumber;
import no.digipost.api.client.representations.SmsNotification;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -43,21 +43,27 @@ public class ForsendelseEksempel {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
- AVSENDERS_KONTOID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 3. Vi oppretter et fødselsnummerobjekt
PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787");
@@ -85,13 +91,13 @@ private static InputStream getPrimaryDocumentContent() {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..a9e315f6 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelDigipostadresse.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelDigipostadresse.java
@@ -22,7 +22,7 @@
import no.digipost.api.client.representations.Document;
import no.digipost.api.client.representations.Message;
import no.digipost.api.client.representations.SmsNotification;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -43,21 +43,27 @@ public class ForsendelseEksempelDigipostadresse {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
- AVSENDERS_KONTOID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 3. Vi oppretter et digipostadresseobjekt
DigipostAddress address = new DigipostAddress("fornavn.etternavn#6789");
@@ -82,13 +88,13 @@ private static InputStream getMessageContent() {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..7c34387e 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelNavnogAdresse.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/ForsendelseEksempelNavnogAdresse.java
@@ -22,7 +22,7 @@
import no.digipost.api.client.representations.Message;
import no.digipost.api.client.representations.NameAndAddress;
import no.digipost.api.client.representations.SmsNotification;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -43,21 +43,27 @@ public class ForsendelseEksempelNavnogAdresse {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
- AVSENDERS_KONTOID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 3. Vi oppretter et nameandaddress-objekt
NameAndAddress nameAndAddress = new NameAndAddress("Ola Nordmann", "Gateveien 1", "Oppgang B", "0001", "Oslo");
@@ -82,13 +88,13 @@ private static InputStream getMessageContent() {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..18794603 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesArchiveExamples.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesArchiveExamples.java
@@ -22,13 +22,13 @@
import no.digipost.api.client.representations.archive.ArchiveDocument;
import no.digipost.api.client.representations.archive.ArchiveDocumentContent;
import no.digipost.api.client.representations.archive.Archives;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Paths;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.Period;
@@ -44,13 +44,19 @@ public class GithubPagesArchiveExamples {
private DigipostClient client;
- public void set_up_client() throws FileNotFoundException {
+ public void set_up_client() throws IOException {
SenderId senderId = SenderId.of(10987);
- DigipostClient client = new DigipostClient(
- DigipostClientConfig.newConfiguration().build(),
- senderId.asBrokerId(),
- Signer.usingKeyFromPKCS12KeyStore(new FileInputStream("certificate.p12"), "TheSecretPassword"));
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("client-cert.p12"))) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig("your-client-id")
+ .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword")
+ .build();
+ }
+
+ DigipostClient client = DigipostClient.create(
+ DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), jwtAuthConfig);
}
public void get_list_of_archives() throws IOException {
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..c68ca015 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesReceiveExamples.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesReceiveExamples.java
@@ -20,25 +20,31 @@
import no.digipost.api.client.SenderId;
import no.digipost.api.client.representations.inbox.Inbox;
import no.digipost.api.client.representations.inbox.InboxDocument;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
@SuppressWarnings("unused")
public class GithubPagesReceiveExamples {
private DigipostClient client;
- public void set_up_client() throws FileNotFoundException {
+ public void set_up_client() throws IOException {
SenderId senderId = SenderId.of(10987);
- DigipostClient client = new DigipostClient(
- DigipostClientConfig.newConfiguration().build(),
- senderId.asBrokerId(),
- Signer.usingKeyFromPKCS12KeyStore(new FileInputStream("certificate.p12"), "TheSecretPassword"));
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("client-cert.p12"))) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig("your-client-id")
+ .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword")
+ .build();
+ }
+
+ DigipostClient client = DigipostClient.create(
+ DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), jwtAuthConfig);
}
public void get_documents_in_inbox() throws IOException {
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..fb3308fe 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesSendExamples.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/GithubPagesSendExamples.java
@@ -34,7 +34,7 @@
import no.digipost.api.client.representations.PrintRecipient;
import no.digipost.api.client.representations.SensitivityLevel;
import no.digipost.api.client.representations.SmsNotification;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import no.digipost.api.datatypes.types.Address;
import no.digipost.api.datatypes.types.Appointment;
import no.digipost.api.datatypes.types.ExternalLink;
@@ -62,20 +62,23 @@ public class GithubPagesSendExamples {
private static final UUID UUID2 = UUID.randomUUID();
private static final UUID UUID3 = UUID.randomUUID();
private static final UUID UUID4 = UUID.randomUUID();
- private static final String CERTIFICATE_PASSWORD = "passord";
+ private static final String CLIENT_CERTIFICATE_PASSWORD = "passord";
private DigipostClient client;
public void set_up_client() throws IOException {
SenderId senderId = SenderId.of(123456);
- Signer signer;
- try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("certificate.p12"))) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, "TheSecretPassword");
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("client-cert.p12"))) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig("your-client-id")
+ .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword")
+ .build();
}
- DigipostClient client = new DigipostClient(
- DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(
+ DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), jwtAuthConfig);
}
public void send_one_letter_to_recipient_via_personal_identification_number() throws IOException {
@@ -241,12 +244,15 @@ public void send_letter_through_norsk_helsenett() throws IOException {
// API URL is different when request is sent from NHN
DigipostClientConfig config = DigipostClientConfig.newConfiguration().digipostApiUri(URI.create("https://api.nhn.digipost.no")).build();
- Signer signer;
- try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("certificate.p12"))) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, CERTIFICATE_PASSWORD);
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("client-cert.p12"))) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig("your-client-id")
+ .pkcs12KeyStore(sertifikatInputStream, CLIENT_CERTIFICATE_PASSWORD)
+ .build();
}
- DigipostClient client = new DigipostClient(config, SENDER_ID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(config, SENDER_ID.asBrokerId(), jwtAuthConfig);
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..a754544f 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/PeppolEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/PeppolEksempel.java
@@ -22,7 +22,7 @@
import no.digipost.api.client.representations.FileType;
import no.digipost.api.client.representations.Message;
import no.digipost.api.client.representations.PeppolAddresses;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -42,21 +42,27 @@ public class PeppolEksempel {
// Din virksomhets Digipost-kontoid
private static final SenderId AVSENDERS_KONTOID = SenderId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(),
- AVSENDERS_KONTOID.asBrokerId(), signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(),
+ AVSENDERS_KONTOID.asBrokerId(), jwtAuthConfig);
// 3. Vi oppretter et fødselsnummerobjekt
PeppolAddresses pa = new PeppolAddresses("9908:810418052", "9908:810418052");
@@ -83,13 +89,13 @@ private static InputStream getPrimaryDocumentContent() {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..42be87e1 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/SokEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/SokEksempel.java
@@ -19,7 +19,7 @@
import no.digipost.api.client.DigipostClient;
import no.digipost.api.client.DigipostClientConfig;
import no.digipost.api.client.representations.Recipient;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -37,33 +37,39 @@ public class SokEksempel {
// Din virksomhets Digipost-kontoid
private static final BrokerId AVSENDERS_KONTOID = BrokerId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, jwtAuthConfig);
// 3. Vi søker etter personer med matchende navn eller adresse
List recipients = client.search("Ole Nilsen Stavanger").getRecipients();
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
}
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..b037a6e2 100644
--- a/src/test/java/no/digipost/api/client/eksempelkode/VedleggEksempel.java
+++ b/src/test/java/no/digipost/api/client/eksempelkode/VedleggEksempel.java
@@ -22,7 +22,7 @@
import no.digipost.api.client.representations.Message;
import no.digipost.api.client.representations.PersonalIdentificationNumber;
import no.digipost.api.client.representations.SmsNotification;
-import no.digipost.api.client.security.Signer;
+import no.digipost.api.client.security.jwt.JwtAuthConfig;
import java.io.File;
import java.io.FileInputStream;
@@ -40,20 +40,26 @@ public class VedleggEksempel {
// Din virksomhets Digipost-kontoid
private static final BrokerId AVSENDERS_KONTOID = BrokerId.of(10987);
- // Passordet sertifikatfilen er beskyttet med
+ // Klient-IDen du fikk da du registrerte klienten hos Digipost
+ private static final String KLIENT_ID = "din-klient-id";
+
+ // Passordet klientsertifikatet er beskyttet med
private static final String SERTIFIKAT_PASSORD = "SertifikatPassord123";
public static void main(final String[] args) throws IOException {
- // 1. Vi lager en Signer ved å lese inn sertifikatet du har knyttet til
- // din Digipost-konto (i .p12-formatet)
- Signer signer;
- try (InputStream sertifikatInputStream = lesInnSertifikat()) {
- signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD);
+ // 1. Vi setter opp autentiseringen ved å lese inn klientsertifikatet
+ // (i .p12-formatet) som brukes i mTLS-handshaken mot token-endepunktet
+ JwtAuthConfig jwtAuthConfig;
+ try (InputStream sertifikatInputStream = lesInnKlientsertifikat()) {
+ jwtAuthConfig = JwtAuthConfig
+ .newConfig(KLIENT_ID)
+ .pkcs12KeyStore(sertifikatInputStream, SERTIFIKAT_PASSORD)
+ .build();
}
// 2. Vi oppretter en DigipostClient
- DigipostClient client = new DigipostClient(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, signer);
+ DigipostClient client = DigipostClient.create(DigipostClientConfig.newConfiguration().build(), AVSENDERS_KONTOID, jwtAuthConfig);
// 3. Vi oppretter et fødselsnummerobjekt
PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787");
@@ -87,13 +93,13 @@ private static InputStream getAttachmentContent() {
return null;
}
- private static InputStream lesInnSertifikat() {
+ private static InputStream lesInnKlientsertifikat() {
try {
- // Leser inn sertifikatet
- return new FileInputStream(new File("/path/til/sertifikatfil.p12"));
+ // Leser inn klientsertifikatet
+ return new FileInputStream(new File("/path/til/klientsertifikat.p12"));
} catch (FileNotFoundException e) {
- // Håndter at sertifikatet ikke kunne leses!
- throw new RuntimeException("Kunne ikke lese sertifikatfil: " + e.getMessage(), e);
+ // Håndter at klientsertifikatet ikke kunne leses!
+ throw new RuntimeException("Kunne ikke lese klientsertifikatet: " + e.getMessage(), e);
}
}
diff --git a/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java
new file mode 100644
index 00000000..8a33a2ba
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/ApiServiceImplTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.jwt.JwtAuthConfig;
+import no.digipost.api.client.security.jwt.MutualTlsTokenProvider;
+import no.digipost.http.client.HttpClientFactory;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+
+import static no.digipost.api.client.DigipostClientConfig.newConfiguration;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class ApiServiceImplTest {
+
+ private static final BrokerId BROKER_ID = BrokerId.of(1234);
+ private static final String P12_RESOURCE = "/no/digipost/api/client/security/jwt/client-cert.p12";
+ private static final String P12_PASSWORD = "qwer1234";
+
+ @Test
+ void bygger_jwt_autentiserende_klient() {
+ DigipostClientConfig config = newConfiguration().build();
+
+ assertDoesNotThrow(() ->
+ ApiServiceImpl.create(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, jwtAuthConfig()));
+ }
+
+ @Test
+ void krever_jwtAuthConfig_for_jwt_basert_autentisering() {
+ DigipostClientConfig config = newConfiguration().build();
+
+ assertThrows(NullPointerException.class, () ->
+ ApiServiceImpl.create(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, null));
+ }
+
+ @Test
+ void krever_token_provider() {
+ DigipostClientConfig config = newConfiguration().build();
+
+ assertThrows(NullPointerException.class, () ->
+ ApiServiceImpl.withMutualTlsTokenProvider(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");
+ }
+
+ private static JwtAuthConfig jwtAuthConfig() {
+ return JwtAuthConfig
+ .newConfig("test-client")
+ // ingen skal svare her: testene under skal aldri komme så langt som til å gjøre et kall
+ .tokenEndpoint("https://localhost:1/oauth2/token")
+ .pkcs12KeyStore(p12Stream(), P12_PASSWORD)
+ .build();
+ }
+
+ private static InputStream p12Stream() {
+ InputStream stream = ApiServiceImplTest.class.getResourceAsStream(P12_RESOURCE);
+ if (stream == null) {
+ throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE);
+ }
+ return stream;
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/DigipostApiStub.java b/src/test/java/no/digipost/api/client/internal/DigipostApiStub.java
new file mode 100644
index 00000000..6c04d8e9
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/DigipostApiStub.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal;
+
+import com.sun.net.httpserver.HttpServer;
+import org.apache.hc.core5.http.HttpHeaders;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.security.MessageDigest;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Collections.unmodifiableList;
+import static no.digipost.api.client.internal.http.Headers.X_Content_SHA256;
+import static no.digipost.api.client.util.JAXBContextUtils.jaxbContext;
+import static no.digipost.api.client.util.JAXBContextUtils.marshal;
+
+/**
+ * A local HTTP server standing in for the Digipost API, answering each request with the next of
+ * the responses it is stubbed with, and recording the {@code Authorization} header of every
+ * request it received.
+ *
+ * It speaks plain HTTP: the JWT client only presents its client certificate towards the token
+ * endpoint, and its TLS configuration is of no consequence to the requests tested here.
+ */
+final class DigipostApiStub implements Closeable {
+
+ private final HttpServer server;
+ private final URI uri;
+
+ private final List receivedAuthorizationHeaders = new ArrayList<>();
+ private volatile List responses = List.of();
+
+ DigipostApiStub() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ StubbedResponse response;
+ synchronized (receivedAuthorizationHeaders) {
+ receivedAuthorizationHeaders.add(exchange.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION));
+ List stubbed = responses;
+ response = stubbed.get(Math.min(receivedAuthorizationHeaders.size() - 1, stubbed.size() - 1));
+ }
+ exchange.getRequestBody().readAllBytes();
+
+ if (response.digipostHeaders) {
+ // The Digipost API dates and hashes its responses, and the client rejects responses lacking it.
+ exchange.getResponseHeaders().set(HttpHeaders.DATE, DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC)));
+ exchange.getResponseHeaders().set(X_Content_SHA256, sha256Base64(response.body));
+ }
+ exchange.sendResponseHeaders(response.status, response.body.length == 0 ? -1 : response.body.length);
+ exchange.getResponseBody().write(response.body);
+ exchange.close();
+ });
+ server.start();
+
+ this.uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
+ }
+
+ URI uri() {
+ return uri;
+ }
+
+ /** Answer the requests with these responses in order, repeating the last one if more requests arrive. */
+ void respondWith(StubbedResponse... responses) {
+ this.responses = List.of(responses);
+ }
+
+ List receivedAuthorizationHeaders() {
+ synchronized (receivedAuthorizationHeaders) {
+ return unmodifiableList(new ArrayList<>(receivedAuthorizationHeaders));
+ }
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ /** A response from the Digipost application itself, carrying the headers it dates and hashes its responses with. */
+ static StubbedResponse marshalled(int status, Object representation) {
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ marshal(jaxbContext, representation, body);
+ return new StubbedResponse(status, body.toByteArray(), true);
+ }
+
+ /**
+ * A response carrying none of the headers the Digipost application would have added. This is
+ * what a request rejected before it reaches the application, e.g. by a gateway refusing its
+ * access token, looks like.
+ */
+ static StubbedResponse withoutDigipostHeaders(int status, String body) {
+ return new StubbedResponse(status, body.getBytes(UTF_8), false);
+ }
+
+ static final class StubbedResponse {
+ final int status;
+ final byte[] body;
+ final boolean digipostHeaders;
+
+ private StubbedResponse(int status, byte[] body, boolean digipostHeaders) {
+ this.status = status;
+ this.body = body;
+ this.digipostHeaders = digipostHeaders;
+ }
+ }
+
+ private static String sha256Base64(byte[] content) {
+ try {
+ return Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(content));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/UnauthorizedRetryTest.java b/src/test/java/no/digipost/api/client/internal/UnauthorizedRetryTest.java
new file mode 100644
index 00000000..d9bcbb8d
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/UnauthorizedRetryTest.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal;
+
+import no.digipost.api.client.BrokerId;
+import no.digipost.api.client.DigipostClientConfig;
+import no.digipost.api.client.errorhandling.DigipostClientException;
+import no.digipost.api.client.errorhandling.ErrorCode;
+import no.digipost.api.client.representations.DigipostUri;
+import no.digipost.api.client.representations.EntryPoint;
+import no.digipost.api.client.representations.ErrorMessage;
+import no.digipost.api.client.representations.ErrorType;
+import no.digipost.api.client.representations.Link;
+import no.digipost.api.client.representations.Relation;
+import no.digipost.api.client.security.jwt.MutualTlsTokenProvider;
+import no.digipost.http.client.HttpClientFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static no.digipost.api.client.DigipostClientConfig.newConfiguration;
+import static org.apache.hc.core5.http.HttpStatus.SC_OK;
+import static org.apache.hc.core5.http.HttpStatus.SC_UNAUTHORIZED;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies that the JWT-authenticating client reacts to a rejected access token the way it is
+ * supposed to: by discarding the token, fetching a new one and sending the request once more.
+ *
+ * This goes through the fully wired client, and not just the individual pieces, because what it
+ * needs to establish is that they are placed correctly relative to each other in the execution
+ * chain, i.e. that the retry re-runs the request interceptors, and that the response
+ * verifications do not fail the 401 before the retry gets to see it.
+ */
+public class UnauthorizedRetryTest {
+
+ private static final BrokerId BROKER_ID = BrokerId.of(1234);
+
+ private DigipostApiStub digipostApi;
+ private MutualTlsTokenProvider tokenProvider;
+ private ApiServiceImpl apiService;
+
+ @BeforeEach
+ void startApiAndBuildClient() throws Exception {
+ digipostApi = new DigipostApiStub();
+ tokenProvider = mock(MutualTlsTokenProvider.class);
+
+ DigipostClientConfig config = newConfiguration().digipostApiUri(digipostApi.uri()).build();
+ apiService = ApiServiceImpl.withMutualTlsTokenProvider(config, HttpClientFactory.createDefaultBuilder(), BROKER_ID, tokenProvider);
+ }
+
+ @AfterEach
+ void closeClientAndStopApi() {
+ if (apiService != null) {
+ apiService.close();
+ }
+ if (digipostApi != null) {
+ digipostApi.close();
+ }
+ }
+
+ @Test
+ void henter_nytt_token_og_sender_requesten_paa_nytt_naar_det_forrige_blir_avvist() {
+ when(tokenProvider.getToken()).thenReturn("rejected-token", "fresh-token");
+ digipostApi.respondWith(unauthorizedByGateway(), entryPoint());
+
+ assertThat(apiService.getEntryPoint().getCertificate(), is("the-certificate"));
+
+ verify(tokenProvider).invalidate("rejected-token");
+ assertThat(digipostApi.receivedAuthorizationHeaders(), contains("Bearer rejected-token", "Bearer fresh-token"));
+ }
+
+ @Test
+ void gir_opp_naar_ogsaa_det_nye_tokenet_blir_avvist() {
+ when(tokenProvider.getToken()).thenReturn("rejected-token", "also-rejected-token");
+ digipostApi.respondWith(unauthorized(), unauthorized());
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> apiService.getEntryPoint());
+
+ assertThat("den faktiske feilen fra apiet skal nå fram, ikke en signaturfeil på det usignerte 401-svaret",
+ thrown.getErrorCode(), is(ErrorCode.UNKNOWN_USER_ID));
+ assertThat("requesten skal sendes én gang til, ikke i det uendelige",
+ digipostApi.receivedAuthorizationHeaders(), contains("Bearer rejected-token", "Bearer also-rejected-token"));
+ }
+
+ @Test
+ void roerer_ikke_tokenet_naar_apiet_svarer_som_normalt() {
+ when(tokenProvider.getToken()).thenReturn("the-token");
+ digipostApi.respondWith(entryPoint());
+
+ apiService.getEntryPoint();
+
+ verify(tokenProvider, never()).invalidate(org.mockito.ArgumentMatchers.anyString());
+ assertThat(digipostApi.receivedAuthorizationHeaders(), contains("Bearer the-token"));
+ }
+
+ /**
+ * A 401 from in front of the Digipost application, i.e. one that is neither dated, hashed
+ * nor signed the way the client expects a response from the application itself to be.
+ */
+ private static DigipostApiStub.StubbedResponse unauthorizedByGateway() {
+ return DigipostApiStub.withoutDigipostHeaders(SC_UNAUTHORIZED, "{\"error\":\"invalid_token\"}");
+ }
+
+ private static DigipostApiStub.StubbedResponse unauthorized() {
+ return DigipostApiStub.marshalled(SC_UNAUTHORIZED, new ErrorMessage(ErrorType.CLIENT_TECHNICAL, "UNKNOWN_USER_ID", "Unknown access token"));
+ }
+
+ private static DigipostApiStub.StubbedResponse entryPoint() {
+ return DigipostApiStub.marshalled(SC_OK, new EntryPoint("the-certificate",
+ new Link(Relation.SEARCH, new DigipostUri("/recipients/search"))));
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/RefreshAccessTokenOnUnauthorizedExecTest.java b/src/test/java/no/digipost/api/client/internal/http/RefreshAccessTokenOnUnauthorizedExecTest.java
new file mode 100644
index 00000000..6fca72ee
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/RefreshAccessTokenOnUnauthorizedExecTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal.http;
+
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.classic.ExecChain;
+import org.apache.hc.client5.http.classic.ExecRuntime;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.entity.InputStreamEntity;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor.ATTEMPTED_ACCESS_TOKEN;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+
+public class RefreshAccessTokenOnUnauthorizedExecTest {
+
+ private final List invalidatedTokens = new ArrayList<>();
+ private final RefreshAccessTokenOnUnauthorizedExec exec = new RefreshAccessTokenOnUnauthorizedExec(invalidatedTokens::add);
+
+ @Test
+ void sender_requesten_paa_nytt_naar_tokenet_blir_avvist() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(get(), scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_OK));
+ assertThat(chain.receivedRequestCount(), is(2));
+ assertThat(invalidatedTokens, contains("rejected-token"));
+ }
+
+ @Test
+ void sender_requesten_paa_nytt_bare_en_gang() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_UNAUTHORIZED);
+
+ ClassicHttpResponse response = exec.execute(get(), scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_UNAUTHORIZED));
+ assertThat(chain.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void roerer_ikke_svar_som_ikke_er_401() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_FORBIDDEN, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(get(), scopeWithAttemptedToken("the-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_FORBIDDEN));
+ assertThat(chain.receivedRequestCount(), is(1));
+ assertThat(invalidatedTokens, is(empty()));
+ }
+
+ @Test
+ void gjoer_ingenting_naar_requesten_ikke_ble_sendt_med_et_token() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(get(), scope(HttpClientContext.create()), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_UNAUTHORIZED));
+ assertThat(chain.receivedRequestCount(), is(1));
+ assertThat(invalidatedTokens, is(empty()));
+ }
+
+ @Test
+ void sender_ikke_innhold_som_ikke_kan_sendes_paa_nytt() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+ ClassicHttpRequest post = post(new InputStreamEntity(new ByteArrayInputStream("content".getBytes(UTF_8)), 7, null));
+
+ ClassicHttpResponse response = exec.execute(post, scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_UNAUTHORIZED));
+ assertThat(chain.receivedRequestCount(), is(1));
+ assertThat("tokenet skal ikke kastes når vi likevel ikke kan prøve på nytt", invalidatedTokens, is(empty()));
+ }
+
+ @Test
+ void sender_innhold_som_kan_sendes_paa_nytt() throws Exception {
+ RespondingChain chain = new RespondingChain(HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_OK);
+
+ ClassicHttpResponse response = exec.execute(post(new StringEntity("content", UTF_8)), scopeWithAttemptedToken("rejected-token"), chain);
+
+ assertThat(response.getCode(), is(HttpStatus.SC_OK));
+ assertThat(chain.receivedRequestCount(), is(2));
+ }
+
+ private static ClassicHttpRequest get() {
+ return new HttpGet("https://api.digipost.no/");
+ }
+
+ private static ClassicHttpRequest post(HttpEntity entity) {
+ HttpPost post = new HttpPost("https://api.digipost.no/");
+ post.setEntity(entity);
+ return post;
+ }
+
+ private static ExecChain.Scope scopeWithAttemptedToken(String token) {
+ HttpClientContext context = HttpClientContext.create();
+ context.setAttribute(ATTEMPTED_ACCESS_TOKEN, token);
+ return scope(context);
+ }
+
+ private static ExecChain.Scope scope(HttpClientContext context) {
+ return new ExecChain.Scope("test-exchange", new HttpRoute(new HttpHost("https", "api.digipost.no", 443)), get(), mock(ExecRuntime.class), context);
+ }
+
+
+ /**
+ * Answers each request with the next of the given statuses, keeping the last one once they
+ * are exhausted, and records the requests it was asked to send.
+ */
+ private static final class RespondingChain implements ExecChain {
+
+ private final List statuses;
+ private final List receivedRequests = new ArrayList<>();
+
+ RespondingChain(Integer... statuses) {
+ this.statuses = List.of(statuses);
+ }
+
+ @Override
+ public ClassicHttpResponse proceed(ClassicHttpRequest request, Scope scope) {
+ int status = statuses.get(Math.min(receivedRequests.size(), statuses.size() - 1));
+ receivedRequests.add(request);
+ return new BasicClassicHttpResponse(status);
+ }
+
+ int receivedRequestCount() {
+ return receivedRequests.size();
+ }
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/request/interceptor/ApacheHttpRequestToSignTest.java b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/ApacheHttpRequestToSignTest.java
deleted file mode 100644
index a18e43e8..00000000
--- a/src/test/java/no/digipost/api/client/internal/http/request/interceptor/ApacheHttpRequestToSignTest.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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.message.BasicClassicHttpRequest;
-import org.junit.jupiter.api.Test;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.is;
-
-public class ApacheHttpRequestToSignTest {
-
- @Test
- public void getPathReturnsEncodedPath() {
- BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "https://api.digipost.no/api/documents/%C3%86%20%C3%98/send");
- assertThat(new ApacheHttpRequestToSign(request).getPath(), is("/api/documents/%C3%86%20%C3%98/send"));
- }
-
- @Test
- public void getPathLeavesPlainAsciiPathUnchanged() {
- BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "https://api.digipost.no/api/documents/send");
- assertThat(new ApacheHttpRequestToSign(request).getPath(), is("/api/documents/send"));
- }
-
- @Test
- public void testStandardQuery(){
- String s = ApacheHttpRequestToSign.queryParametersFromURI("http://www.idontknowwhatifeel.com?query=1&query=2");
- assertThat(s, is("query=1&query=2"));
- }
-
- @Test
- public void testStandardNonQuery(){
- String s = ApacheHttpRequestToSign.queryParametersFromURI("http://www.idontknowwhatifeel.com");
- assertThat(s, is(""));
- }
-
- @Test
- public void testNonStandardNonQuery(){
- String s = ApacheHttpRequestToSign.queryParametersFromURI("http://www.idontknowwhatifeel.com?");
- assertThat(s, is(""));
- }
-
-}
diff --git a/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.java b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.java
new file mode 100644
index 00000000..59df185f
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptorTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal.http.request.interceptor;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor.ATTEMPTED_ACCESS_TOKEN;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class RequestBearerTokenInterceptorTest {
+
+ @Test
+ public void setter_authorization_headeren_med_bearer_prefiks() {
+ HttpGet request = new HttpGet("https://api.digipost.no/");
+
+ new RequestBearerTokenInterceptor(() -> "the-token").process(request, null, new BasicHttpContext());
+
+ assertThat(request.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer the-token"));
+ }
+
+ @Test
+ public void henter_tokenet_paa_nytt_for_hvert_request() {
+ List tokens = new ArrayList<>(List.of("first-token", "second-token"));
+ RequestBearerTokenInterceptor interceptor = new RequestBearerTokenInterceptor(() -> tokens.remove(0));
+
+ HttpGet first = new HttpGet("https://api.digipost.no/");
+ HttpGet second = new HttpGet("https://api.digipost.no/");
+ interceptor.process(first, null, new BasicHttpContext());
+ interceptor.process(second, null, new BasicHttpContext());
+
+ assertThat(first.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer first-token"));
+ assertThat(second.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer second-token"));
+ }
+
+ @Test
+ public void legger_tokenet_i_konteksten_saa_det_kan_invalideres_om_det_blir_avvist() {
+ BasicHttpContext context = new BasicHttpContext();
+
+ new RequestBearerTokenInterceptor(() -> "the-token").process(new HttpGet("https://api.digipost.no/"), null, context);
+
+ assertThat((String) context.getAttribute(ATTEMPTED_ACCESS_TOKEN), is("the-token"));
+ }
+
+ @Test
+ public void erstatter_en_eksisterende_authorization_header() {
+ HttpGet request = new HttpGet("https://api.digipost.no/");
+ request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer stale-token");
+
+ new RequestBearerTokenInterceptor(() -> "fresh-token").process(request, null, new BasicHttpContext());
+
+ assertThat(request.getHeaders(HttpHeaders.AUTHORIZATION).length, is(1));
+ assertThat(request.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue(), is("Bearer fresh-token"));
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptorTest.java b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptorTest.java
new file mode 100644
index 00000000..18038b93
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptorTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal.http.request.interceptor;
+
+import no.digipost.api.client.internal.http.Headers;
+import no.digipost.api.client.security.Digester;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+public class RequestContentHashInterceptorTest {
+
+ private final RequestContentHashInterceptor interceptor =
+ new RequestContentHashInterceptor(Digester.sha256, Headers.X_Content_SHA256);
+
+ @Test
+ public void setter_sha256_header_beregnet_over_request_body() throws IOException, NoSuchAlgorithmException {
+ byte[] body = "digipost".getBytes(StandardCharsets.UTF_8);
+ HttpPost request = new HttpPost("https://api.digipost.no/");
+ request.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_OCTET_STREAM));
+
+ interceptor.process(request, null, new BasicHttpContext());
+
+ String expected = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(body));
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256), notNullValue());
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256).getValue(), is(expected));
+ }
+
+ @Test
+ public void setter_hash_over_tom_body() throws IOException, NoSuchAlgorithmException {
+ HttpPost request = new HttpPost("https://api.digipost.no/");
+ request.setEntity(new ByteArrayEntity(new byte[0], ContentType.APPLICATION_OCTET_STREAM));
+
+ interceptor.process(request, null, new BasicHttpContext());
+
+ String expected = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(new byte[0]));
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256).getValue(), is(expected));
+ }
+
+ @Test
+ public void setter_ingen_header_naar_request_ikke_har_body() throws IOException {
+ HttpGet request = new HttpGet("https://api.digipost.no/");
+
+ interceptor.process(request, null, new BasicHttpContext());
+
+ assertThat(request.getFirstHeader(Headers.X_Content_SHA256), nullValue());
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/internal/http/response/interceptor/VerifyUnlessUnauthorizedTest.java b/src/test/java/no/digipost/api/client/internal/http/response/interceptor/VerifyUnlessUnauthorizedTest.java
new file mode 100644
index 00000000..a97a6ef2
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/internal/http/response/interceptor/VerifyUnlessUnauthorizedTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.internal.http.response.interceptor;
+
+import org.apache.hc.core5.http.HttpResponseInterceptor;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static no.digipost.api.client.internal.http.response.interceptor.VerifyUnlessUnauthorized.unlessUnauthorized;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.is;
+
+public class VerifyUnlessUnauthorizedTest {
+
+ private final List verifiedResponses = new ArrayList<>();
+ private final HttpResponseInterceptor verification =
+ unlessUnauthorized((response, entityDetails, context) -> verifiedResponses.add(response.getCode()));
+
+ @Test
+ void verifiserer_vanlige_svar() throws Exception {
+ verification.process(new BasicClassicHttpResponse(HttpStatus.SC_OK), null, new BasicHttpContext());
+ verification.process(new BasicClassicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR), null, new BasicHttpContext());
+
+ assertThat(verifiedResponses, contains(HttpStatus.SC_OK, HttpStatus.SC_INTERNAL_SERVER_ERROR));
+ }
+
+ @Test
+ void verifiserer_ikke_svar_om_at_tokenet_ble_avvist() throws Exception {
+ verification.process(new BasicClassicHttpResponse(HttpStatus.SC_UNAUTHORIZED), null, new BasicHttpContext());
+
+ assertThat(verifiedResponses, is(empty()));
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/CryptoUtilTest.java b/src/test/java/no/digipost/api/client/security/CryptoUtilTest.java
deleted file mode 100644
index f36ac5b7..00000000
--- a/src/test/java/no/digipost/api/client/security/CryptoUtilTest.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * 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;
-
-import org.junit.jupiter.api.Test;
-
-import java.security.PrivateKey;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.notNullValue;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-public class CryptoUtilTest {
-
- @Test
- public void shouldLoadPrivateKeyFromPKCS12File() {
- final PrivateKey privateKey = CryptoUtil.loadKeyFromP12(getClass().getResourceAsStream("certificate.p12"), "Qwer12345");
- assertThat(privateKey, notNullValue());
- }
-
- @Test
- public void shouldThrowRuntimeExceptionWhenBadPassword() {
- assertThrows(RuntimeException.class, () -> CryptoUtil.loadKeyFromP12(getClass().getResourceAsStream("certificate.p12"), ""));
- }
-}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderCacheTest.java b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderCacheTest.java
new file mode 100644
index 00000000..bba37772
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderCacheTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static java.time.temporal.ChronoUnit.SECONDS;
+import static no.digipost.api.client.security.jwt.MutualTlsTokenProvider.resolveCacheValidUntil;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.is;
+
+public class MutualTlsTokenProviderCacheTest {
+
+ private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
+
+ @Test
+ public void refresher_tokenet_kort_foer_det_utloeper() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(300, SECONDS)), is(NOW.plus(270, SECONDS)));
+ }
+
+ @Test
+ public void cacher_kortlevde_tokens_i_stedet_for_aa_hente_nytt_per_request() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(10, SECONDS)), is(NOW.plus(5, SECONDS)));
+ }
+
+ @Test
+ public void cacher_aldri_lenger_enn_tokenet_er_gyldig() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(3, SECONDS)), is(NOW.plus(3, SECONDS)));
+ }
+
+ @Test
+ public void cacher_alltid_i_et_positivt_tidsrom() {
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(31, SECONDS)), greaterThan(NOW));
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(30, SECONDS)), greaterThan(NOW));
+ assertThat(resolveCacheValidUntil(NOW, NOW.plus(1, SECONDS)), greaterThan(NOW));
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java
new file mode 100644
index 00000000..437f113c
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/MutualTlsTokenProviderTest.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import no.digipost.api.client.BrokerId;
+import no.digipost.api.client.errorhandling.DigipostClientException;
+import no.digipost.http.client.HttpClientConnectionSettings;
+import no.digipost.http.client.HttpClientSettings;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.InputStream;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import static java.time.temporal.ChronoUnit.SECONDS;
+import static no.digipost.api.client.errorhandling.ErrorCode.FAILED_TO_OBTAIN_ACCESS_TOKEN;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class MutualTlsTokenProviderTest {
+
+ private static final String P12_RESOURCE = "client-cert.p12";
+ private static final String P12_PASSWORD = "qwer1234";
+ private static final String CLIENT_ID = "test-client";
+ private static final BrokerId BROKER_ID = BrokerId.of(1234);
+ private static final URI RESOURCE_SERVER_URI = URI.create("https://api.digipost.no");
+ private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
+
+ private TokenEndpointStub tokenEndpoint;
+ private SettableClock clock;
+ private final List tokenProviders = new ArrayList<>();
+
+ @BeforeEach
+ void startTokenEndpoint() throws Exception {
+ tokenEndpoint = new TokenEndpointStub();
+ clock = new SettableClock(NOW);
+ }
+
+ @AfterEach
+ void closeTokenProvidersAndStopTokenEndpoint() {
+ tokenProviders.forEach(MutualTlsTokenProvider::close);
+ tokenProviders.clear();
+ if (tokenEndpoint != null) {
+ tokenEndpoint.close();
+ }
+ }
+
+ @Test
+ void henter_token_og_presenterer_klientsertifikatet_i_handshaken() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+
+ assertThat(tokenProvider().getToken(), is("the-token"));
+
+ Certificate[] presented = tokenEndpoint.certificatesPresentedByClient();
+ assertThat("mIdP mottok ingen klientsertifikat – klienten presenterte ingenting i handshaken", presented, notNullValue());
+ assertThat(presented[0], instanceOf(X509Certificate.class));
+ assertThat(((X509Certificate) presented[0]).getSubjectX500Principal().getName(), containsString("sertifikat-TEST"));
+ }
+
+ @Test
+ void sender_client_credentials_parametrene_til_token_endepunktet() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+
+ tokenProvider().getToken();
+
+ assertThat(parameter("grant_type"), is("client_credentials"));
+ assertThat(parameter("client_id"), is(CLIENT_ID));
+ assertThat(parameter("scope"), is("dpost-api:1234"));
+ assertThat(parameter("resource"), is(RESOURCE_SERVER_URI.toString()));
+ }
+
+ @Test
+ void cacher_tokenet_mellom_kall() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ clock.advance(Duration.ofSeconds(100));
+
+ assertThat(tokenProvider.getToken(), is("the-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(1));
+ }
+
+ @Test
+ void henter_nytt_token_naar_det_forrige_naermer_seg_utloep() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"first-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ clock.advance(Duration.ofSeconds(280));
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"second-token\",\"expires_in\":300}");
+
+ assertThat(tokenProvider.getToken(), is("second-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void henter_nytt_token_naar_det_forrige_er_invalidert() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"first-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"second-token\",\"expires_in\":300}");
+ tokenProvider.invalidate("first-token");
+
+ assertThat(tokenProvider.getToken(), is("second-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void beholder_tokenet_naar_et_annet_blir_invalidert() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ tokenProvider.invalidate("a-token-already-replaced-by-the-cached-one");
+
+ assertThat(tokenProvider.getToken(), is("the-token"));
+ assertThat(tokenEndpoint.receivedRequestCount(), is(1));
+ }
+
+ @Test
+ void bruker_exp_fra_tokenet_naar_expires_in_mangler() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"" + jwtExpiringAt(NOW.plus(300, SECONDS)) + "\"}");
+ MutualTlsTokenProvider tokenProvider = tokenProvider();
+
+ tokenProvider.getToken();
+ clock.advance(Duration.ofSeconds(100));
+ tokenProvider.getToken();
+ assertThat("tokenet er gyldig i 300s, så det skal fortsatt være cachet", tokenEndpoint.receivedRequestCount(), is(1));
+
+ clock.advance(Duration.ofSeconds(180));
+ tokenProvider.getToken();
+ assertThat(tokenEndpoint.receivedRequestCount(), is(2));
+ }
+
+ @Test
+ void feil_fra_token_endepunktet_gir_DigipostClientException() throws Exception {
+ tokenEndpoint.respondWith(503, "{\"error\":\"temporarily_unavailable\"}");
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider().getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat(thrown.getMessage(), containsString("503"));
+ }
+
+ /**
+ * Statuskoder som ikke kan ha en responsbody gir ingen {@link HttpEntity} å lese
+ * feilmeldingen fra, og {@link EntityUtils#toString(HttpEntity, java.nio.charset.Charset)}
+ * kaster {@link NullPointerException} hvis den blir kalt med en null-entity.
+ */
+ @ParameterizedTest
+ @ValueSource(ints = { 204, 304 })
+ void feil_uten_responsbody_gir_DigipostClientException_og_ikke_NullPointerException(int statusUtenBody) throws Exception {
+ tokenEndpoint.respondWithoutBody(statusUtenBody);
+
+ Exception thrown = assertThrows(Exception.class, () -> tokenProvider().getToken());
+
+ assertThat("EntityUtils.toString(..) ble kalt med responsens null-entity", thrown, not(instanceOf(NullPointerException.class)));
+ assertThat(thrown, instanceOf(DigipostClientException.class));
+
+ DigipostClientException clientException = (DigipostClientException) thrown;
+ assertThat(clientException.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat(clientException.getMessage(), containsString(String.valueOf(statusUtenBody)));
+ assertThat(clientException.getMessage(), containsString(tokenEndpoint.tokenEndpointUri().toString()));
+ assertThat("feilmeldingen skal ikke antyde at det fulgte med en body", clientException.getMessage(), not(containsString("null")));
+ }
+
+ @Test
+ void svar_som_ikke_er_json_gir_DigipostClientException() throws Exception {
+ tokenEndpoint.respondWith(200, "not json");
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider().getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ }
+
+ @Test
+ void svar_uten_access_token_gir_DigipostClientException() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"expires_in\":300}");
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider().getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat(thrown.getMessage(), containsString("access_token"));
+ }
+
+ @Test
+ void bruker_timeoutene_som_er_konfigurert_for_token_klienten() throws Exception {
+ tokenEndpoint.respondWith(200, "{\"access_token\":\"the-token\",\"expires_in\":300}");
+ tokenEndpoint.delayResponsesBy(Duration.ofSeconds(2));
+
+ JwtAuthConfig config = configBuilder()
+ .tokenEndpointHttpSettings(HttpClientSettings.DEFAULT, HttpClientConnectionSettings.DEFAULT.socketTimeout(200))
+ .build();
+
+ DigipostClientException thrown = assertThrows(DigipostClientException.class, () -> tokenProvider(config).getToken());
+
+ assertThat(thrown.getErrorCode(), is(FAILED_TO_OBTAIN_ACCESS_TOKEN));
+ assertThat("token-klienten ventet lenger enn den konfigurerte socket-timeouten", thrown.getCause(), instanceOf(SocketTimeoutException.class));
+ }
+
+ private MutualTlsTokenProvider tokenProvider() throws Exception {
+ return tokenProvider(configBuilder().build());
+ }
+
+ private MutualTlsTokenProvider tokenProvider(JwtAuthConfig config) throws Exception {
+ MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(config, BROKER_ID, RESOURCE_SERVER_URI, clock, tokenEndpoint.trustManagers());
+ tokenProviders.add(tokenProvider);
+ return tokenProvider;
+ }
+
+ private JwtAuthConfig.Builder configBuilder() {
+ return JwtAuthConfig
+ .newConfig(CLIENT_ID)
+ .tokenEndpoint(tokenEndpoint.tokenEndpointUri().toString())
+ .pkcs12KeyStore(p12Stream(), P12_PASSWORD);
+ }
+
+ private String parameter(String name) {
+ List form = tokenEndpoint.lastReceivedForm();
+ return form.stream()
+ .filter(parameter -> parameter.getName().equals(name))
+ .map(NameValuePair::getValue)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Parameteren '" + name + "' ble ikke sendt. Mottok: " + form));
+ }
+
+ private static String jwtExpiringAt(Instant expiry) {
+ Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
+ String header = encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8));
+ String payload = encoder.encodeToString(("{\"exp\":" + expiry.getEpochSecond() + "}").getBytes(StandardCharsets.UTF_8));
+ return header + "." + payload + ".signature";
+ }
+
+ private static InputStream p12Stream() {
+ InputStream stream = MutualTlsTokenProviderTest.class.getResourceAsStream(P12_RESOURCE);
+ if (stream == null) {
+ throw new IllegalStateException("Mangler testressurs " + P12_RESOURCE);
+ }
+ return stream;
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/SettableClock.java b/src/test/java/no/digipost/api/client/security/jwt/SettableClock.java
new file mode 100644
index 00000000..3c15aae1
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/SettableClock.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+
+final class SettableClock extends Clock {
+
+ private volatile Instant now;
+
+ SettableClock(Instant now) {
+ this.now = now;
+ }
+
+ void advance(Duration duration) {
+ now = now.plus(duration);
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java b/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java
new file mode 100644
index 00000000..89226ae5
--- /dev/null
+++ b/src/test/java/no/digipost/api/client/security/jwt/TokenEndpointStub.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) Posten Bring AS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package no.digipost.api.client.security.jwt;
+
+import com.sun.net.httpserver.HttpsConfigurator;
+import com.sun.net.httpserver.HttpsExchange;
+import com.sun.net.httpserver.HttpsParameters;
+import com.sun.net.httpserver.HttpsServer;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.net.WWWFormCodec;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+import java.io.Closeable;
+import java.math.BigInteger;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * A local HTTPS server standing in for the OAuth 2.0 token endpoint, presenting a
+ * generated certificate valid for 127.0.0.1 and requesting a client certificate.
+ */
+final class TokenEndpointStub implements Closeable {
+
+ private final HttpsServer server;
+ private final X509Certificate serverCertificate;
+ private final URI tokenEndpointUri;
+
+ private final List> receivedForms = new ArrayList<>();
+ private final AtomicReference certificatesPresentedByClient = new AtomicReference<>();
+
+ private volatile int responseStatus = 200;
+ private volatile String responseBody = "{}";
+ private volatile Duration responseDelay = Duration.ZERO;
+
+ TokenEndpointStub() throws Exception {
+ KeyPair keyPair = generateKeyPair();
+ this.serverCertificate = selfSignedCertificateFor(keyPair);
+
+ server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ SSLContext serverContext = serverSslContext(keyPair, serverCertificate);
+ server.setHttpsConfigurator(new HttpsConfigurator(serverContext) {
+ @Override
+ public void configure(HttpsParameters params) {
+ SSLParameters sslParameters = serverContext.getDefaultSSLParameters();
+ // TLS 1.3 defers client authentication past the handshake, which would leave
+ // getPeerCertificates() empty in the handler below.
+ sslParameters.setProtocols(new String[]{ "TLSv1.2" });
+ sslParameters.setWantClientAuth(true);
+ params.setSSLParameters(sslParameters);
+ }
+ });
+ server.createContext("/token", exchange -> {
+ try {
+ certificatesPresentedByClient.set(((HttpsExchange) exchange).getSSLSession().getPeerCertificates());
+ } catch (SSLPeerUnverifiedException e) {
+ certificatesPresentedByClient.set(null);
+ }
+ String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ synchronized (receivedForms) {
+ receivedForms.add(WWWFormCodec.parse(form, StandardCharsets.UTF_8));
+ }
+
+ sleep(responseDelay);
+
+ String body = responseBody;
+ if (body == null) {
+ exchange.sendResponseHeaders(responseStatus, -1);
+ } else {
+ byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(responseStatus, bodyBytes.length);
+ exchange.getResponseBody().write(bodyBytes);
+ }
+ exchange.close();
+ });
+ server.start();
+
+ this.tokenEndpointUri = URI.create("https://127.0.0.1:" + server.getAddress().getPort() + "/token");
+ }
+
+ URI tokenEndpointUri() {
+ return tokenEndpointUri;
+ }
+
+ void respondWith(int status, String body) {
+ this.responseStatus = status;
+ this.responseBody = body;
+ }
+
+ /** Wait the given duration before responding, e.g. to provoke a socket timeout in the client. */
+ void delayResponsesBy(Duration delay) {
+ this.responseDelay = delay;
+ }
+
+ /** Respond with the given status and no response body at all, i.e. not even an empty one. */
+ void respondWithoutBody(int status) {
+ this.responseStatus = status;
+ this.responseBody = null;
+ }
+
+ int receivedRequestCount() {
+ synchronized (receivedForms) {
+ return receivedForms.size();
+ }
+ }
+
+ List lastReceivedForm() {
+ synchronized (receivedForms) {
+ return receivedForms.get(receivedForms.size() - 1);
+ }
+ }
+
+ Certificate[] certificatesPresentedByClient() {
+ return certificatesPresentedByClient.get();
+ }
+
+ /** Trust managers accepting this stub's certificate, in place of the JVM default trust store. */
+ TrustManager[] trustManagers() throws Exception {
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ trustStore.setCertificateEntry("token-endpoint", serverCertificate);
+
+ TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init(trustStore);
+ return trustManagerFactory.getTrustManagers();
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ private static void sleep(Duration duration) {
+ if (duration.isZero() || duration.isNegative()) {
+ return;
+ }
+ try {
+ Thread.sleep(duration.toMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static SSLContext serverSslContext(KeyPair keyPair, X509Certificate certificate) throws Exception {
+ char[] password = "token-endpoint-stub".toCharArray();
+
+ KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ keyStore.load(null, null);
+ keyStore.setKeyEntry("token-endpoint", keyPair.getPrivate(), password, new Certificate[]{ certificate });
+
+ KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(keyStore, password);
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagerFactory.getKeyManagers(), anyClientCertificate(), null);
+ return sslContext;
+ }
+
+ private static KeyPair generateKeyPair() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ }
+
+ private static X509Certificate selfSignedCertificateFor(KeyPair keyPair) throws Exception {
+ X500Name subject = new X500Name("CN=token-endpoint-stub");
+ Date notBefore = new Date(System.currentTimeMillis() - 86400_000);
+ Date notAfter = new Date(System.currentTimeMillis() + 86400_000);
+
+ JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
+ subject, BigInteger.ONE, notBefore, notAfter, subject, keyPair.getPublic());
+ builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
+ builder.addExtension(Extension.subjectAlternativeName, false,
+ new GeneralNames(new GeneralName(GeneralName.iPAddress, "127.0.0.1")));
+
+ return new JcaX509CertificateConverter().getCertificate(
+ builder.build(new JcaContentSignerBuilder("SHA256WithRSA").build(keyPair.getPrivate())));
+ }
+
+ private static TrustManager[] anyClientCertificate() {
+ return new TrustManager[]{ new X509TrustManager() {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) { }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) { }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ } };
+ }
+}
diff --git a/src/test/resources/no/digipost/api/client/security/certificate.p12 b/src/test/resources/no/digipost/api/client/security/certificate.p12
deleted file mode 100644
index d7454356..00000000
Binary files a/src/test/resources/no/digipost/api/client/security/certificate.p12 and /dev/null differ
diff --git a/src/test/resources/no/digipost/api/client/security/jwt/client-cert.p12 b/src/test/resources/no/digipost/api/client/security/jwt/client-cert.p12
new file mode 100644
index 00000000..84eb6363
Binary files /dev/null and b/src/test/resources/no/digipost/api/client/security/jwt/client-cert.p12 differ