From bcba63ab81bdddda5fe5b8263949c059da1de593 Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Thu, 1 Feb 2024 12:03:21 +0100 Subject: [PATCH] ARC-1217: GesundheitsID Simple Test Flow (#7) * ARC-1217: Basic setup * ARC-1217: Include fully working authentication flow. * ARC-1217: Readme * ARC-1217: Review findings --- .gitignore | 1 + README.md | 100 ++++++++++ gesundheitsid/env.properties.example | 1 + gesundheitsid/pom.xml | 7 + .../gesundheitsid/auth/AuthExceptions.java | 33 ++++ .../auth/AuthenticationFlow.java | 49 +++++ .../oviva/gesundheitsid/auth/IdTokenJWS.java | 27 +++ .../steps/SelectSectoralIdpStepImpl.java | 138 ++++++++++++++ .../steps/TrustedSectoralIdpStepImpl.java | 86 +++++++++ .../auth/steps/SelectSectoralIdpStep.java | 14 ++ .../auth/steps/TrustedSectoralIdpStep.java | 14 ++ .../oviva/gesundheitsid/crypto/ECKeyPair.java | 6 + .../gesundheitsid/crypto/KeySupplier.java | 6 + .../fedclient/api/EntityStatement.java | 83 +++++++- .../auth/AuthenticationFlowExampleTest.java | 179 ++++++++++++++++++ .../auth/AuthenticationFlowTest.java | 29 +++ .../steps/SelectSectoralIdpStepImplTest.java | 96 ++++++++++ .../steps/TrustedSectoralIdpStepImplTest.java | 131 +++++++++++++ .../gesundheitsid/crypto/JwsVerifierTest.java | 1 - .../FederationMasterClientImplTest.java | 2 +- .../api/EntityStatementBuilderTest.java | 14 +- .../test/ECKeyPairGenerator.java | 3 +- .../GematikHeaderDecoratorHttpClient.java | 7 +- .../oviva/gesundheitsid/test/JwksUtils.java | 15 +- oidc-server/pom.xml | 115 +++++++++++ 25 files changed, 1148 insertions(+), 9 deletions(-) create mode 100644 README.md create mode 100644 gesundheitsid/env.properties.example create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthenticationFlow.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImpl.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImpl.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/SelectSectoralIdpStep.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/TrustedSectoralIdpStep.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/KeySupplier.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowTest.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImplTest.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java create mode 100644 oidc-server/pom.xml diff --git a/.gitignore b/.gitignore index 5fd87f2..747efea 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ target/ gesundheitsid/env.properties *.iml gesundheitsid/dependency-reduced-pom.xml +*_jwks.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b39251 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=oviva-ag_keycloak-gesundheitsid&metric=alert_status&token=64c09371c0f6c1d729fc0b0424706cd54011cb90)](https://sonarcloud.io/summary/new_code?id=oviva-ag_keycloak-gesundheitsid) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=oviva-ag_keycloak-gesundheitsid&metric=coverage&token=64c09371c0f6c1d729fc0b0424706cd54011cb90)](https://sonarcloud.io/summary/new_code?id=oviva-ag_keycloak-gesundheitsid) + +# Keycloak Identity Provider for GesundheitsID (eHealthID) + +## Contents +- [gesundheitsid](./gesundheitsid) - A plain Java library to build RelyingParties for GesundheitsID. + - API clients + - Models for the EntityStatments, IDP list endpoints etc. + - Narrow support for the 'Fachdienst' use-case. + +## End-to-End Test flow with Gematik Reference IDP + +**Prerequisites**: + +1. Setup your test environment, your own issuer **MUST** serve a **VALID** and **TRUSTED** entity + statement. See [Gematik docs](https://wiki.gematik.de/pages/viewpage.action?pageId=544316583) +2. Setup the file `.env.properties` to provide + the [X-Authorization header](https://wiki.gematik.de/display/IDPKB/Fachdienste+Test-Umgebungen) + for the Gematik +3. Setup the JWK sets for signing and encryption keys + +```java +public class Example { + + public static void main(String[] args) { + + // ... setup, see full example linked below + + var flow = + new AuthenticationFlow( + self, fedmasterClient, openIdClient, relyingPartyEncryptionJwks::getKeyByKeyId); + + // these should come from the client in the real world + var verifier = generateCodeVerifier(); + var codeChallenge = calculateS256CodeChallenge(verifier); + + // ==== 1) start a new flow + var step1 = flow.start(new Session("test", "test", redirectUri, codeChallenge, scopes)); + + // ==== 2) get the list of available IDPs + var idps = step1.fetchIdpOptions(); + + // ==== 3) select and IDP + + // for now we hardcode the reference IDP from Gematik + var sektoralerIdpIss = "https://gsi.dev.gematik.solutions"; + + var step2 = step1.redirectToSectoralIdp(sektoralerIdpIss); + + var idpRedirectUri = step2.idpRedirectUri(); + + // ==== 3a) do in-code authentication flow, this is in reality the proprietary flow + var redirectResult = doFederatedAuthFlow(idpRedirectUri); + System.out.println(redirectResult); + + var values = parseQuery(redirectResult); + var code = values.get("code"); + + // ==== 4) exchange the code for the ID token + var token = step2.exchangeSectoralIdpCode(code, verifier); + + // Success! Let's print it. + var om = new ObjectMapper().configure(SerializationFeature.INDENT_OUTPUT, true); + System.out.println(om.writeValueAsString(token.body())); + } +} + +``` + +See [AuthenticationFlowExampleTest](https://github.com/oviva-ag/keycloak-gesundheitsid/blob/8751c92e45531f6cdca204b8db18a2825e05e69a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java#L44-L117) + +## Working with Gematik Test Environment + + +### Gematik Test Sektoraler IdP in Browser + +Since the Gematik reference IDP in the Test Environment needs a custom header, it can not be used directly in the browser for authentication. +Setting up a proxy with a header filter can get around that limitation though. + +**Prerequisite:** Install some Chrome-ish browser like [Thorium](https://github.com/Alex313031/Thorium-MacOS/releases) or Chromium. + +1. launch `mitmweb`: `mitmweb -p 8881 --web-port=8882` +2. launch Chrome-like browser + ``` + /Applications/Thorium.app/Contents/MacOS/Thorium --proxy-server=http://localhost:8881 + ``` +3. setup `modify_headers` option + ```mitmproxy + # modify_headers filter + /~q & ~d gsi.dev.gematik.solutions/X-Authorization/ + ``` + + +## Helpful Links + +- [AppFlow - Authentication flow to implement](https://wiki.gematik.de/display/IDPKB/App-App+Flow#AppAppFlow-0-FederationMaster) +- [Sektoraler IDP - Examples & Reference Implementation](https://wiki.gematik.de/display/IDPKB/Sektoraler+IDP+-+Referenzimplementierung+und+Beispiele) +- [OpenID Federation Spec](https://openid.net/specs/openid-federation-1_0.html) + diff --git a/gesundheitsid/env.properties.example b/gesundheitsid/env.properties.example new file mode 100644 index 0000000..35abafa --- /dev/null +++ b/gesundheitsid/env.properties.example @@ -0,0 +1 @@ +GEMATIK_AUTH_HEADER= \ No newline at end of file diff --git a/gesundheitsid/pom.xml b/gesundheitsid/pom.xml index 6d955b1..c03e0f4 100644 --- a/gesundheitsid/pom.xml +++ b/gesundheitsid/pom.xml @@ -97,6 +97,13 @@ + + org.jsoup + jsoup + 1.16.1 + test + + org.jboss.resteasy resteasy-core diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java new file mode 100644 index 0000000..7d4d594 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java @@ -0,0 +1,33 @@ +package com.oviva.gesundheitsid.auth; + +public class AuthExceptions { + private AuthExceptions() {} + + public static RuntimeException invalidParRequestUri(String uri) { + return new RuntimeException("invalid par request_uri '%s'".formatted(uri)); + } + + public static RuntimeException missingAuthorizationUrl(String sub) { + return new RuntimeException( + "entity statement of '%s' has no authorization url configuration".formatted(sub)); + } + + public static RuntimeException missingParUrl(String sub) { + return new RuntimeException( + "entity statement of '%s' has no pushed authorization request configuration" + .formatted(sub)); + } + + public static RuntimeException missingOpenIdConfigurationInEntityStatement(String sub) { + return new RuntimeException( + "entity statement of '%s' lacks openid configuration".formatted(sub)); + } + + public static RuntimeException badIdTokenSignature(String issuer) { + return new RuntimeException("bad ID token signature from sub=%s".formatted(issuer)); + } + + public static RuntimeException badIdToken(String issuer, Exception cause) { + return new RuntimeException("bad ID token from sub=%s".formatted(issuer), cause); + } +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthenticationFlow.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthenticationFlow.java new file mode 100644 index 0000000..4995717 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthenticationFlow.java @@ -0,0 +1,49 @@ +package com.oviva.gesundheitsid.auth; + +import com.oviva.gesundheitsid.auth.internal.steps.SelectSectoralIdpStepImpl; +import com.oviva.gesundheitsid.auth.steps.SelectSectoralIdpStep; +import com.oviva.gesundheitsid.crypto.KeySupplier; +import com.oviva.gesundheitsid.fedclient.FederationMasterClient; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.net.URI; +import java.util.List; + +public class AuthenticationFlow { + + private final URI selfIssuer; + private final FederationMasterClient federationMasterClient; + + private final OpenIdClient openIdClient; + + private final KeySupplier relyingPartyKeySupplier; + + public AuthenticationFlow( + @NonNull URI selfIssuer, + @NonNull FederationMasterClient federationMasterClient, + @NonNull OpenIdClient openIdClient, + @NonNull KeySupplier relyingPartyKeySupplier) { + this.selfIssuer = selfIssuer; + this.federationMasterClient = federationMasterClient; + this.openIdClient = openIdClient; + this.relyingPartyKeySupplier = relyingPartyKeySupplier; + } + + @NonNull + public SelectSectoralIdpStep start(@NonNull Session session) { + + return new SelectSectoralIdpStepImpl( + selfIssuer, + federationMasterClient, + openIdClient, + relyingPartyKeySupplier, + session.callbackUri(), + session.nonce(), + session.codeChallengeS256(), + session.state(), + session.scopes()); + } + + public record Session( + String state, String nonce, URI callbackUri, String codeChallengeS256, List scopes) {} +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java new file mode 100644 index 0000000..d28c7e8 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java @@ -0,0 +1,27 @@ +package com.oviva.gesundheitsid.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nimbusds.jose.JWSObject; + +public record IdTokenJWS(JWSObject jws, IdToken body) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record IdToken( + @JsonProperty("iss") String iss, + @JsonProperty("sub") String sub, + @JsonProperty("aud") String aud, + @JsonProperty("iat") long iat, + @JsonProperty("exp") long exp, + @JsonProperty("nbf") long nbf, + @JsonProperty("nonce") String nonce, + @JsonProperty("acr") String acr, + @JsonProperty("amr") String amr, + @JsonProperty("email") String email, + @JsonProperty("urn:telematik:claims:profession") String telematikProfession, + @JsonProperty("urn:telematik:claims:given_name") String telematikGivenName, + + // for insured person (IP) the immutable part of the Krankenversichertennummer (KVNR) + @JsonProperty("urn:telematik:claims:id") String telematikKvnr, + @JsonProperty("urn:telematik:claims:email") String telematikEmail) {} +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImpl.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImpl.java new file mode 100644 index 0000000..b148df3 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImpl.java @@ -0,0 +1,138 @@ +package com.oviva.gesundheitsid.auth.internal.steps; + +import com.oviva.gesundheitsid.auth.AuthExceptions; +import com.oviva.gesundheitsid.auth.steps.SelectSectoralIdpStep; +import com.oviva.gesundheitsid.auth.steps.TrustedSectoralIdpStep; +import com.oviva.gesundheitsid.crypto.KeySupplier; +import com.oviva.gesundheitsid.fedclient.FederationMasterClient; +import com.oviva.gesundheitsid.fedclient.IdpEntry; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenidProvider; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient.ParResponse; +import com.oviva.gesundheitsid.fedclient.api.ParBodyBuilder; +import edu.umd.cs.findbugs.annotations.NonNull; +import jakarta.ws.rs.core.UriBuilder; +import java.net.URI; +import java.util.List; + +/** + * Official documentation: - + * https://wiki.gematik.de/display/IDPKB/App-App+Flow#AppAppFlow-0-FederationMaster + */ +public class SelectSectoralIdpStepImpl implements SelectSectoralIdpStep { + + private final URI selfIssuer; + private final FederationMasterClient fedMasterClient; + private final OpenIdClient openIdClient; + private final KeySupplier relyingPartyEncKeySupplier; + + private final URI callbackUri; + private final String nonce; + private final String codeChallengeS256; + private final String state; + private final List scopes; + + public SelectSectoralIdpStepImpl( + URI selfIssuer, + FederationMasterClient fedMasterClient, + OpenIdClient openIdClient, + KeySupplier relyingPartyEncKeySupplier1, + URI callbackUri, + String nonce, + String codeChallengeS256, + String state, + List scopes) { + this.selfIssuer = selfIssuer; + this.fedMasterClient = fedMasterClient; + this.openIdClient = openIdClient; + this.relyingPartyEncKeySupplier = relyingPartyEncKeySupplier1; + this.callbackUri = callbackUri; + this.nonce = nonce; + this.codeChallengeS256 = codeChallengeS256; + this.state = state; + this.scopes = scopes; + } + + @NonNull + @Override + public List fetchIdpOptions() { + return fedMasterClient.listAvailableIdps(); + } + + @Override + public @NonNull TrustedSectoralIdpStep redirectToSectoralIdp(@NonNull String sectoralIdpIss) { + + var trustedIdpEntityStatement = fedMasterClient.establishIdpTrust(URI.create(sectoralIdpIss)); + + // start PAR with sectoral IdP + // https://datatracker.ietf.org/doc/html/rfc9126 + + var parBody = + ParBodyBuilder.create() + .clientId(selfIssuer.toString()) + .codeChallenge(codeChallengeS256) + .codeChallengeMethod("S256") + .redirectUri(callbackUri) + .nonce(nonce) + .state(state) + .scopes(scopes) + .acrValues("gematik-ehealth-loa-high") + .responseType("code"); + + var res = doPushedAuthorizationRequest(parBody, trustedIdpEntityStatement.body()); + + var redirectUri = buildAuthorizationUrl(res.requestUri(), trustedIdpEntityStatement.body()); + + return new TrustedSectoralIdpStepImpl( + openIdClient, + selfIssuer, + redirectUri, + callbackUri, + trustedIdpEntityStatement, + relyingPartyEncKeySupplier); + } + + private URI buildAuthorizationUrl(String parRequestUri, EntityStatement trustedEntityStatement) { + + if (parRequestUri == null || parRequestUri.isBlank()) { + throw AuthExceptions.invalidParRequestUri(parRequestUri); + } + + var openidConfig = getIdpOpenIdProvider(trustedEntityStatement); + var authzEndpoint = openidConfig.authorizationEndpoint(); + + if (authzEndpoint == null || authzEndpoint.isBlank()) { + throw AuthExceptions.missingAuthorizationUrl(trustedEntityStatement.sub()); + } + + return UriBuilder.fromUri(authzEndpoint) + .queryParam("request_uri", parRequestUri) + .queryParam("client_id", selfIssuer.toString()) + .build(); + } + + private ParResponse doPushedAuthorizationRequest( + ParBodyBuilder builder, EntityStatement trustedEntityStatement) { + + var openidConfig = getIdpOpenIdProvider(trustedEntityStatement); + var parEndpoint = openidConfig.pushedAuthorizationRequestEndpoint(); + if (parEndpoint == null || parEndpoint.isBlank()) { + throw AuthExceptions.missingParUrl(trustedEntityStatement.sub()); + } + + return openIdClient.requestPushedUri(URI.create(parEndpoint), builder); + } + + private OpenidProvider getIdpOpenIdProvider( + @NonNull EntityStatement trustedIdpEntityConfiguration) { + + if (trustedIdpEntityConfiguration.metadata() == null + || trustedIdpEntityConfiguration.metadata().openidProvider() == null) { + throw AuthExceptions.missingOpenIdConfigurationInEntityStatement( + trustedIdpEntityConfiguration.sub()); + } + + return trustedIdpEntityConfiguration.metadata().openidProvider(); + } +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImpl.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImpl.java new file mode 100644 index 0000000..8407640 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImpl.java @@ -0,0 +1,86 @@ +package com.oviva.gesundheitsid.auth.internal.steps; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWEObject; +import com.nimbusds.jose.crypto.MultiDecrypter; +import com.oviva.gesundheitsid.auth.AuthExceptions; +import com.oviva.gesundheitsid.auth.IdTokenJWS; +import com.oviva.gesundheitsid.auth.IdTokenJWS.IdToken; +import com.oviva.gesundheitsid.auth.steps.TrustedSectoralIdpStep; +import com.oviva.gesundheitsid.crypto.JwsVerifier; +import com.oviva.gesundheitsid.crypto.KeySupplier; +import com.oviva.gesundheitsid.fedclient.api.EntityStatementJWS; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; +import com.oviva.gesundheitsid.util.JsonCodec; +import com.oviva.gesundheitsid.util.JsonPayloadTransformer; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.net.URI; +import java.text.ParseException; + +public class TrustedSectoralIdpStepImpl implements TrustedSectoralIdpStep { + + private final OpenIdClient openIdClient; + + private final URI selfIssuer; + private final URI idpRedirectUri; + private final URI callbackUri; + private final EntityStatementJWS trustedIdpEntityStatement; + private final KeySupplier relyingPartyEncKeySupplier; + + public TrustedSectoralIdpStepImpl( + @NonNull OpenIdClient openIdClient, + @NonNull URI selfIssuer, + @NonNull URI idpRedirectUri, + @NonNull URI callbackUri, + @NonNull EntityStatementJWS trustedIdpEntityStatement, + @NonNull KeySupplier relyingPartyEncKeySupplier) { + this.openIdClient = openIdClient; + this.selfIssuer = selfIssuer; + this.idpRedirectUri = idpRedirectUri; + this.callbackUri = callbackUri; + this.trustedIdpEntityStatement = trustedIdpEntityStatement; + this.relyingPartyEncKeySupplier = relyingPartyEncKeySupplier; + } + + @Override + public @NonNull URI idpRedirectUri() { + return idpRedirectUri; + } + + @NonNull + @Override + public IdTokenJWS exchangeSectoralIdpCode(@NonNull String code, @NonNull String codeVerifier) { + + var tokenEndpoint = + trustedIdpEntityStatement.body().metadata().openidProvider().tokenEndpoint(); + var res = + openIdClient.exchangePkceCode( + URI.create(tokenEndpoint), + code, + callbackUri.toString(), + selfIssuer.toString(), + codeVerifier); + + try { + var jweObject = JWEObject.parse(res.idToken()); + var decrypter = + new MultiDecrypter(relyingPartyEncKeySupplier.apply(jweObject.getHeader().getKeyID())); + jweObject.decrypt(decrypter); + + var signedJws = jweObject.getPayload().toJWSObject(); + + if (!JwsVerifier.verify(trustedIdpEntityStatement.body().jwks(), signedJws)) { + throw AuthExceptions.badIdTokenSignature(trustedIdpEntityStatement.body().sub()); + } + + var payload = + signedJws + .getPayload() + .toType(new JsonPayloadTransformer<>(IdToken.class, JsonCodec::readValue)); + return new IdTokenJWS(signedJws, payload); + + } catch (JOSEException | ParseException e) { + throw AuthExceptions.badIdToken(trustedIdpEntityStatement.body().sub(), e); + } + } +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/SelectSectoralIdpStep.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/SelectSectoralIdpStep.java new file mode 100644 index 0000000..1bd7293 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/SelectSectoralIdpStep.java @@ -0,0 +1,14 @@ +package com.oviva.gesundheitsid.auth.steps; + +import com.oviva.gesundheitsid.fedclient.IdpEntry; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.List; + +public interface SelectSectoralIdpStep { + + @NonNull + List fetchIdpOptions(); + + @NonNull + TrustedSectoralIdpStep redirectToSectoralIdp(@NonNull String sectoralIdpIss); +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/TrustedSectoralIdpStep.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/TrustedSectoralIdpStep.java new file mode 100644 index 0000000..3a4e3e7 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/TrustedSectoralIdpStep.java @@ -0,0 +1,14 @@ +package com.oviva.gesundheitsid.auth.steps; + +import com.oviva.gesundheitsid.auth.IdTokenJWS; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.net.URI; + +public interface TrustedSectoralIdpStep { + + @NonNull + URI idpRedirectUri(); + + @NonNull + IdTokenJWS exchangeSectoralIdpCode(@NonNull String code, @NonNull String codeVerifier); +} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java new file mode 100644 index 0000000..650e0b3 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java @@ -0,0 +1,6 @@ +package com.oviva.gesundheitsid.crypto; + +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; + +public record ECKeyPair(ECPublicKey pub, ECPrivateKey priv) {} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/KeySupplier.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/KeySupplier.java new file mode 100644 index 0000000..65a2aa0 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/KeySupplier.java @@ -0,0 +1,6 @@ +package com.oviva.gesundheitsid.crypto; + +import com.nimbusds.jose.jwk.JWK; +import java.util.function.Function; + +public interface KeySupplier extends Function {} diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java index 8394ca4..6173dd2 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java @@ -49,7 +49,79 @@ public record OpenidProvider( @JsonProperty("authorization_endpoint") String authorizationEndpoint, @JsonProperty("scopes_supported") List scopesSupported, @JsonProperty("grant_types_supported") List grantTypesSupported, - @JsonProperty("user_type_supported") List userTypeSupported) {} + @JsonProperty("user_type_supported") List userTypeSupported) { + + public static Builder create() { + return new Builder(); + } + + public static final class Builder { + + private String pushedAuthorizationRequestEndpoint; + private String issuer; + private Boolean requirePushedAuthorizationRequests; + private String tokenEndpoint; + private String authorizationEndpoint; + private List scopesSupported; + private List grantTypesSupported; + private List userTypeSupported; + + private Builder() {} + + public Builder pushedAuthorizationRequestEndpoint(String pushedAuthorizationRequestEndpoint) { + this.pushedAuthorizationRequestEndpoint = pushedAuthorizationRequestEndpoint; + return this; + } + + public Builder issuer(String issuer) { + this.issuer = issuer; + return this; + } + + public Builder requirePushedAuthorizationRequests( + Boolean requirePushedAuthorizationRequests) { + this.requirePushedAuthorizationRequests = requirePushedAuthorizationRequests; + return this; + } + + public Builder tokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + + public Builder authorizationEndpoint(String authorizationEndpoint) { + this.authorizationEndpoint = authorizationEndpoint; + return this; + } + + public Builder scopesSupported(List scopesSupported) { + this.scopesSupported = scopesSupported; + return this; + } + + public Builder grantTypesSupported(List grantTypesSupported) { + this.grantTypesSupported = grantTypesSupported; + return this; + } + + public Builder userTypeSupported(List userTypeSupported) { + this.userTypeSupported = userTypeSupported; + return this; + } + + public OpenidProvider build() { + return new OpenidProvider( + pushedAuthorizationRequestEndpoint, + issuer, + requirePushedAuthorizationRequests, + tokenEndpoint, + authorizationEndpoint, + scopesSupported, + grantTypesSupported, + userTypeSupported); + } + } + } @JsonIgnoreProperties(ignoreUnknown = true) public record Metadata( @@ -70,6 +142,8 @@ public static final class Builder { private OpenIdRelyingParty openIdRelyingParty; private FederationEntity federationEntity; + private OpenidProvider openidProvider; + private Builder() {} public Builder openIdRelyingParty(OpenIdRelyingParty openIdRelyingParty) { @@ -82,8 +156,13 @@ public Builder federationEntity(FederationEntity federationEntity) { return this; } + public Builder openidProvider(OpenidProvider openidProvider) { + this.openidProvider = openidProvider; + return this; + } + public Metadata build() { - return new Metadata(null, openIdRelyingParty, federationEntity); + return new Metadata(openidProvider, openIdRelyingParty, federationEntity); } } } diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java new file mode 100644 index 0000000..69c6c4e --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java @@ -0,0 +1,179 @@ +package com.oviva.gesundheitsid.auth; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.oviva.gesundheitsid.auth.AuthenticationFlow.Session; +import com.oviva.gesundheitsid.fedclient.FederationMasterClientImpl; +import com.oviva.gesundheitsid.fedclient.api.CachedFederationApiClient; +import com.oviva.gesundheitsid.fedclient.api.FederationApiClientImpl; +import com.oviva.gesundheitsid.fedclient.api.InMemoryCacheImpl; +import com.oviva.gesundheitsid.fedclient.api.JavaHttpClient; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; +import com.oviva.gesundheitsid.fedclient.api.UrlFormBodyBuilder; +import com.oviva.gesundheitsid.test.Environment; +import com.oviva.gesundheitsid.test.GematikHeaderDecoratorHttpClient; +import com.oviva.gesundheitsid.test.JwksUtils; +import java.io.IOException; +import java.net.URI; +import java.net.URLDecoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.jsoup.Jsoup; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class AuthenticationFlowExampleTest { + + @Test + @Disabled("e2e") + void flowIntegrationTest() throws IOException, InterruptedException { + + // setup your environment, your own issuer MUST serve a _valid_ and _trusted_ entity + // configuration + // see: https://wiki.gematik.de/pages/viewpage.action?pageId=544316583 + var fedmaster = URI.create("https://app-test.federationmaster.de"); + var self = URI.create("https://idp-test.oviva.io/auth/realms/master/ehealthid"); + + // this URI must be listed in your entity statement, configure as needed + var redirectUri = URI.create("http://localhost:8080"); + + // those _MUST_ be at most the ones you requested when handing in the entity statement + var scopes = List.of("openid", "urn:telematik:email", "urn:telematik:versicherter"); + + // path to the JWKS containing the private keys to decrypt ID tokens, the public part + // is in your entity configuration + var relyingPartyEncryptionJwks = JwksUtils.load(Path.of("../relying-party-enc_jwks.json")); + + // setup the file `.env.properties` to provide the X-Authorization header for the Gematik + // test environment + // see: https://wiki.gematik.de/display/IDPKB/Fachdienste+Test-Umgebungen + var httpClient = new GematikHeaderDecoratorHttpClient(new JavaHttpClient()); + + // setup as needed + var clock = Clock.systemUTC(); + var ttl = Duration.ofMinutes(5); + + var federationApiClient = + new CachedFederationApiClient( + new FederationApiClientImpl(httpClient), + new InMemoryCacheImpl<>(clock, ttl), + new InMemoryCacheImpl<>(clock, ttl), + new InMemoryCacheImpl<>(clock, ttl)); + + var fedmasterClient = new FederationMasterClientImpl(fedmaster, federationApiClient, clock); + var openIdClient = new OpenIdClient(httpClient); + + var flow = + new AuthenticationFlow( + self, fedmasterClient, openIdClient, relyingPartyEncryptionJwks::getKeyByKeyId); + + // these should come from the client in the real world + var verifier = generateCodeVerifier(); + var codeChallenge = calculateS256CodeChallenge(verifier); + + // ==== 1) start a new flow + var step1 = flow.start(new Session("test", "test", redirectUri, codeChallenge, scopes)); + + // ==== 2) get the list of available IDPs + var idps = step1.fetchIdpOptions(); + + // ==== 3) select and IDP + + // for now we hardcode the reference IDP from Gematik + var sektoralerIdpIss = "https://gsi.dev.gematik.solutions"; + + var step2 = step1.redirectToSectoralIdp(sektoralerIdpIss); + + var idpRedirectUri = step2.idpRedirectUri(); + + // ==== 3a) do in-code authentication flow, this is in reality the proprietary flow + var redirectResult = doFederatedAuthFlow(idpRedirectUri); + System.out.println(redirectResult); + + var values = parseQuery(redirectResult); + var code = values.get("code"); + + // ==== 4) exchange the code for the ID token + var token = step2.exchangeSectoralIdpCode(code, verifier); + + // Success! Let's print it. + var om = new ObjectMapper().configure(SerializationFeature.INDENT_OUTPUT, true); + System.out.println(om.writeValueAsString(token.body())); + } + + private Map parseQuery(String uri) { + var u = URI.create(uri); + var raw = u.getQuery(); + + return Arrays.stream(raw.split("&")) + .map(p -> Arrays.asList(p.split("="))) + .map( + splits -> + splits.stream().map(s -> URLDecoder.decode(s, StandardCharsets.UTF_8)).toList()) + .collect(Collectors.toMap(s -> s.get(0), s -> s.get(1))); + } + + private String doFederatedAuthFlow(URI parUri) throws IOException, InterruptedException { + + var client = HttpClient.newHttpClient(); + + var doc = + Jsoup.connect(parUri.toString()) + .header("X-Authorization", Environment.gematikAuthHeader()) + .get(); + + var form = doc.forms().stream().findFirst().orElseThrow(); + var action = form.absUrl("action"); + + var formBuilder = UrlFormBodyBuilder.create(); + form.formData().forEach(k -> formBuilder.param(k.key(), k.value())); + var urlEncoded = formBuilder.build(); + + var formSubmit = action + "?" + new String(urlEncoded, StandardCharsets.UTF_8); + + var req = + HttpRequest.newBuilder() + .GET() + .header("X-Authorization", Environment.gematikAuthHeader()) + .uri(URI.create(formSubmit)) + .build(); + + var res = client.send(req, BodyHandlers.ofString()); + assertEquals(302, res.statusCode(), "response was:\n%s".formatted(res)); + + return res.headers().firstValue("location").orElseThrow(); + } + + private String calculateS256CodeChallenge(String codeVerifier) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private String generateCodeVerifier() { + var rng = new SecureRandom(); + + var bytes = new byte[32]; + rng.nextBytes(bytes); + return Base64.getUrlEncoder().encodeToString(bytes); + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowTest.java new file mode 100644 index 0000000..be530bd --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowTest.java @@ -0,0 +1,29 @@ +package com.oviva.gesundheitsid.auth; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.oviva.gesundheitsid.auth.AuthenticationFlow.Session; +import com.oviva.gesundheitsid.crypto.KeySupplier; +import com.oviva.gesundheitsid.fedclient.FederationMasterClient; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AuthenticationFlowTest { + + @Test + void start() { + var self = URI.create("https://fachdienst.example.com"); + var fedmasterClient = mock(FederationMasterClient.class); + var openIdClient = mock(OpenIdClient.class); + var keySupplier = mock(KeySupplier.class); + + var flow = new AuthenticationFlow(self, fedmasterClient, openIdClient, keySupplier); + + var step = flow.start(new Session(null, null, null, null, List.of())); + + assertNotNull(step); + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImplTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImplTest.java new file mode 100644 index 0000000..8e97800 --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/SelectSectoralIdpStepImplTest.java @@ -0,0 +1,96 @@ +package com.oviva.gesundheitsid.auth.internal.steps; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.oviva.gesundheitsid.fedclient.FederationMasterClient; +import com.oviva.gesundheitsid.fedclient.IdpEntry; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement.Metadata; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenidProvider; +import com.oviva.gesundheitsid.fedclient.api.EntityStatementJWS; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient.ParResponse; +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SelectSectoralIdpStepImplTest { + + @Test + void fetchIdpOptions() { + + var fedmasterClient = mock(FederationMasterClient.class); + + var sut = + new SelectSectoralIdpStepImpl( + null, fedmasterClient, null, null, null, null, null, null, null); + + var entries = List.of(new IdpEntry("https://tk.example.com", "Techniker KK", null)); + when(fedmasterClient.listAvailableIdps()).thenReturn(entries); + + // when + var idps = sut.fetchIdpOptions(); + + // then + assertEquals(entries, idps); + } + + @Test + void redirectToSectoralIdp() { + + var self = URI.create("https://fachdienst.example.com"); + var callbackUri = self.resolve("/callback"); + + var fedmasterClient = mock(FederationMasterClient.class); + var openIdClient = mock(OpenIdClient.class); + + var sut = + new SelectSectoralIdpStepImpl( + self, + fedmasterClient, + openIdClient, + null, + callbackUri, + null, + "test", + "test-state", + List.of()); + + var sectoralIdp = URI.create("https://tk.example.com"); + + var entityConfig = sectoralIdpEntityConfiguration(sectoralIdp); + when(fedmasterClient.establishIdpTrust(sectoralIdp)).thenReturn(entityConfig); + + var parResponse = new ParResponse(sectoralIdp.resolve("/auth").toString(), 0); + when(openIdClient.requestPushedUri(any(), any())).thenReturn(parResponse); + + // when + var step = sut.redirectToSectoralIdp(sectoralIdp.toString()); + + // then + assertEquals( + "https://tk.example.com/auth?request_uri=https%3A%2F%2Ftk.example.com%2Fauth&client_id=https%3A%2F%2Ffachdienst.example.com", + step.idpRedirectUri().toString()); + } + + private EntityStatementJWS sectoralIdpEntityConfiguration(URI sub) { + var body = + EntityStatement.create() + .sub(sub.toString()) + .iss(sub.toString()) + .metadata( + Metadata.create() + .openidProvider( + OpenidProvider.create() + .pushedAuthorizationRequestEndpoint(sub.resolve("/par").toString()) + .authorizationEndpoint(sub.resolve("/auth").toString()) + .build()) + .build()) + .build(); + + return new EntityStatementJWS(null, body); + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java new file mode 100644 index 0000000..3122913 --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java @@ -0,0 +1,131 @@ +package com.oviva.gesundheitsid.auth.internal.steps; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement.Metadata; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenidProvider; +import com.oviva.gesundheitsid.fedclient.api.EntityStatementJWS; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient.TokenResponse; +import java.net.URI; +import java.text.ParseException; +import org.junit.jupiter.api.Test; + +class TrustedSectoralIdpStepImplTest { + + @Test + void idpRedirectUri() { + + var redirectUri = URI.create("https://tk.example.com/auth?redirect_uri=urn:redirect:mystuff"); + + var sut = new TrustedSectoralIdpStepImpl(null, null, redirectUri, null, null, null); + + // when & then + assertEquals(redirectUri, sut.idpRedirectUri()); + } + + @Test + void exchangeSectoralIdpCode() throws ParseException { + + var selfIssuer = URI.create("https://fachdienst.example.com"); + var callbackUri = selfIssuer.resolve("/callback"); + + var openIdClient = mock(OpenIdClient.class); + var sectoralIdp = URI.create("https://gsi.dev.gematik.solutions"); + + var tokenEndpoint = sectoralIdp.resolve("/token"); + var redirectUri = sectoralIdp.resolve("/auth?redirect_uri=urn:redirect:mystuff"); + + var sectoralIdpJwks = loadSectoralIdpJwks(); + var idpEntityStatement = + EntityStatement.create() + .jwks(sectoralIdpJwks) + .metadata( + Metadata.create() + .openidProvider( + OpenidProvider.create().tokenEndpoint(tokenEndpoint.toString()).build()) + .build()) + .build(); + var idpJws = new EntityStatementJWS(null, idpEntityStatement); + + var code = "12345"; + var verifier = "38988d8d8"; + + var rawIdToken = + "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImN0eSI6IkpXVCIsImtpZCI6InJlbHlpbmctcGFydHktZW5jIiwiZXBrIjp7Imt0eSI6IkVDIiwieCI6IlpuOTZnX0p3YXY0dENZNE41VEtGQUlYaXlmaTlvV2s0OUpLVEQ3aFAtTUEiLCJ5IjoiYl9YQmVZRjJ4ME5JTDhVbXBEMTVubktueFhwbHRiRzZaX3dHTGlhQjBEUSIsImNydiI6IlAtMjU2In19.._B35CBOzkZQpiYMx.witIF5OyREmxO3GEizbN7nx0yrVPQr0A2FLsOPC2oyGcNlCrsk97XnY_I13A8EXq3IMtxJIFWe2TW8dI3Cz6js8Z0Nn-qc_xJu_zcRnkjFTEtHqcxniy5nEGqLjUPFt6CypzuXf2UD-q__IMv4rJ79ODVBJ3x2ezmJworI3l-goIM6xAd-fWx6X_f2JCtxepuQNfau11pvWLmSVe2N4yod67aFxiT2mH3zLmT-bmpK8nUYsguSm8fhyFwFBOxCXDTtVDYMkZFJDbYRDewFSiskFq7wdlrtNhqkTjL83JiiYzT26B-zeQE-23p-_YjnIdC6-Wk9n-D98JIWCRGulCrsLQToWN4AeN3ShTAx8GDj3q4lbN-mXhysi43FqML4Nwb3a-Ar-qfZSQX588hv79cXhgrDTPTon6uh3_dhaIneYxTA-3iM57o5f2fnIwscMq2ra6GE2TF0WFVdkgzy9Reo-LnzoZk_3BOt6_sSMxRpc6YDfTY1abz7W1ixl_VJGqJOhAAKDcOMq0fGtcrAbG6q4fxBSdRBszUGNcjSSNQSCkohNj6aTO0lJG9XNUpEvnJgJ-lXC3VGRXq-YuCiMTIbVRt31diVg8hnncnzIDt8hSiYjghgX-mZUax4P6KymKb_czNYgyTcIGHrcoFwJgoIRJFKQnQXocr375AIUYGkSzCE1ZobrDXDsQUWFKEoKKmD5PnDZMhWVmKT42jbovhkTlqilU7nHOVIJjCtmJnK1DhYpEpEUuLli75P5HUbO3IMRSOMvgetRB8UhLFzv17j9xQE4hdzTzD1OXU96B6DD0sZB2SA_KZclkUhj4WDgTZUa_dKAUuwlnymMDyRW_AXoX05K29Oe1jwdbVAKAPlFz1RH-OKjgtbK8KmmNVRYleM6rd_inb49GlR0fRt-TK9yYqDvkZAQ_0kwVecW4Wpm1zIfF2SXZ1qZsjTBRxtL9hme1onvH84k2AtSxq09EfnIEwLKqSJTRdWQ8Q5KKNPBkPgHVUwsweG9aBOhgm5Azkuzf9SATVLE5LNiUh3cyUEwMhHwrFaf-XsCebB7dp_WL83kNXA.ziIZQ31jn9NBqfaczBTzXQ"; + var res = new TokenResponse(null, 0, rawIdToken, null, null); + + when(openIdClient.exchangePkceCode( + eq(tokenEndpoint), + eq(code), + eq(callbackUri.toString()), + eq(selfIssuer.toString()), + eq(verifier))) + .thenReturn(res); + + var jwks = loadEncryptionKeys(); + var sut = + new TrustedSectoralIdpStepImpl( + openIdClient, selfIssuer, redirectUri, callbackUri, idpJws, jwks::getKeyByKeyId); + + // when + var idToken = sut.exchangeSectoralIdpCode(code, verifier); + + // then + assertEquals( + "X110411675-https://idp-test.oviva.io/auth/realms/master/ehealthid", idToken.body().sub()); + assertEquals(sectoralIdp.toString(), idToken.body().iss()); + } + + private JWKSet loadEncryptionKeys() throws ParseException { + return JWKSet.parse( + """ + { + "keys": [ + { + "kty": "EC", + "d": "SLACiqrEVQXgAKOFIA8HAenlumjUtho07rhqCBruJOk", + "use": "enc", + "crv": "P-256", + "kid": "relying-party-enc", + "x": "TGY6FLnl6I4PMR4OlhMZrK8Ln_4Fs47RTBYpKSiP2kc", + "y": "fs_HK7KbnJ7F7F3mv64lmjt2w5n_Bm3cXnRFTt-iHKU" + } + ] + } + """); + } + + private JWKSet loadSectoralIdpJwks() throws ParseException { + return JWKSet.parse( + """ + { + "keys": [ + { + "alg": "ES256", + "crv": "P-256", + "kid": "puk_idp_sig", + "kty": "EC", + "use": "sig", + "x": "Abt2Uyrk6KhczexlBOwJOTs_eB0DsFbcNxaxa0Z0vd4", + "y": "YZKBJtOUYEWTMknzFwBdl-6tVKyWnUDtxf2q0pST5X4" + }, + { + "alg": "ES256", + "crv": "P-256", + "kid": "puk_fed_idp_token", + "kty": "EC", + "use": "sig", + "x": "YzEPFvphu4T3GgWmjPXxPT0-Pdm_Q04OLENAH98zn-M", + "y": "AHPHggsq6YwFfW2fSIJtawMLAh9ZoKPFTZqPFgQW0t4" + } + ] + } + """); + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java index fc852e8..3eb6ed0 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java @@ -15,7 +15,6 @@ import com.nimbusds.jose.Payload; import com.nimbusds.jose.jwk.JWKSet; import com.oviva.gesundheitsid.test.ECKeyPairGenerator; -import com.oviva.gesundheitsid.test.ECKeyPairGenerator.ECKeyPair; import java.text.ParseException; import org.junit.jupiter.api.Test; diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java index bba1742..d3ab529 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +import com.oviva.gesundheitsid.crypto.ECKeyPair; import com.oviva.gesundheitsid.fedclient.api.EntityStatement; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.FederationEntity; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.Metadata; @@ -13,7 +14,6 @@ import com.oviva.gesundheitsid.fedclient.api.IdpList.IdpEntity; import com.oviva.gesundheitsid.fedclient.api.IdpListJWS; import com.oviva.gesundheitsid.test.ECKeyPairGenerator; -import com.oviva.gesundheitsid.test.ECKeyPairGenerator.ECKeyPair; import com.oviva.gesundheitsid.test.JwksUtils; import com.oviva.gesundheitsid.test.JwsUtils; import com.oviva.gesundheitsid.util.JsonCodec; diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java index 15f3592..eaf3c5f 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java @@ -8,6 +8,7 @@ import com.oviva.gesundheitsid.fedclient.api.EntityStatement.FederationEntity; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.Metadata; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenIdRelyingParty; +import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenidProvider; import com.oviva.gesundheitsid.test.ECKeyPairGenerator; import com.oviva.gesundheitsid.util.JsonCodec; import java.time.Instant; @@ -43,6 +44,17 @@ void build() { .homepageUri("https://dev.example.com") .name("Example Inc.") .build()) + .openidProvider( + OpenidProvider.create() + .authorizationEndpoint(sub + "/auth") + .tokenEndpoint(sub + "/token") + .pushedAuthorizationRequestEndpoint(sub + "/par") + .issuer(sub) + .scopesSupported(List.of("openid", "email")) + .grantTypesSupported(List.of("direct", "code")) + .userTypeSupported(List.of("IP")) + .requirePushedAuthorizationRequests(true) + .build()) .openIdRelyingParty( OpenIdRelyingParty.create() .clientName(sub) @@ -66,7 +78,7 @@ void build() { assertEquals( """ - {"iss":"https://aok-testfalen.example.com","sub":"https://aok-testfalen.example.com","iat":1672791140,"exp":1672791840,"nbf":1672791239,"jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]},"authority_hints":["https://fedmaster.example.com"],"metadata":{"openid_provider":null,"openid_relying_party":{"organization_name":"Example Inc.","client_name":"https://aok-testfalen.example.com","redirect_uris":["https://aok-testfalen.example.com/callback","http://localhost:8080/test"],"response_types":["code","token"],"client_registration_types":["magic"],"grant_types":["direct"],"require_pushed_authorization_requests":true,"scope":"openid closedid","id_token_signed_response_alg":"P256","id_token_encrypted_response_alg":"ecdh-es","id_token_encrypted_response_enc":"AES123","jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]}},"federation_entity":{"name":"Example Inc.","contacts":"alice@example.com","homepage_uri":"https://dev.example.com","federation_fetch_endpoint":null,"federation_list_endpoint":null,"idp_list_endpoint":null}}}""", + {"iss":"https://aok-testfalen.example.com","sub":"https://aok-testfalen.example.com","iat":1672791140,"exp":1672791840,"nbf":1672791239,"jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]},"authority_hints":["https://fedmaster.example.com"],"metadata":{"openid_provider":{"pushed_authorization_request_endpoint":"https://aok-testfalen.example.com/par","issuer":"https://aok-testfalen.example.com","require_pushed_authorization_requests":true,"token_endpoint":"https://aok-testfalen.example.com/token","authorization_endpoint":"https://aok-testfalen.example.com/auth","scopes_supported":["openid","email"],"grant_types_supported":["direct","code"],"user_type_supported":["IP"]},"openid_relying_party":{"organization_name":"Example Inc.","client_name":"https://aok-testfalen.example.com","redirect_uris":["https://aok-testfalen.example.com/callback","http://localhost:8080/test"],"response_types":["code","token"],"client_registration_types":["magic"],"grant_types":["direct"],"require_pushed_authorization_requests":true,"scope":"openid closedid","id_token_signed_response_alg":"P256","id_token_encrypted_response_alg":"ecdh-es","id_token_encrypted_response_enc":"AES123","jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]}},"federation_entity":{"name":"Example Inc.","contacts":"alice@example.com","homepage_uri":"https://dev.example.com","federation_fetch_endpoint":null,"federation_list_endpoint":null,"idp_list_endpoint":null}}}""", JsonCodec.writeValueAsString(es)); } } diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java index bdf520a..bb9a1c4 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java @@ -2,6 +2,7 @@ import static org.mockito.Mockito.spy; +import com.oviva.gesundheitsid.crypto.ECKeyPair; import java.security.InvalidAlgorithmParameterException; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; @@ -44,8 +45,6 @@ private static ECKeyPair generateP256(SecureRandom sr) { } } - public record ECKeyPair(ECPublicKey pub, ECPrivateKey priv) {} - private static class NotSoSecureRandom extends SecureRandom { private final Random prng = new Random(1337L); diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/GematikHeaderDecoratorHttpClient.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/GematikHeaderDecoratorHttpClient.java index 70722eb..85debb7 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/GematikHeaderDecoratorHttpClient.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/GematikHeaderDecoratorHttpClient.java @@ -1,6 +1,7 @@ package com.oviva.gesundheitsid.test; import com.oviva.gesundheitsid.fedclient.api.HttpClient; +import java.util.ArrayList; public class GematikHeaderDecoratorHttpClient implements HttpClient { @@ -20,7 +21,11 @@ public Response call(Request req) { "missing 'GEMATIK_AUTH_HEADER' environment value against '%s'" .formatted(HOST_GEMATIK_IDP)); } - req.headers().add(new Header("X-Authorization", Environment.gematikAuthHeader())); + + var headers = new ArrayList<>(req.headers()); + headers.add(new Header("X-Authorization", Environment.gematikAuthHeader())); + + req = new Request(req.uri(), req.method(), headers, req.body()); } return delegate.call(req); diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java index 3072c59..2140dd6 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java @@ -4,13 +4,26 @@ import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWKSet; -import com.oviva.gesundheitsid.test.ECKeyPairGenerator.ECKeyPair; +import com.oviva.gesundheitsid.crypto.ECKeyPair; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.ParseException; import java.util.List; public class JwksUtils { private JwksUtils() {} + public static JWKSet load(Path path) { + + try (var fin = Files.newInputStream(path)) { + return JWKSet.load(fin); + } catch (IOException | ParseException e) { + throw new RuntimeException("failed to load JWKS from '%s'".formatted(path), e); + } + } + public static JWKSet toJwks(ECKeyPair pair) { try { diff --git a/oidc-server/pom.xml b/oidc-server/pom.xml new file mode 100644 index 0000000..c03e0f4 --- /dev/null +++ b/oidc-server/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + com.oviva.gesundheitsid + gesundheitsid-parent + 0.0.1-SNAPSHOT + + + gesundheitsid + jar + + + + org.slf4j + slf4j-api + + + com.github.spotbugs + spotbugs-annotations + + + com.nimbusds + nimbus-jose-jwt + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + jakarta.ws.rs + jakarta.ws.rs-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.hamcrest + hamcrest + test + + + org.mockito + mockito-junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.slf4j + slf4j-jdk14 + test + + + + + org.apache.commons + commons-lang3 + test + + + com.github.jknack + handlebars-helpers + test + + + com.jayway.jsonpath + json-path + test + + + org.wiremock + wiremock + test + + + + + org.jsoup + jsoup + 1.16.1 + test + + + + org.jboss.resteasy + resteasy-core + test + + + + +