Skip to content

OAuth/JWT-autentisering - #232

Open
fredrbus wants to merge 29 commits into
mainfrom
jwt-autentisering
Open

OAuth/JWT-autentisering#232
fredrbus wants to merge 29 commits into
mainfrom
jwt-autentisering

Conversation

@fredrbus

@fredrbus fredrbus commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

OAuth 2-autentisering mot Digipost API

Legger til funksjonalitet slik at klienter kan autentisere seg mot Digipost API med JWT, med OAuth-klienter registrert i Nyva og tokens hentet fra mIdP'en.

Denne PR'en er altså klientsiden av denne PR'en i digipost/digipost, som allerede er i prod.

Fremangsmåte

Jeg ville gått gjennom commit for commit og lest commitmeldingene, men ikke nødvendigvis allt koden da det har vært en del frem og tilbake her.

Klassene jeg ville fokusert på er:

  1. MutualTlsTokenProvider
  2. ApiServiceImpl
  3. RequestSignatureInterceptor + RequestContentHashInterceptor
  4. ApacheHttpRequestToSign / ApacheHttpResponseToVerify / RequestPathInterceptor
  5. DigipostClient + JwtAuthConfig
  6. TokenEndpointStub + MutualTlsTokenProviderTest

Kort oppsummert

Bakoverkompabilitet

Endringene er bakoverkompatible med eksisterende autentiseringsløsning. Valg av autentiseringsmetode gjøres nå med factorymetodene

var client = DigipostClient.withJwtMtlsAuthentication(...).

// eller

var client =  DigipostClient.withCertificateAuthentication(...)

Henting og caching av tokens

Klienten håndterer henting og caching av tokens selv. "Batteries included", som jeg sikkert hadde sagt om jeg ikke var så jordnær og ydmyk som jeg jo er.

Testing

Jeg har også laget en demo i testklienter, på branch test-jwt-autentisering. Der finner man testen no.digipost.testclients.dpostapi.oauth2.OAuth2SendeKlientQa, som tester sending av dokumenter med JWT-autentisering mot QA.

The client can now authenticate in two ways: the existing
certificate-based signing (Signer), or OAuth 2.0 client credentials with
a certificate-bound JWT (RFC 8705) over mutual TLS.

- JwtAuthConfig: configures the token endpoint, resource server,
  clientId and client certificate (PKCS12 keystore or KeyStore).

- MutualTlsTokenProvider: fetches and caches an access token from mIdP
  over mTLS, with a refresh margin and expiry derived from expires_in or
  the exp claim.

- RequestBearerTokenInterceptor: sets the Authorization:Bearer header.

- ApiServiceImpl selects the authentication mode based on whether a
  Signer or a JwtAuthConfig is set, and throws when neither is
  configured.

- New DigipostClient constructors without a Signer.
Moving this out of RequestSignatureInterceptor, as we need this
functionality also for OAuth-based authentication, which does not use
the RequestSignatureInterceptor.

Also defined the attribute name as a constant in the new interceptor to
make the connection between the interceptor and verification step
clearer.
The client previously selected its authentication mode based on
whether a Signer was null, and the JWT/mTLS config was hidden
inside DigipostClientConfig. The choice was scattered and easy
to misconfigure.

- Introduce DigipostClient.withCertificateAuthentication(...) and
  withJwtMtlsAuthentication(...) (each with an HttpClientBuilder
  overload), so the chosen authentication method is stated at the
  call site and the required credential cannot be forgotten.
