Skip to content

OAuth/JWT-autentisering - #232

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

OAuth/JWT-autentisering#232
fredrbus wants to merge 19 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)

.setSslContext(sslContext)
.build())
.build());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Mogleg det er nice å kunne konfigurere timeouts på denne klienten, nå er den på 10 sek fra defaultsa.

Hilsen en som har brent seg på lange timeouts mer enn én gang

}
} else {
setSignatureHeader(httpRequest);
private static void verifyContentIsHashed(HttpRequest httpRequest) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Vet ikke om det er meningen, men denne sjekken ser ut til å bare bli gjort for cert-auth fordi RequestSignatureInterceptor ikke er lagt til i createJwtAuthenticatingHttpClient().

Headeren X-Content-SHA256 ser ut til å være påkrevd for både cert og jwt i dpost-api


private final JwtAuthConfig config;
private final Clock clock;
private final CloseableHttpClient tokenClient;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pirkings: Mangler close av tokenClient

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

- **Certificate-based signing:** each request is signed with a private key. Use
`DigipostClient.withCertificateAuthentication(...)`.

The chosen method is stated explicitly in the factory method you call.

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.

Ønsker vi her å fremheve JWT-authen som det "korrekte" valget? Feks ved å kalle den signatur-baserte løsningen "legacy"?

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.


CloseableHttpClient httpClient = httpClientBuilder
.setConnectionManager(HttpClientConnectionManagerFactory.createDefaultBuilder()
.setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()

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.

setSSLSocketFactory og SSLConnectionSocketFactoryBuilder er deprecated. Et raskt google-søk foreslår å bruke TlsSocketStrategy.

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?

Comment on lines +33 to +34
(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/].

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.

Feil markdown i disse to lenkene, skal være (text)[url]

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