diff --git a/docs/_config.yml b/docs/_config.yml index 590e168d..71c8022f 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -8,8 +8,8 @@ licenseUrl: http://www.apache.org/licenses/LICENSE-2.0 baseurl: "/digipost-api-client-java" -currentVersion: "16.x" -versions: ["16.x", "15.x", "13.x", "11.x", "10.x", "9.0", "8.0"] +currentVersion: "19.x" +versions: ["19.x", "16.x", "15.x", "13.x", "11.x", "10.x", "9.0", "8.0"] deprecatedVersions: ["8.0", "9.0", "10.x", "11.x", "13.x"] collections: @@ -34,6 +34,9 @@ collections: v16_x: output: true permalink: /v16.x/ + v19_x: + output: true + permalink: /v19.x/ # list of additional links on the right of the top menu headerLinks: diff --git a/docs/_v10_x/index.html b/docs/_v10_x/index.html index 7416013c..20f1f8de 100644 --- a/docs/_v10_x/index.html +++ b/docs/_v10_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v11_x/index.html b/docs/_v11_x/index.html index 4bc2a660..84e043fc 100644 --- a/docs/_v11_x/index.html +++ b/docs/_v11_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v13_x/index.html b/docs/_v13_x/index.html index fdaf342f..fae900df 100644 --- a/docs/_v13_x/index.html +++ b/docs/_v13_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v15_x/index.html b/docs/_v15_x/index.html index 8ad93408..1b6a09aa 100644 --- a/docs/_v15_x/index.html +++ b/docs/_v15_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v16_x/index.html b/docs/_v16_x/index.html index fef277ee..ab86afe0 100644 --- a/docs/_v16_x/index.html +++ b/docs/_v16_x/index.html @@ -1,7 +1,6 @@ --- identifier: index layout: default -redirect_from: / --- diff --git a/docs/_v19_x/1_client_config.md b/docs/_v19_x/1_client_config.md new file mode 100644 index 00000000..7b8e171c --- /dev/null +++ b/docs/_v19_x/1_client_config.md @@ -0,0 +1,129 @@ +--- +title: Instantiate and configure the client +identifier: client_config +layout: default +--- + +### Install + +The client library is available on the [Maven Central Repository](https://central.sonatype.com/artifact/no.digipost/digipost-api-client-java). +Copy the ``-snippet from that website and put it in your pom.xml file. +Make sure to use the latest version available. + +This client requires Java 11 and `jakarta.xml-bind`. + +### Configure for production use + +To instantiate the client instance you need to supply your assigned _broker ID_, which +is set up to be permitted to integrate with the Digipost API. In addition, you must choose +an authentication method. The client supports two: + +- **OAuth 2.0 over mutual TLS (JWT/mTLS):** the client obtains access tokens over an + mTLS-secured channel and sends them as bearer tokens. Use + `DigipostClient.withJwtMtlsAuthentication(...)`. +- **Certificate-based signing (legacy):** each request is signed with a private key. Use + `DigipostClient.withCertificateAuthentication(...)`. + +The chosen method is stated explicitly in the factory method you call. + + +#### JWT/mTLS authentication + +Before you can use the Digipost API using JWT/mTLS, you must register a client with the +[Digipost OAuth 2 client authority (Nyva)](https://nyva.digipost.no). Contact the sales team at Digipost to get access to +the client authority and register your client. More information can be found in the [Digipost API Documentation](https://digipost.github.io/digipost-technical-docs/). + +Configure a `JwtAuthConfig` with your client ID and the client certificate (as a `.p12` +keystore) used for the mutual-TLS handshake against the token endpoint. The token endpoint +defaults to the production one, so it only has to be set for other environments. + +```java +SenderId senderId = SenderId.of(123456); + +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.withJwtMtlsAuthentication( + DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), jwtAuthConfig); +``` + +Access tokens are fetched lazily on first use and cached until shortly before they expire. +They are requested for the API given by `DigipostClientConfig.digipostApiUri`, so you do +not configure the API URI in two places. + +The access tokens are fetched with a separate HTTP client, as it has to present the client +certificate configured above in the TLS handshake against the token endpoint. Its timeouts +(and proxy, connection pool, ...) can be configured with `tokenEndpointHttpSettings(..)`: + +```java +JwtAuthConfig jwtAuthConfig = JwtAuthConfig + .newConfig("your-client-id") + .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword") + .tokenEndpointHttpSettings( + HttpClientSettings.DEFAULT.timeouts(HttpClientDefaults.DEFAULT_TIMEOUTS_MS.connect(2000).connectionRequest(1000)), + HttpClientConnectionSettings.DEFAULT.socketTimeout(5000)) + .build(); +``` + +Both parameters have sensible defaults, so pass `HttpClientSettings.DEFAULT` or +`HttpClientConnectionSettings.DEFAULT` for the one you do not need to change. The timeouts of +the client talking to the Digipost API itself are configured separately, with the +`HttpClientBuilder` accepted by `DigipostClient.withJwtMtlsAuthentication(..)`. + + +#### Certificate-based authentication (legacy) + +Create a `Signer` instance, e.g. by using a `.p12` file to read the private key used to +sign the API requests. + +```java +SenderId senderId = SenderId.of(123456); + +Signer signer; +try (InputStream sertifikatInputStream = Files.newInputStream(Paths.get("certificate.p12"))) { + signer = Signer.usingKeyFromPKCS12KeyStore(sertifikatInputStream, "TheSecretPassword"); +} + +DigipostClient client = DigipostClient.withCertificateAuthentication( + DigipostClientConfig.newConfiguration().build(), senderId.asBrokerId(), signer); +``` + +This example will configure the client to communicate with the regular Digipost production +environment. + +### Other environments + +If you have access to other environments, this can be configured using +`DigipostClientConfig`, e.g: + +```java +URI apiUri = URI.create("https://api.test.digipost.no"); +DigipostClientConfig config = DigipostClientConfig.newConfiguration().digipostApiUri(apiUri).build(); +``` + +When using JWT/mTLS, also point `JwtAuthConfig` at the token endpoint of that environment: + +```java +JwtAuthConfig jwtAuthConfig = JwtAuthConfig + .newConfig("your-client-id") + .tokenEndpoint("https://midp.test.digipost.no/oauth2/token") + .pkcs12KeyStore(sertifikatInputStream, "TheSecretPassword") + .build(); +``` + +#### Norsk Helsenett (NHN) + +The Digipost API is accessible from both internet and Norsk Helsenett (NHN). Both entry points use +the same API, the only difference is the base URL. + +```java +URI nhnApiUri = URI.create("https://api.nhn.digipost.no"); +DigipostClientConfig config = DigipostClientConfig.newConfiguration().digipostApiUri(nhnApiUri).build(); +``` + + diff --git a/docs/_v19_x/2_send.md b/docs/_v19_x/2_send.md new file mode 100644 index 00000000..5b27f3c4 --- /dev/null +++ b/docs/_v19_x/2_send.md @@ -0,0 +1,467 @@ +--- +title: Send messages +identifier: send +layout: default +--- + +The Java client library also contains some +[example code](https://github.com/digipost/digipost-api-client-java/tree/master/src/test/java/no/digipost/api/client/eksempelkode) +which include similar examples. + +## Send a message to a recipient + +To send a message to a recipient in Digipost, you need to choose a way to identify +the recipient, instantiate a primary `Document` and the containing `Message`. Finally +these are given to the client as well as the content of the document as an `InputStream`. +The actual API communication will happen when you invoke the `.send()` method. + +### Send using a personal identification number for the recipient + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID documentUuid = UUID.randomUUID(); +Document primaryDocument = new Document(documentUuid, "Document subject", FileType.PDF); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +### Other recipient types + +There are other recipient types available to identify recipients of messages. Note that +some recipient types may require special permissions to be set up in order to be used. +E.g. bank account number requires such permissions, and are _not_ enabled by default. + +```java +NameAndAddress nameAndAddress = new NameAndAddress("Ola Nordmann", "Gateveien 1", "Oppgang B", "0001", "Oslo"); +``` + +```java +BankAccountNumber accountNum = new BankAccountNumber("12345123451"); +``` + +### Multiple documents in one message + +A message is required to have at least one document, the _primary_ document. Additional +documents can also be included as _attachments_. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF); + +Document attachment1 = new Document(UUID2, "Attachment1 subject", FileType.PDF); +Document attachment2 = new Document(UUID3, "Attachment2 subject", FileType.PDF); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .attachments(attachment1, attachment2) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("main_document_content.pdf"))) + .addContent(attachment1, Files.newInputStream(Paths.get("attachment1_content.pdf"))) + .addContent(attachment2, Files.newInputStream(Paths.get("attachment2_content.pdf"))) + .send(); +``` +## Send invoice + +```java + +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +//Previous versions of the client uses what is called an Invoice Document. With the release of v15 this has been +//removed. Now we use digipost data types instead. +Document invoice = new Document( + UUID1 + , "Invoice subject" + , FileType.PDF + , new Invoice(null, ZonedDateTime.of(2022, 5, 5, 0, 0, 0, 0, ZoneId.of("Europe/Oslo")), new BigDecimal("1.20"), "704279604", "82760100435") +); + +Message message = Message.newMessage("messageId", invoice) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(invoice, Files.newInputStream(Paths.get("invoice.pdf"))) + .send(); + + +``` + +## Send a message with SMS notification + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +// The time the SMS is sent out can be based on time after letter is delivered +// or a specific date. This example specifies that the SMS should be sent out +// one day after the letter i delivered. +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF, null, + new SmsNotification(1), null, + AuthenticationLevel.PASSWORD, SensitivityLevel.NORMAL); + +Message message = Message.newMessage(UUID2, primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + + +## Send letter with fallback to print + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF); + +PrintDetails printDetails = new PrintDetails( + new PrintRecipient("Ola Nordmann", new NorwegianAddress("Prinsensveien 123", "0460", "Oslo")), + new PrintRecipient("Norgesbedriften", new NorwegianAddress("Akers Àle 2", "0400", "Oslo")), + PrintDetails.PrintColors.MONOCHROME, PrintDetails.NondeliverableHandling.RETURN_TO_SENDER); + +Message message = Message.newMessage(UUID2, primaryDocument) + .recipient(new MessageRecipient(pin, printDetails)) + .build(); + +// addContent can also take a third parameter which is the file/ipnput stream that will be used only +// for physical mail. The below example uses the same file/input stream in both channels (digital and physical mail) +MessageDelivery result = client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +## Send letter with html + +If you want to be able to send HTML-documents you first need to contact Digipost to activate +the feature for your broker/sender. Then it is just matter of specifing HTML as the filetype +and serve an html-file as content. +Bevare that there are strict rules to what is allowed. These rules are quite verbose. But +we have open sourced the html validator and santizer software we use to make sure that +html conforms to these rules. Check out [https://github.com/digipost/digipost-html-validator](digipost-html-validator). +If you preencrypt your document, this validation will be performed in the client instead of the +server so that you can be confident that you recipient will be able to open the document. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID documentUuid = UUID.randomUUID(); +Document primaryDocument = new Document(documentUuid, "Document subject", FileType.HTML); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.html"))) + .send(); +``` + + +## Send letter with higher security level + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +// TWO_FACTOR - require BankID or BuyPass authentication to open letter +// SENSITIVE - Sender information and subject will be hidden until Digipost user +// is logged in at the appropriate authentication level +Document primaryDocument = new Document(UUID1, "Document subject", FileType.PDF, null, null, null, + AuthenticationLevel.TWO_FACTOR, SensitivityLevel.SENSITIVE); + +Message message = Message.newMessage(UUID2, primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream("content.pdf"))) + .send(); +``` + +## Send a message with extra computer readable data + +With version 7 of the Digipost API, messages can have extra bits of computer readable information that +allows the creation of a customized, dynamic user experience for messages in Digipost. These extra bits of +information are referred to as instances of "Datatypes". + +All datatypes are sent in the same way. Each document can accommodate one datatype-object. An exhaustive list of +available datatypes and their documentation can be found at +[digipost/digipost-data-types](https://github.com/digipost/digipost-data-types). + +For convenience, all datatypes are available as java-classes in the java client library. + +### Datatype Appointment + +In this example, an appointment-datatype that allows for certain calendar-related functions is added to a +message. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID messageUUID = UUID.randomUUID(); + +ZonedDateTime startTime = ZonedDateTime.of(2017, 10, 23, 10, 0, 0, 0, ZoneId.systemDefault()); +AppointmentAddress address = new AppointmentAddress("Storgata 1", "0001", "Oslo"); +Info preparation = new Info("Preparation", "Please do not eat or drink 6 hours prior to examination"); +Info about = new Info("About Oslo X-Ray center", "Oslo X-Ray center is specialized in advanced image diagnostics..."); +List info = Arrays.asList(preparation, about); + +Appointment appointment = new Appointment( + startTime, startTime.plusMinutes(30), "Please arrive 15 minutes early", + "Oslo X-Ray center", address, "Lower back examination", info, Language.EN); + +Document primaryDocument = new Document(messageUUID, "X-Ray appointment", FileType.PDF, appointment); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +### Datatype ExternalLink + +This Datatype enhances a message in Digipost with a button which sends the user to an external site. The button +can optionally have a deadline, a description and a custom text. + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID messageUUID = UUID.randomUUID(); + +URI externalLinkTarget = URI.create("https://example.org/loan-offer/uniqueCustomerId/"); +ZonedDateTime deadline = ZonedDateTime.of(2018, 10, 23, 10, 0, 0, 0, ZoneId.systemDefault()); + +ExternalLink externalLink = new ExternalLink(externalLinkTarget, deadline, + "Please read the terms, and use the button above to accept them. The offer expires at 23/10-2018 10:00.", + "Accept offer"); + +Document primaryDocument = new Document(messageUUID, "Housing loan application", FileType.PDF, externalLink); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream("terms.pdf"))) + .send(); +``` + +### Datatype ShareDocumentsRequest + +This datatype enables sharing of documents between an organisation and a Digipost end user. The organisation +first sends a message of datatype ShareDocumentsRequest, to which the end user can attach a list of documents. When +new documents are shared, a DocumentEvent is generated. The organisation can retrieve the status of their +ShareDocumentsRequest. If documents are shared and the sharing is not cancelled, the documents can either be downloaded +or viewed on the digipostdata.no domain. Active requests can be cancelled both by the end user and the organisation. + +The `purpose` attribute of the ShareDocumentsRequest should briefly explain why the sender organisation want to gain +access to the relevant documents. This text will be displayed prominently, and should contain the information necessary +for the user to make an informed choice. The primary document should contain a more detailed explanation. + +#### Send ShareDocumentsRequest +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID messageUUID = UUID.randomUUID(); + +ShareDocumentsRequest shareDocumentsRequest = new ShareDocumentsRequest( + Duration.ofDays(60).toSeconds(), + "We require to see your six latest pay slips in order to give you a loan." +); + +Document primaryDocument = new Document(messageUUID, "Request to access your latest payslips", FileType.PDF, shareDocumentsRequest); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .addContent(primaryDocument, Files.newInputStream(Path.of("longer-desc-of-sharing-purpose.pdf"))) + .send(); +``` + +#### Discover new shared documents +The sender organisation can discover new shared documents by polling document events regularly. Use the `uuid` attribute +of the DocumentEvent to match with the `messageUUID` of the origin ShareDocumentsRequest: + +```java +List sharedDocumentEvents = digipostClient.getDocumentEvents(brokerId.asSenderId(), ZonedDateTime.now().minus(Duration.ofDays(1)), ZonedDateTime.now(), 0, 100); + .getEvents() + .stream() + .filter(event -> DocumentEventType.SHARE_DOCUMENTS_REQUEST_DOCUMENTS_SHARED.equals(event.getType())) + .toList() +``` + +NB: events are attached to the broker, _not_ each individual sender. + +#### Get state of ShareDocumentsRequest + +```java +ShareDocumentsRequestState sharedDocumentsRequestState = sendClient.getShareDocumentsRequestState(senderId, uuid); +``` + +#### Get documents + +Each `SharedDocument` has attributes describing the document and its origin. If `SharedDocumentOrigin` is of type +`OrganisationOrigin`, the corresponding document was received by the end user through Digipost from the organisation +with the provided organisation number. If the origin is of type `PrivatePersonOrigin`, the document was received either +from another end user or uploaded by the user itself. + +Get a single document as stream: + +```java +SharedDocument doc1 = sharedDocumentsRequestState.getSharedDocuments().get(0); +InputStream inputStream = sendClient.getSharedDocumentContentStream(doc1.getSharedDocumentContentStream()); +``` + +Get link to view a single document on digipostdata.no + +```java +SharedDocumentContent sharedDocumentContent = sendClient.getSharedDocumentContent(doc1.getSharedDocumentContent()); +String uri = sharedDocumentContent.getUri(); +``` + +#### Stop sharing + +```java +client.stopSharing(senderId, sharedDocumentsRequestState.stopSharing()) +``` + + + +## Send message with request for registration + +It is possible to send a message to a person, who does not have a Digipost account, where the message triggers an SMS notification with a request for registration. The SMS notification says that if they register for a Digipost account the document will be delivered digitally. The actual content of the SMS notification is set manually by Digipost. If the user does not register for a Digipost account within the defined deadline, the document will either be delivered as physical mail or not at all. + +The phone number provided SHOULD include the country code (i.e. +47). If the phone number does not start with either `"+"`, `"00"` or `"011"`, we will prepend `"+47"` if and only if the phone number string is 8 characters long. If this is not the case, the request is rejected. + +### Request for registration with physical mail as fallback + +In this case the document will be delivered as physical mail if the recipient has not registered for a Digipost account by the defined deadline. + +```java +UUID documentId = UUID.randomUUID(); +Document document = new Document(documentId, "Hello!", FileType.PDF); + +PrintDetails printDetails = new PrintDetails(RECIPIENT, RETURN_RECIPIENT); + +RequestForRegistration requestForRegistration = new RequestForRegistration( +// Deadline for when the recipent can no longer register a Digipost account + ZonedDateTime.now().plus(6, ChronoUnit.HOURS), +// Phone number that will be used for the SMS notification. Make sure the country code is included, starting with "+". + new PhoneNumber("+4712345678"), + null, + printDetails +); + +UUID messageId = UUID.randomUUID(); +Message message = Message.newMessage(messageId.toString(), document) + .recipient(new PersonalIdentificationNumber("12345678901")) + .senderId(senderId) + .requestForRegistration(requestForRegistration) + .build(); + +MessageDelivery delivery = sendClient.createMessage(message) + .addContent(document, Contents.filFraDisk("gyldig-for-print.pdf")) + .send(); + +System.out.println("status: " + delivery.getStatus()); +System.out.println("channel: " + delivery.getChannel()); + +// If the recipient does not have a Digipost account already, the value of `getChannel()` will be `null`, otherwise `Channel.DIGIPOST`. +``` + +### Request for registration without physical mail as fallback + +If the sender wishes to send the document as physical mail through its own service (if the recipient does not register a Digipost account), print details must not be included. + +```java +UUID documentId = UUID.randomUUID(); +Document document = new Document(documentId, "Hello!", FileType.PDF); + +RequestForRegistration requestForRegistration = new RequestForRegistration( +// Deadline for when the recipent can receive the document digitally right after Digipost account registration. + ZonedDateTime.now().plus(6, ChronoUnit.HOURS), +// Phone number that will be used for the SMS notification + new PhoneNumber("+4712345678"), + null, + null +); + +UUID messageId = UUID.randomUUID(); +Message message = Message.newMessage(messageId.toString(), document) + .recipient(new PersonalIdentificationNumber("12345678901")) + .senderId(senderId) + .requestForRegistration(requestForRegistration) + .build(); + +MessageDelivery delivery = sendClient.createMessage(message) + .addContent(document, Contents.filFraDisk("gyldig-for-print.pdf")) + .send(); +``` +It is up to the sender to then check if the document has been delivered digitaly prior to the defined deadline. After the deadline has passed the document will not be delivered if recipient registers for a Digipost account. The delivery status can be checked with the following: + +```java +// The messageId would be the UUID that was used when the originating message was sent +UUID messageId = UUID.fromString("efe11ce1-dfce-459a-865b-52dc313dbcb9"); +DocumentStatus status = sendClient.getDocumentStatus(senderId, messageId); +System.out.println("Status: " + status.status); +System.out.println("Channel: " + status.channel); +``` +The following statuses are possible: + +* NOT_DELIVERED +* DELIVERED + * When the document is delivered the channel can be either "DIGITAL" or "PRINT" + +## Identify user based on personal identification number + +```java +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); + +Identification identification = new Identification(pin); + +IdentificationResult identificationResult = client.identifyRecipient(identification); +``` + + + +## Create Digipost User Accounts + +Create new or activate existing Digipost user account. + +```java +SenderId sender = SenderId.of(123456); +UserInformation user = new UserInformation( + new NationalIdentityNumber("01013300001"), + new PhoneNumber("+4799998888"), + new EmailAddress("user@example.com") +); + +UserAccount userAccount = client.createOrActivateUserAccount(sender, user); + +DigipostAddress digipostAddress = userAccount.getDigipostAddress(); +EncryptionKey encryptionKey = userAccount.getEncryptionKey(); +``` + + +## Get Status of Document + +After you have sent a message, you can get the _status_ of a document with `getDocumentStatus`. +The response includes basic information about the delivery, like the channel the document was delivered to, as well as +delivery times and more. + +```java +DocumentStatus status = client.getDocumentStatus(senderId, documentUuid); + +System.out.println("Status: " + status.status); +System.out.println("Channel: " + status.channel); +``` diff --git a/docs/_v19_x/3_receive.md b/docs/_v19_x/3_receive.md new file mode 100644 index 00000000..71a49b2f --- /dev/null +++ b/docs/_v19_x/3_receive.md @@ -0,0 +1,60 @@ +--- +title: Receive messages +identifier: inbox +layout: default +--- + +The inbox API makes it possible for an organisation to manage messages received in Digipost. + + + +## Get documents in inbox + +The inbox call outputs a list of documents ordered by delivery time. `Offset` is the start index of the list, and `limit` is the max number of documents to be returned. The `offset` and `limit` is therefore not in any way connected to `InboxDocument.id`. + +The values `offset` and `limit` is meant for pagination so that one can fetch 100 and then the next 100. + + +```java +//get first 100 documents +Inbox first100 = client.getInbox(SenderId.of(123456), 0, 100); + +//get next 100 documents +Inbox next100 = client.getInbox(SenderId.of(123456), 100, 100); +``` + +We have now fetched the 200 newest inbox documents. As long as no new documents are received, the two API-calls shown above will always return the same result. If we now receive a new document, this will change. The first 100 will now contain 1 new document and 99 documents we have seen before. This means that as soon as you stumble upon a document you have seen before you can stop processing, given that all the following older ones have been processed. + +## Download document content + +```java +Inbox inbox = client.getInbox(SenderId.of(123456)); + +InboxDocument documentMetadata = inbox.documents.get(0); + +System.out.println("Content type is: " + documentMetadata.getContentType()); +InputStream documentContent = client.getInboxDocumentContent(documentMetadata); +``` + +## Delete document + +```java +Inbox inbox = client.getInbox(SenderId.of(123456)); + +InboxDocument documentMetadata = inbox.documents.get(0); + +client.deleteInboxDocument(documentMetadata); +``` + +## Download attachment content + +```java +Inbox inbox = client.getInbox(SenderId.of(123456)); + +InboxDocument documentMetadata = inbox.documents.get(0); +InboxDocument attachment = documentMetadata.getAttachments().get(0); + +System.out.println("Content type is: " + attachment.getContentType()); +InputStream attachmentContent = client.getInboxDocumentContent(attachment); +``` + diff --git a/docs/_v19_x/4_archive.md b/docs/_v19_x/4_archive.md new file mode 100644 index 00000000..5df831a8 --- /dev/null +++ b/docs/_v19_x/4_archive.md @@ -0,0 +1,240 @@ +--- +title: Archive functionality +identifier: archive +layout: default +--- + +The archive API makes it possible for an organisation to manage documents in archives. These files are kept in separate +archives, and the files belong to the sender organisation. + + +## Archive documents to an archive + +Let's say you want to archive two documents eg. an invoice and an attachment and +you want to have some kind of reference to both documents. You can do that +by describing the two documents with `ArchiveDocument`. Then you need to create an archive +and add the documents to the archive. In the following example we use a default archive. +You then need to send this archive and attach the actual files to the request by linking +the `ArchiveDocument` with a file and send. + +```java +// 1. We describe the documents +final ArchiveDocument invoice = new ArchiveDocument( + UUID.randomUUID() + , "invoice_123123.pdf" + , "pdf" + , "application/pdf" +); +final ArchiveDocument attachment = new ArchiveDocument( + UUID.randomUUID() + , "attachment_123123.pdf" + , "pdf" + , "application/pdf" +); + +// 2. We create an archive and add the documents to it +Archive archive = Archive.defaultArchive() + .documents(invoice, attachment) + .build(); + +// 3. We create a request to archive the files with reference between the ArchiveDocument and the actual file +client.archiveDocuments(archive) + .addFile(invoice, readFileFromDisk("invoice_123123.pdf")) + .addFile(attachment, readFileFromDisk("attachment_123123.pdf")) + .send(); +``` + +## Get a list of archives + +An organisation can have many archives, or just the default unnamed archive. That is up to +your design wishes. To get a list of the archives for a given Sender, you can do this: + +```java +//get a list of the archives +Archives archives = client.getArchives(SenderId.of(123456)); +``` + +The class `Archives` holds a list of `Archive` where you can see the name of the archive. + +## Iterate documents in an archive + +You _can_ get content of an archive with paged requests. Under is an example of how to iterate +an archive. However, it's use is strongly discouraged because it leads to the idea that +an archive can be iterated. We expect an archive to possibly reach many million rows so the iteration +will possibly give huge loads. On the other hand being able to dump all data is a necessary feature of any archive. + +_Please use fetch document by UUID or referenceID instead to create functionality on top of the archive._ +You should on your side know where and how to get a document from an archive. You do this by knowing where +you put a file you want to retrieve. + +```java +final Archives archives = client.getArchives(); + +Archive current = archives.getArchives().get(0); +final List documents = new ArrayList<>(); + +while (current.getNextDocuments().isPresent()) { + current = current.getNextDocuments() + .map(client::getArchiveDocuments) + .orElse(new Archive()); + + documents.addAll(current.getDocuments()); +} + +// This prints to total content of the list of documents +System.out.println(documents); +``` +## Archive Document attributes + +You can add optional attributes to documents. An attribute is a key/val string-map that describe documents. You can add +up to 15 attributes pr. archive document. The attribute key and value is case sensitive. + +```java +final ArchiveDocument invoice = new ArchiveDocument( + UUID.randomUUID() + , "invoice_123123.pdf" + , "pdf" + , "application/pdf" +).withAttribute("INR", "123123").withAttribute("custid", "4321"); +``` + +The attributes can be queried, so that you can get an iterable list of documents. + +```java +final Archives archives = digipostClient.getArchives(); +Archive current = archives.getArchives().get(0); + +final List documents = current.getNextDocumentsWithAttributes(Map.of("INR", "123123", "custid", "4321")) + .map(digipostClient::getArchiveDocuments) + .map(Archive::getDocuments).orElse(emptyList()); + +// This prints to total content of the list of documents +System.out.println(documents); +``` + +We recommend that the usage of attributes is made such that the number of results for a query on attributes +is less than 100. If you still want that, it's ok, but you need to iterate the pages to get all the results. + +```java +final Archives archives = client.getArchives(); + +Archive current = archives.getArchives().get(0); +final List documents = new ArrayList<>(); + +while (current.getNextDocuments().isPresent()) { + current = current.getNextDocumentsWithAttributes(Map.of("INR", "123123")) + .map(client::getArchiveDocuments) + .orElse(new Archive()); + + documents.addAll(current.getDocuments()); +} + +// This prints to total content of the list of documents +System.out.println(documents); +``` + +You can now also select by date or by attributes by date. Date is when the documents has been stored in Digipost archive. + +```java +final Archives archives = client.getArchives(); + +Archive current = archives.getArchives().get(0); +final List documents = new ArrayList<>(); + +while (current.getNextDocuments().isPresent()) { + current = current.getNextDocumentsWithAttributesByDate(Map.of("INR", "123123"), OffsetDateTime.now().minus(Period.ofDays(4)), OffsetDateTime.now()) + //current = defaultArchive.getNextDocumentsByDate(OffsetDateTime.now().minus(Period.ofDays(4)), OffsetDateTime.now()); + .map(client::getArchiveDocuments) + .orElse(new Archive()); + + documents.addAll(current.getDocuments()); +} + +// This prints to total content of the list of documents +System.out.println(documents); +``` + + +## Get documents by referenceID + +You can retrieve a set of documents by a given referenceID. You will then get the documents listed in their respective +archives in return. + +```java +final Archives archives = client.getArchiveDocumentsByReferenceId("REFERENCE_ID"); +``` + +## Get documents by uuid + +You can retrieve a set of documents by the UUID that you give the document when you archive it. In the example above +we use `UUID.randomUUID()` to generate an uuid. You can either store that random uuid in your database for +retrieval later, or you can generate a deterministic uuid based on your conventions for later retrieval. + +You will get in return an instance of `Archive` which contains information on the archive the document is contained in +and the actual document. From this you can fetch the actual document. + +```java +final UUID myConvensionUUID = UUID.fromString("vedlegg:123123:txt"); + +final Archive archiveWithDocument = client.getArchiveDocumentByUuid(myConvensionUUID); +``` + +## Get content of a document as a single-use link + +You can get the actual content of a document after you have retrieved the archive document. Below is an example of how +you can achieve this with a given `ArchiveDocument`. In the resulting `ArchiveDocumentContent`, you will get a url to +the content which expires after 30 seconds. + +```java +// This ArchiveDocument must be retrieved beforehand using one of the methods described above +final ArchiveDocument archiveDocument; + +URI getDocumentContentURI = archiveDocument.getDocumentContent().orElseThrow(); +ArchiveDocumentContent content = client.getArchiveDocumentContent(getDocumentContentURI); +``` + +## Get content of a document as a stream + +In addition to a single-use link, you also have the option to retrieve the content of a document directly as a +byte stream. + +```java +// This ArchiveDocument must be retrieved beforehand using one of the methods described above +final ArchiveDocument archiveDocument; + +URI getDocumentContentStreamURI = archiveDocument.getDocumentContentStream().orElseThrow(); +InputStream content = client.getArchiveDocumentContentStream(getDocumentContentStreamURI); +``` + +## Update document attributes and/or referenceID + +You can add an attribute or change an attribute value, but not delete an attribute. You can however set the value +to empty string. The value of the field for referenceID can be changed as well. + +```java +final UUID myConvensionUUID = UUID.fromString("vedlegg:123123:txt"); + +final Archive archiveWithDocument = client.getArchiveDocumentByUuid(myConvensionUUID); + +archiveDocument.withReferenceId("My final referenceId").withAttribute("Status", "COMPLETED_PROCESS"); + +client.updateArchiveDocument(archiveDocument, archiveDocument.getUpdate()); +``` + +## Using archive as a broker + +It is possible to be a broker for an actual sender. Most of the api described above also support +the use of SenderId to specify who you are archiving for. + +eg.: +```java +client.getArchives(SenderId.of(123456)) +client.getArchiveDocumentsByReferenceId(SenderId.of(123456), "REFERENCE_ID"); +client.getArchiveDocumentByUuid(SenderId.of(123456), myConvensionUUID); + + +Archive archive = Archive.defaultArchive() + .documents(faktura) + .senderId(SenderId.of(123456)) + .build(); +``` diff --git a/docs/_v19_x/5_batch.md b/docs/_v19_x/5_batch.md new file mode 100644 index 00000000..1732ccc4 --- /dev/null +++ b/docs/_v19_x/5_batch.md @@ -0,0 +1,94 @@ +--- +title: Batch functionality +identifier: batch +layout: default +--- + +The batch API makes it possible for an organisation to manage sending of several messages, both to Digipost and Print, in a +batch. The batch will then be delivered all at the same time atomically. If it has not been sendt yet, the batch can +also be cancelled. + +## Start and get information about a batch + +A batch is identified by a UUID specified by you. To create a batch you send a uuid to the Digipost api. In return~~~~ +you get a batch object with a status and som links for complete and cancel. + +```java +// Create an UUID +final UUID batchUUID = UUID.randomUUID(); + +// Create the batch +final Batch batch = client.createBatch(batchUUID); + +// At any time, read information about the batch +final Batch batchInformation = client.getBatchInformation(batchUUID); + +``` + +A batch can have 4 states: +`CREATED`, `NOT_COMMITTED`, `COMMITTED`, `DONE` + +CREATED is an initial state. NOT_COMMITTED is the state given when there has been added messages to the batch. +COMMITTED is a state that can occur if the batch has to be processed asynchronously. DONE means that the batch has +been commited. Digipost messages are delivered at commit time and Print messages will be delivered on first +possible work day after commit time. + + +## Send messages with batch reference. + +You can send both Digipost and Print messages just as you normally would, but to attach them to a batch you add the +batch as a reference on the message. The IMPORTANT part is visible below. Without this, the message will be delivered as +otherwise specified. + +```java +// Create an UUID +UUID batchUUID = UUID.randomUUID(); + +// Create the batch +client.createBatch(batchUUID); + +PersonalIdentificationNumber pin = new PersonalIdentificationNumber("26079833787"); +UUID documentUuid = UUID.randomUUID(); +Document primaryDocument = new Document(documentUuid, "Document subject", FileType.PDF); + +Message message = Message.newMessage("messageId", primaryDocument) + .recipient(pin) + .build(); + +client.createMessage(message) + .batch(batchUUID) // <- IMPORTANT + .addContent(primaryDocument, Files.newInputStream(Paths.get("content.pdf"))) + .send(); +``` + +## Commit a batch + +After you have created a batch and sendt the messages with the batch you need to commit the batch. Without the commit +Digipost will never send the messages and might at a later time delete the incomplete batch and messages +referred to in the batch. + +To complete the batch, simply complete it: + +``` java +// [...] +// get the information and verify that the count of digipost/print messages are as expected +final Batch batchInformation = client.getBatchInformation(batchUUID); + +// complete the batch +final Batch completedBatch = client.completeBatch(batchInformation); +``` + +## Cancel a batch + +You can at any time before completion cancel a batch. Cancelling means that the batch will be removed and cannot +be processed futher. Digipost will immediately delete all documents, messages and other references to the batch. +Any further attempts to fetch information about the batch will throw a 404. + +``` java +// [...] +// get the batch information +final Batch batchInformation = client.getBatchInformation(batchUUID); + +// cancel the batch +client.cancelBatch(batchInformation); +``` diff --git a/docs/_v19_x/index.html b/docs/_v19_x/index.html new file mode 100644 index 00000000..a56e64ac --- /dev/null +++ b/docs/_v19_x/index.html @@ -0,0 +1,15 @@ +--- +identifier: index +layout: default +redirect_from: / +--- + + +{% for dok in site.v19_x %} + {% if dok.identifier != 'index' %} +
+