- Replace the implicit "signer == null" selection with an explicit
  AuthMode enum resolved in one place
  (ApiServiceImpl#resolveAuthMode). It now also throws when both
  certificate and JWT/mTLS auth are configured, or neither.
- Move JwtAuthConfig out of DigipostClientConfig; it is now a
  required argument to the JWT factory method.
- Add Javadoc for the factory methods, including the clientBuilder
  parameter.
- Add ApiServiceImplAuthModeTest covering all four resolution
  cases.

BREAKING CHANGE: the DigipostClient(config, brokerId, signer[,
clientBuilder]) constructors and the implicit no-signer
constructors have been removed. All call sites (example code and
DigipostSwingClient) have been migrated to the new factory
methods.
Every versioned docs page carried redirect_from: /, so several
versions claimed the site root and the target became ambiguous.
Drop it from the older versions so only v19 (the current version)
owns the root redirect.
The resource URI could be set both in JwtAuthConfig.apiUri and
DigipostClientConfig.digipostApiUri, both defaulting to production.
A client pointed at test through DigipostClientConfig alone got tokens
for production.
Token endpoint failures surfaced as IllegalStateException, so callers
could not handle them like the rest of the client's errors. Also keep
the cause when the response is not valid JSON.
The test built its own SSLContext and HTTP client, so it verified the
test's handshake rather than the provider's. It passed even with the
provider's logic untouched.

Let the provider's trust managers be overridden, so the test can drive
the real getToken() path: mTLS handshake, request parameters, caching,
expiry from the exp claim, and error mapping.
The enum and the unreachable switch default guarded against states only
the internal constructor could create. The factories make the mode a
property of the call, so no argument has to be null.
The interceptors were handed the eventLogger already wrapped for
ApiServiceImpl's logger, so every message reached slf4j twice.
Taking a Supplier<String> instead of MutualTlsTokenProvider lets the
interceptor be tested without a keystore and a TLS handshake.
The name stuttered "Request" twice.
Also fixes "certificate-base" -> "certificate-based", and states on the
JWT methods that tokens are requested for the API given by config.

@martin-jackson martin-jackson 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.

Heftig shit!

Sliter litt med å få testet med testklienter: Fikk først kompilseringsfeil på JwtAuthConfig::newBuilder, men ser ut som at det bare er pga commit 872aa89.

Etter å ha builderen til følgende får jeg 401 fra midp:

JwtAuthConfig jwtAuthConfig = JwtAuthConfig
      .newConfig(clientId)
      .tokenEndpoint(midpTokenUri.toString())
      .pkcs12KeyStore(keyStream, keyPassword.getPassword())
      .build();
Exception in thread "main" no.digipost.api.client.errorhandling.DigipostClientException: FAILED_TO_OBTAIN_ACCESS_TOKEN: Token endpoint returned HTTP 401 for https://midp.qa.digipost.no/oauth2/token: {"error_description":"Client authentication failed: client_id","error":"invalid_client","error_uri":"https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1"}
	at no.digipost.api.client.security.jwt.MutualTlsTokenProvider.lambda$fetchAndCacheToken$0(MutualTlsTokenProvider.java:107)
	at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:247)
	at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:188)
	at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:162)
	at no.digipost.api.client.security.jwt.MutualTlsTokenProvider.fetchAndCacheToken(MutualTlsTokenProvider.java:103)
	at no.digipost.api.client.security.jwt.MutualTlsTokenProvider.getToken(MutualTlsTokenProvider.java:90)
	at no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor.process(RequestBearerTokenInterceptor.java:36)
	at org.apache.hc.core5.http.protocol.DefaultHttpProcessor.process(DefaultHttpProcessor.java:107)

Comment thread src/main/java/no/digipost/api/client/security/jwt/MutualTlsTokenProvider.java Outdated
@fredrbus

fredrbus commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Etter å ha builderen til følgende får jeg 401 fra midp:

JwtAuthConfig jwtAuthConfig = JwtAuthConfig
      .newConfig(clientId)
      .tokenEndpoint(midpTokenUri.toString())
      .pkcs12KeyStore(keyStream, keyPassword.getPassword())
      .build();
Exception in thread "main" no.digipost.api.client.errorhandling.DigipostClientException: FAILED_TO_OBTAIN_ACCESS_TOKEN: Token endpoint returned HTTP 401 for https://midp.qa.digipost.no/oauth2/token: {"error_description":"Client authentication failed: client_id","error":"invalid_client","error_uri":"https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1"}
	at no.digipost.api.client.security.jwt.MutualTlsTokenProvider.lambda$fetchAndCacheToken$0(MutualTlsTokenProvider.java:107)
	at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:247)
	at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:188)
	at org.apache.hc.client5.http.impl.classic.CloseableHttpClient.execute(CloseableHttpClient.java:162)
	at no.digipost.api.client.security.jwt.MutualTlsTokenProvider.fetchAndCacheToken(MutualTlsTokenProvider.java:103)
	at no.digipost.api.client.security.jwt.MutualTlsTokenProvider.getToken(MutualTlsTokenProvider.java:90)
	at no.digipost.api.client.internal.http.request.interceptor.RequestBearerTokenInterceptor.process(RequestBearerTokenInterceptor.java:36)
	at org.apache.hc.core5.http.protocol.DefaultHttpProcessor.process(DefaultHttpProcessor.java:107)

