Skip to content

Add mTLS authentication via Digipost mIdP to use JWT in client API calls - #372

Open
sondrew wants to merge 9 commits into
mainfrom
support_new_jwt_authentication
Open

sondrew wants to merge 9 commits into
mainfrom
support_new_jwt_authentication

Conversation

@sondrew

@sondrew sondrew commented Aug 27, 2026 •

Copy link
Copy Markdown

💰 Funksjonell beskrivelse av endringen

Adds OAuth 2.0 client-credentials authentication as an alternative to authenticating with the organization certificate directly. The client presents its certificate over mTLS to Digipost's identity provider (mIdP) to obtain an access token, then authenticates API requests with that token as a bearer token. The functionality is backwards compatible: without jwtAuthentication(..) nothing changes.

This enables customers to use our clients without having to pay for an expensive enterprise certificate, and being able to use that certificate across multiple services (signing, digipost, etc). mIdP accepts certificates issued by Digipost's own certificate issuer, and the same certificate works across Digipost services. The equivalent change is done for all java/dotnet clients for digipost and signature. A certificate is still required — this changes which certificate is acceptable, not whether one is needed. It authenticates to mIdP and signs ASiC-E bundles (an XAdES signature over the document hashes, which needs the private key and embeds the certificate chain).

🏆 Interessante highlights

  • The certificate is presented only to mIdP, API requests are authenticated by the token alone, so mutual TLS is no longer used against the API. Validation of the API's server certificate is unchanged (Posten Bring organization number)
  • The predefined environments know their own token endpoint; custom environments can be given one with ServiceEnvironment.withTokenEndpoint(..)
  • No JWT is handled or verified here, the access token is treated as opaque and sent to the API
  • Token endpoint, scope and resource are derived, not caller-supplied: the token endpoint and resource from the ServiceEnvironment, the scope as signering:<broker id>. All are exact-match contracts with mIdP
  • New public type BrokerId, supplied alongside the client id in JwtAuthConfig. The two are issued together and there is exactly one broker id per client id - specifically for signature-api, as there might be multiple brokerIds for one client in dpost-api
  • Broker and sender are independent. A given JWT client acquires tokens as one broker for its whole lifetime, so the scope never varies per request. Which sender a job is for is stated in the job, and a broker acting on behalf of several organizations still uses withSender(..) per job as before. defaultSender(..) is therefore not required in order to authenticate
  • Rejected tokens are recovered from. A 401 discards the cached token and retries the request once with a freshly acquired one. Neither the method of a request nor the shape of its body decides whether it is retried: the API rejects a request with 401 before acting on it, so repeating it cannot apply it twice. Bounded to a single retry — a second 401 is passed on to the caller
  • No JSON parsing lib existed already, added jackson-core, not jackson-databind — streaming parser only, avoiding byte-buddy and byte-buddy-agent dependency
  • Still needs adding documentation to client docs

🤷‍♀️ Anbefalt fremgangsmåte

certificate --mTLS--> mIdP token endpoint --> access token
access token --Bearer--> Signering API (no client certificate)
certificate --> ASiC-E document signing (unchanged)

Public API

ClientConfiguration.builder(keyStoreConfig)
        .serviceEnvironment(ServiceEnvironment.PRODUCTION)
        .defaultSender(new Sender("123456789"))
        .jwtAuthentication(JwtAuthConfig.forClient("my-client-id", BrokerId.of("555444")))
        .build();
  1. build() resolves the token request from the ServiceEnvironment and the JwtAuthConfig.
  2. On the first API request, a client_credentials grant is POSTed to the environment's token endpoint over mTLS, authenticated with the organization certificate: scope=signering:<broker id>, resource=<service root URL without its path>, e.g. https://api.signering.posten.no for the /api root of production. Both are matched as exact strings by mIdP.
  3. The token is cached and re-acquired lazily, 30 seconds before expiry.
  4. Both the default client and the document download client send Authorization: Bearer

Testing

  • Token acquisition, caching, refresh margin, invalidation, and concurrent callers sharing a single acquired token
  • Real TLS handshakes proving the client certificate is presented to the token endpoint and is not presented to the API, including document downloads (new localhost-tls-testserver.p12 fixture, since the existing test certificate is expired and not issued for localhost)
  • That the token endpoint's server certificate is properly validated
  • 401 recovery: that the rejected token is discarded, that the request is retried exactly once with the replacement token, and that this holds for creating a signature job and requesting a new redirect URL as well as for reads
  • Malformed token responses: missing, empty, non-integer, non-positive and overflowing values
  • Proxy and User-Agent settings reaching the token endpoint

@sondrew
sondrew requested review from a team August 27, 2026 15:49
@Smurfz87
Smurfz87 requested a balanced review from Copilot August 27, 2026 19:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because all changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because all changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

@Smurfz87

This comment was marked as resolved.

@sondrew

This comment was marked as resolved.

@sondrew

This comment was marked as resolved.

@sondrew
sondrew requested a review from a team September 22, 2026 09:57

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants