Skip to content

OAuth/JWT-autentisering - #232

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

fredrbus wants to merge 40 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

Endringene er ikke bakoverkompatible. Legacy sertifikatautentisering støttes av Digipost API, men ikke dette klientbiblioteket f.o.m. v19.

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.

@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 Outdated
Comment thread docs/_v19_x/1_client_config.md Outdated
Comment thread src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java Outdated
Comment thread src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java Outdated
Comment thread src/main/java/no/digipost/api/client/internal/ApiServiceImpl.java
Comment thread docs/_v19_x/1_client_config.md Outdated
@eivinhb

eivinhb commented Sep 17, 2026

Copy link
Copy Markdown
Member

Det jeg ikke helt henger med på er hvorfor vi trenger å lage dette bakoverkompatibelt. Nå legges det opp til en ny major, men jeg ser ikke noe breaking i apiet her.
Er det noen grunn til at vi ikke kan fjerne legacy-signing helt? Vi kan release dette som en RC1 i første omgang om vi vil ha en release for oss selv.

@arneroen

Copy link
Copy Markdown
Member

Det jeg ikke helt henger med på er hvorfor vi trenger å lage dette bakoverkompatibelt. Nå legges det opp til en ny major, men jeg ser ikke noe breaking i apiet her. Er det noen grunn til at vi ikke kan fjerne legacy-signing helt? Vi kan release dette som en RC1 i første omgang om vi vil ha en release for oss selv.

Da jeg prata med Fredrik om det, var begrunnelsen at vi dessverre ikke kan gå ut ifra at alle kundene våre umiddelbart kommer seg over på ny auth-løsning. Ved å da beholde begge metodene, gjør vi det lettere for oss selv å potensielt patche sikkerhets-hull i framtiden, uten å måtte release to versjoner for gammel og ny auth.

Comment thread docs/_v19_x/1_client_config.md Outdated
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.
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.
BatchSendMessagesEksempel also dropped its custom connection manager.
The JWT client replaces the connection manager to install the mTLS
socket strategy.
Drops both DigipostClient.withCertificateAuthentication overloads, the
matching ApiServiceImpl factory and the interceptor chain it built.

JWT/mTLS is now the only way to construct a client.
Response signature verification is untouched as the server signs its
responses.
loadKeyFromP12 and sign were only ever called from Signer. JwtAuthConfig
loads its own keystore, so there is nothing left to reuse.
The constructor took a Function<ApiServiceImpl, CloseableHttpClient>
only so the two static factories could each pick their own mode. With
one mode left it can call the instance method directly.
Drops the legacy certificate section and the "choose one of
two"-framing. Instead, we add a migration section.
The API does not validate the cnf claim, so access tokens are not bound
to the client certificate and the API client has no need to present it.

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.

6 participants