Mulig databasen har blitt resatt, skal få dyttet inn ny testdata

@arneroen arneroen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ref meldinga i midp-kanalen på slack her for leden. Vi må bli enige om hvordan vi håndterer 401 fra API-serveren. Minimum tenker jeg at det cachede tokenet bør invalideres. Og så tror jeg helt fint vi kan hente et nytt token og retrye requesten mot APIet.

Comment thread docs/_v19_x/1_client_config.md
Comment thread docs/_v19_x/1_client_config.md Outdated
}

public ApiServiceImpl(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, Signer signer, JwtAuthConfig jwtAuthConfig) {
public static ApiServiceImpl withJwtMtlsAuthentication(DigipostClientConfig config, HttpClientBuilder httpClientBuilder, BrokerId brokerId, JwtAuthConfig jwtAuthConfig) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Litt nitpick, men disse metodenavnene kan antyde at auth-metoden mot API-serveren er JWT+mTLS, som jo ikke stemmer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Er ikke helt enig as. Antyder ingenting om hva serveren bruker, men hvordan DigipostApiClient autentiserer seg, som er både JWT (mot dpost-api) og mTLS (mot mIdP). Ville ha med begge siden begge disse konfigureres i den builderen.

Comment thread src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java Outdated
Clock clock = config.clock;
MutualTlsTokenProvider tokenProvider = new MutualTlsTokenProvider(jwtAuthConfig, brokerId, config.digipostApiUri, clock);

CloseableHttpClient httpClient = httpClientBuilder

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Den samme HTTP-klienten brukes mot både mIdPen og mot API-serveren. Denne klienten er konfigurert for mTLS.

Så lenge digipost sitt API ikke også er konfigurert for mTLS, vil jo ikke klient -> API kommunisere over mTLS. Dette i henhold til det dokumentasjonen sier. Men dersom mTLS også slås på i digipost APIet, vi klienten forsøke å kommunisere over mTLS, som jo strider med dokumentasjonen.

Eventuelt er intensjonen kanskje at klient -> digipost-API også skal gå over mTLS, ref RFC 8705?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Det stemmer vel ikke, de har hver sin HTTP-client. Det eneste de har til felles er SSL-context, slik at API-klienten kan presentere samme sertifikat til dpost-api som til mIdP'en (dersom vi vil validere API-kallet mot cnf-verdien i tokenet), men det tenker jeg å fjerne uansett siden vi har gått vekk fra å pakke om sertifikatet i reverse-proxyen og sende det videre med HTTP-headere til dpost-api.

Comment thread docs/_v19_x/1_client_config.md Outdated
In practice, this check was only a check for the order of the
request-interceptors, which are hard-coded anyways.
SslConnectionFactory-methods are deprecated.
ApiServiceImpl only closed its own http client. In JWT/mTLS mode the
MutualTlsTokenProvider holds a separate client with its own connection
pool, and nothing ever closed it. Keep the token provider as a field and
close it together with the API-service.

Since ApiServiceImpl is internal, its close()-method was unreachable
from the public API. Therefore, we also implement AutoCloseable in the
DigipostClient, which closes the API-service if, and only if, it created
the API-service itself.
DigipostClient kept the API-service it created itself in a nullable
field, where null meant "the caller provided the API-services, and
owns them". A no-op AutoCloseable states the same intent without the
null check, and close() no longer needs to know what it is closing.
On 401 from the server, invalidate the used access token and fetch a new
one, before retrying the request.

Implementing this as an ExecChainHandler, and placing it before
ChainElement.PROTOCOL, allowing the other interceptors to run again,
without touching the HttpRequestRetryStrategy. The handler proceeds down
to the chain at most twice and never inspects the second response, so we
don't need a counter to ensure that the request is only retried once.

Also adding a VerifyUnlessUnauthorized-wrapper-interceptor, that only
uses the wrapped response interceptor if the response was NOT a 401.
This ensures that the wrapped interceptor does not throw an exception
due to missing/invalid headers before we can handle the 401.
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.

4 participants