{{ dok.title }}

+ {{dok.content}} +
+ {% endif%} +{% endfor %} diff --git a/pom.xml b/pom.xml index 7c7e9a89..3c461c27 100644 --- a/pom.xml +++ b/pom.xml @@ -159,6 +159,10 @@ digipost-data-types 1.3.0
+ + com.fasterxml.jackson.core + jackson-databind + org.glassfish.jaxb jaxb-runtime diff --git a/src/main/java/no/digipost/api/client/DigipostClient.java b/src/main/java/no/digipost/api/client/DigipostClient.java index 27975e39..d13eaa4d 100644 --- a/src/main/java/no/digipost/api/client/DigipostClient.java +++ b/src/main/java/no/digipost/api/client/DigipostClient.java @@ -53,6 +53,7 @@ import no.digipost.api.client.representations.shareddocuments.SharedDocumentContent; import no.digipost.api.client.security.CryptoUtil; import no.digipost.api.client.security.Signer; +import no.digipost.api.client.security.jwt.JwtAuthConfig; import no.digipost.api.client.shareddocuments.SharedDocumentsApi; import no.digipost.api.client.tag.TagApi; import no.digipost.api.client.util.JAXBContextUtils; @@ -97,12 +98,51 @@ public class DigipostClient { private final SharedDocumentsApi sharedDocumentsApi; - public DigipostClient(DigipostClientConfig config, BrokerId brokerId, Signer signer) { - this(config, brokerId, signer, HttpClientFactory.createDefaultBuilder()); + /** + * Creates a client that authenticates with the Digipost API using certificate-based request signing. + * + * @param config the client configuration, e.g. which API to communicate with + * @param brokerId the broker permitted to integrate with the Digipost API + * @param signer signs each request with the broker's private key + */ + public static DigipostClient withCertificateAuthentication(DigipostClientConfig config, BrokerId brokerId, Signer signer) { + return withCertificateAuthentication(config, brokerId, signer, HttpClientFactory.createDefaultBuilder()); + } + + /** + * Creates a client that authenticates with the Digipost API using certificate-based request signing. + * + * @param config the client configuration, e.g. which API to communicate with + * @param brokerId the broker permitted to integrate with the Digipost API + * @param signer signs each request with the broker's private key + * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. connection manager, timeouts and proxy settings + */ + public static DigipostClient withCertificateAuthentication(DigipostClientConfig config, BrokerId brokerId, Signer signer, HttpClientBuilder clientBuilder) { + return new DigipostClient(config, ApiServiceImpl.withCertificateAuthentication(config, clientBuilder, brokerId, signer)); } - public DigipostClient(DigipostClientConfig config, BrokerId brokerId, Signer signer, HttpClientBuilder clientBuilder) { - this(config, new ApiServiceImpl(config, clientBuilder, brokerId, signer)); + /** + * Creates a client that authenticates with the Digipost API using OAuth 2.0 access tokens + * obtained over a mutual-TLS channel. + * + * @param config the client configuration, e.g. which API to communicate with. The access tokens are requested for that same API + * @param brokerId the broker permitted to integrate with the Digipost API + * @param jwtAuthConfig configures the token endpoint and the client certificate used for mTLS + */ + public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig config, BrokerId brokerId, JwtAuthConfig jwtAuthConfig) { + return withJwtMtlsAuthentication(config, brokerId, jwtAuthConfig, HttpClientFactory.createDefaultBuilder()); + } + + /** + * Creates a client that authenticates with the Digipost API using OAuth 2.0 access tokens obtained over a mutual-TLS channel. + * + * @param config the client configuration, e.g. which API to communicate with. The access tokens are requested for that same API + * @param brokerId the broker permitted to integrate with the Digipost API + * @param jwtAuthConfig configures the token endpoint and the client certificate used for mTLS + * @param clientBuilder the Apache {@link HttpClientBuilder} used to build the underlying HTTP client, allowing customization of e.g. timeouts and proxy settings. Note that its connection manager is replaced with one configured for the mTLS handshake. + */ + public static DigipostClient withJwtMtlsAuthentication(DigipostClientConfig config, BrokerId brokerId, JwtAuthConfig jwtAuthConfig, HttpClientBuilder clientBuilder) { + return new DigipostClient(config, ApiServiceImpl.withJwtMtlsAuthentication(config, clientBuilder, brokerId, jwtAuthConfig)); } private DigipostClient(DigipostClientConfig config, ApiServiceImpl apiService) { diff --git a/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java b/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java index 193c5185..4081ca5e 100644 --- a/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java +++ b/src/main/java/no/digipost/api/client/errorhandling/ErrorCode.java @@ -32,6 +32,7 @@ public enum ErrorCode { // Internal client errors CLIENT_ERROR(CLIENT_TECHNICAL), + FAILED_TO_OBTAIN_ACCESS_TOKEN(CLIENT_TECHNICAL), // Server errors GENERAL_ERROR(UNKNOWN), diff --git a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java index fa160b47..4d188bcc 100644 --- a/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java +++ b/src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java @@ -28,8 +28,10 @@ import no.digipost.api.client.inbox.InboxApi; import no.digipost.api.client.internal.http.Headers; import no.digipost.api.client.internal.http.MultipartNoLengthCheckHttpEntity; -import no.digipost.api.client.internal.http.request.interceptor.RequestContentHashFilter; +import no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor; +import no.digipost.api.client.internal.http.request.interceptor.RequestContentHashInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestDateInterceptor; +import no.digipost.api.client.internal.http.request.interceptor.RequestPathInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestSignatureInterceptor; import no.digipost.api.client.internal.http.request.interceptor.RequestUserAgentInterceptor; import no.digipost.api.client.internal.http.response.interceptor.ResponseContentSHA256Interceptor; @@ -65,6 +67,8 @@ import no.digipost.api.client.representations.shareddocuments.SharedDocumentContent; import no.digipost.api.client.security.Digester; import no.digipost.api.client.security.Signer; +import no.digipost.api.client.security.jwt.JwtAuthConfig; +import no.digipost.api.client.security.jwt.MutualTlsTokenProvider; import no.digipost.api.client.shareddocuments.SharedDocumentsApi; import no.digipost.api.client.tag.TagApi; import no.digipost.api.client.util.JAXBContextUtils; @@ -75,8 +79,10 @@ import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.classic.methods.HttpPut; +import no.digipost.http.client.HttpClientConnectionManagerFactory; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; @@ -95,14 +101,17 @@ import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; +import java.time.Clock; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Function; import static jakarta.xml.bind.JAXB.unmarshal; +import static java.util.Objects.requireNonNull; import static java.util.Optional.ofNullable; import static no.digipost.api.client.internal.ExceptionUtils.asUnchecked; import static no.digipost.api.client.internal.ExceptionUtils.exceptionNameAndMessage; @@ -133,21 +142,63 @@ public class ApiServiceImpl implements MessageDeliveryApi, InboxApi, DocumentApi // which was the case for the pattern "yyyy-MM-dd'T'HH:mm:ss.SSSZZ". See commit messages for 59caeb5737e45a15 and dcf41785a84f42caf935 for details. private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx"); - public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer) { + public static ApiServiceImpl withCertificateAuthentication(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer) { + requireNonNull(signer, "signer cannot be null"); + return new ApiServiceImpl(config, brokerId, apiService -> apiService.createCertificateAuthenticatingHttpClient(httpClientBuilder, signer, config)); + } + + public static ApiServiceImpl withJwtMtlsAuthentication(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, JwtAuthConfig jwtAuthConfig) { + requireNonNull(jwtAuthConfig, "jwtAuthConfig cannot be null"); + return new ApiServiceImpl(config, brokerId, apiService -> apiService.createJwtAuthenticatingHttpClient(httpClientBuilder, jwtAuthConfig, config)); + } + + private ApiServiceImpl(DigipostClientConfig config, BrokerId brokerId, Function httpClientFactory) { this.brokerId = brokerId; this.eventLogger = config.eventLogger.withDebugLogTo(LOG); this.digipostUrl = config.digipostApiUri; - this.cached = new Cached(() -> fetchEntryPoint(Optional.empty())); - this.httpClient = httpClientBuilder - .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, config.clock)) - .addRequestInterceptorLast(new RequestUserAgentInterceptor()) - .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, config.eventLogger, new RequestContentHashFilter(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256))) - .addResponseInterceptorLast(new ResponseDateInterceptor(config.clock)) - .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) - .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) - .build(); - this.eventLogger.log("Initialiserte apache-klient mot " + config.digipostApiUri); + this.httpClient = httpClientFactory.apply(this); + } + + private CloseableHttpClient createCertificateAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, Signer signer, DigipostClientConfig config) { + Clock clock = config.clock; + CloseableHttpClient httpClient = httpClientBuilder + .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) + .addRequestInterceptorLast(new RequestUserAgentInterceptor()) + .addRequestInterceptorLast(new RequestPathInterceptor()) + .addRequestInterceptorLast(new RequestContentHashInterceptor(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256)) + .addRequestInterceptorLast(new RequestSignatureInterceptor(signer, config.eventLogger)) + .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) + .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) + .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) + .build(); + + eventLogger.log("Initialiserte apache-klient (sertifikatmodus) mot " + config.digipostApiUri); + return httpClient; + } + + private CloseableHttpClient createJwtAuthenticatingHttpClient(HttpClientBuilder httpClientBuilder, JwtAuthConfig jwtAuthConfig, DigipostClientConfig config) { + Clock clock = config.clock; + MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig, brokerId, config.digipostApiUri, clock); + + CloseableHttpClient httpClient = httpClientBuilder + .setConnectionManager(HttpClientConnectionManagerFactory.createDefaultBuilder() + .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() + .setSslContext(tokenProvider.getSslContext()) + .build()) + .build()) + .addRequestInterceptorLast(new RequestDateInterceptor(config.eventLogger, clock)) + .addRequestInterceptorLast(new RequestUserAgentInterceptor()) + .addRequestInterceptorLast(new RequestPathInterceptor()) + .addRequestInterceptorLast(new RequestBearerTokenInterceptor(tokenProvider::getToken)) + .addRequestInterceptorLast(new RequestContentHashInterceptor(config.eventLogger, Digester.sha256, Headers.X_Content_SHA256)) + .addResponseInterceptorLast(new ResponseDateInterceptor(clock)) + .addResponseInterceptorLast(new ResponseContentSHA256Interceptor()) + .addResponseInterceptorLast(new ResponseSignatureInterceptor(this::getEntryPoint)) + .build(); + + eventLogger.log("Initialiserte apache-klient (JWT/mTLS-modus) mot " + config.digipostApiUri); + return httpClient; } //Kan sende inn null. Man får da det samme som getEntryPoint() diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java new file mode 100644 index 00000000..5d387402 --- /dev/null +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestBearerTokenInterceptor.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) Posten Bring AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package no.digipost.api.client.internal.http.request.interceptor; + +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.protocol.HttpContext; + +import java.util.function.Supplier; + +public class RequestBearerTokenInterceptor implements HttpRequestInterceptor { + + private final Supplier accessToken; + + public RequestBearerTokenInterceptor(Supplier accessToken) { + this.accessToken = accessToken; + } + + @Override + public void process(HttpRequest request, EntityDetails entityDetails, HttpContext context) { + request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.get()); + } +} diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashFilter.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptor.java similarity index 51% rename from src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashFilter.java rename to src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptor.java index 8b6d6fe4..0e8e2f99 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashFilter.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestContentHashInterceptor.java @@ -17,33 +17,50 @@ import no.digipost.api.client.EventLogger; import no.digipost.api.client.security.Digester; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.protocol.HttpContext; import org.bouncycastle.util.encoders.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class RequestContentHashFilter { +import java.io.IOException; +import java.util.Optional; - private static final Logger LOG = LoggerFactory.getLogger(RequestContentHashFilter.class); +public class RequestContentHashInterceptor implements HttpRequestInterceptor { + + private static final Logger LOG = LoggerFactory.getLogger(RequestContentHashInterceptor.class); private final EventLogger eventLogger; private final Digester digester; private final String header; - public RequestContentHashFilter(EventLogger eventLogger, Digester digester, String header) { + public RequestContentHashInterceptor(Digester digester, String header) { + this(EventLogger.NOOP_LOGGER, digester, header); + } + + public RequestContentHashInterceptor(EventLogger eventLogger, Digester digester, String header) { this.eventLogger = (eventLogger != null ? eventLogger : EventLogger.NOOP_LOGGER).withDebugLogTo(LOG); this.digester = digester; this.header = header; } - public RequestContentHashFilter(Digester digester, final String header) { - this(EventLogger.NOOP_LOGGER, digester, header); - } - - public void settContentHashHeader(final byte[] data, final HttpRequest httpRequest) { - byte[] result = digester.createDigest(data); - String hash = new String(Base64.encode(result)); + @Override + public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) throws IOException { + if (!(httpRequest instanceof ClassicHttpRequest)) { + return; + } + HttpEntity entity = ((ClassicHttpRequest) httpRequest).getEntity(); + if (entity == null) { + return; + } + byte[] data = Optional.ofNullable(EntityUtils.toByteArray(entity)).orElseGet(() -> new byte[0]); + String hash = new String(Base64.encode(digester.createDigest(data))); httpRequest.setHeader(header, hash); - eventLogger.log(RequestContentHashFilter.class.getSimpleName() + " satt headeren " + header + "=" + hash); + eventLogger.log(getClass().getSimpleName() + " satt headeren " + header + "=" + hash); } } diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestPathInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestPathInterceptor.java new file mode 100644 index 00000000..537a4783 --- /dev/null +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestPathInterceptor.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) Posten Bring AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package no.digipost.api.client.internal.http.request.interceptor; + +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.protocol.HttpContext; + +public class RequestPathInterceptor implements HttpRequestInterceptor { + + public static final String REQUEST_PATH_ATTRIBUTE = "request-path"; + + @Override + public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) { + httpContext.setAttribute(REQUEST_PATH_ATTRIBUTE, httpRequest.getPath()); + } +} diff --git a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java index c3045ff9..c155d8e6 100644 --- a/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java +++ b/src/main/java/no/digipost/api/client/internal/http/request/interceptor/RequestSignatureInterceptor.java @@ -19,36 +19,30 @@ import no.digipost.api.client.internal.http.Headers; import no.digipost.api.client.security.RequestMessageSignatureUtil; import no.digipost.api.client.security.Signer; -import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.EntityDetails; -import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; -import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.protocol.HttpContext; import org.bouncycastle.util.encoders.Base64; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.util.Optional; public class RequestSignatureInterceptor implements HttpRequestInterceptor { private static final Logger LOG = LoggerFactory.getLogger(RequestSignatureInterceptor.class); private final Signer signer; - private final RequestContentHashFilter hashFilter; private final EventLogger eventLogger; - public RequestSignatureInterceptor(Signer signer, RequestContentHashFilter hashFilter) { - this(signer, EventLogger.NOOP_LOGGER, hashFilter); + public RequestSignatureInterceptor(Signer signer) { + this(signer, EventLogger.NOOP_LOGGER); } - public RequestSignatureInterceptor(Signer signer, EventLogger eventLogger, RequestContentHashFilter hashFilter){ + public RequestSignatureInterceptor(Signer signer, EventLogger eventLogger) { this.eventLogger = (eventLogger != null ? eventLogger : EventLogger.NOOP_LOGGER).withDebugLogTo(LOG); this.signer = signer; - this.hashFilter = hashFilter; } private void setSignatureHeader(HttpRequest httpRequest) { @@ -66,23 +60,6 @@ private void setSignatureHeader(HttpRequest httpRequest) { @Override public void process(HttpRequest httpRequest, EntityDetails entityDetails, HttpContext httpContext) throws IOException { - - if(httpRequest instanceof ClassicHttpRequest) { - ClassicHttpRequest request = (ClassicHttpRequest) httpRequest; - HttpEntity rqEntity = request.getEntity(); - - if (rqEntity == null) { - setSignatureHeader(httpRequest); - } else { - byte[] entityBytes = Optional.ofNullable(EntityUtils.toByteArray(rqEntity)).orElseGet(() -> new byte[0]); - hashFilter.settContentHashHeader(entityBytes, request); - setSignatureHeader(httpRequest); - } - } else { - setSignatureHeader(httpRequest); - } - httpContext.setAttribute("request-path", httpRequest.getPath()); - - + setSignatureHeader(httpRequest); } } diff --git a/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java b/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java index eb2354ec..6cffd55b 100644 --- a/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java +++ b/src/main/java/no/digipost/api/client/internal/http/response/interceptor/ApacheHttpResponseToVerify.java @@ -23,6 +23,8 @@ import java.util.SortedMap; import java.util.TreeMap; +import static no.digipost.api.client.internal.http.request.interceptor.RequestPathInterceptor.REQUEST_PATH_ATTRIBUTE; + final class ApacheHttpResponseToVerify implements ResponseToVerify { private final HttpContext context; @@ -49,7 +51,7 @@ public SortedMap getHeaders() { @Override public String getPath() { - String pathWithQueryParams = (String) context.getAttribute("request-path"); + String pathWithQueryParams = (String) context.getAttribute(REQUEST_PATH_ATTRIBUTE); int indexOfQuestionMark = pathWithQueryParams.indexOf('?'); if (indexOfQuestionMark != -1) { return pathWithQueryParams.substring(0, indexOfQuestionMark); diff --git a/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java b/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java new file mode 100644 index 00000000..c44af0d3 --- /dev/null +++ b/src/main/java/no/digipost/api/client/security/jwt/JwtAuthConfig.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) Posten Bring AS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package no.digipost.api.client.security.jwt; + +import no.digipost.http.client.HttpClientConnectionSettings; +import no.digipost.http.client.HttpClientSettings; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; + +import static java.util.Objects.requireNonNull; + +/** + * Configures how the client obtains OAuth 2.0 access tokens over a mutual-TLS channel: + * which token endpoint to ask, which client to identify as, and which client certificate + * to present in the handshake. + *

+ * The resource the tokens are requested for is not configured here. It is derived + * from {@link no.digipost.api.client.DigipostClientConfig#digipostApiUri}, so that the + * tokens are always issued for the same API the client actually talks to. + */ +public final class JwtAuthConfig { + + public final URI tokenEndpointUri; + public final String clientId; + final KeyStore keyStore; + final char[] keyPassword; + final HttpClientSettings httpClientSettings; + final HttpClientConnectionSettings httpClientConnectionSettings; + + public static Builder newConfig(String clientId) { + return new Builder(clientId); + } + + public static class Builder { + private URI tokenEndpointUri = URI.create("https://midp.digipost.no/oauth2/token"); + private final String clientId; + private KeyStore keyStore; + private char[] keyPassword; + private HttpClientSettings httpClientSettings = HttpClientSettings.DEFAULT; + private HttpClientConnectionSettings httpClientConnectionSettings = HttpClientConnectionSettings.DEFAULT; + + private Builder(String clientId) { + this.clientId = requireNonNull(clientId, "clientId cannot be null"); + } + + public Builder tokenEndpoint(String tokenEndpoint) { + this.tokenEndpointUri = URI.create(tokenEndpoint); + return this; + } + + public Builder pkcs12KeyStore(InputStream pkcs12Stream, String password) { + requireNonNull(pkcs12Stream, "pkcs12Stream cannot be null"); + requireNonNull(password, "password cannot be null"); + try { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(pkcs12Stream, password.toCharArray()); + this.keyStore = ks; + this.keyPassword = password.toCharArray(); + return this; + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) { + throw new IllegalArgumentException("Could not load PKCS12 keystore", e); + } + } + + public Builder keyStore(KeyStore keyStore, String keyPassword) { + this.keyStore = requireNonNull(keyStore, "keyStore cannot be null"); + this.keyPassword = requireNonNull(keyPassword, "keyPassword cannot be null").toCharArray(); + return this; + } + + /** + * Customizes the HTTP client used to fetch access tokens from the token endpoint, e.g. its + * timeouts. The client is built by this library, as it must present the client certificate + * configured here in the TLS handshake, and is separate from the client used to talk to the + * Digipost API. Both parameters have sensible defaults, so pass + * {@link HttpClientSettings#DEFAULT} or {@link HttpClientConnectionSettings#DEFAULT} for + * the one you do not need to change. + * + *

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