From 8751c92e45531f6cdca204b8db18a2825e05e69a Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Wed, 31 Jan 2024 19:00:32 +0100 Subject: [PATCH] ARC-1217: Include fully working authentication flow. --- .../gesundheitsid/auth/AuthExceptions.java | 8 +- .../auth/AuthenticationFlow.java | 15 +- .../oviva/gesundheitsid/auth/IdTokenJWS.java | 4 +- .../steps/SelectSectoralIdpStepImpl.java | 138 ++++++++++++++ .../steps/TrustedSectoralIdpStepImpl.java | 86 +++++++++ .../auth/steps/SelectSectoralIdpStep.java | 129 +------------ .../auth/steps/TrustedSectoralIdpStep.java | 86 +-------- .../fedclient/api/EntityStatement.java | 83 +++++++- .../auth/AuthenticationFlowExampleTest.java | 179 ++++++++++++++++++ .../auth/AuthenticationFlowTest.java | 172 ++--------------- .../steps/SelectSectoralIdpStepImplTest.java | 96 ++++++++++ .../steps/TrustedSectoralIdpStepImplTest.java | 131 +++++++++++++ .../api/EntityStatementBuilderTest.java | 14 +- 13 files changed, 762 insertions(+), 379 deletions(-) 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/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.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 diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java index bf3c2ff..b70f9c8 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthExceptions.java @@ -23,7 +23,11 @@ public static RuntimeException missingOpenIdConfigurationInEntityStatement(Strin "entity statement of '%s' lacks openid configuration".formatted(sub)); } - public static RuntimeException noEntityConfiguration() { - return new RuntimeException("no entity configuration for idp available"); + 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 index d0a8059..4995717 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthenticationFlow.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/AuthenticationFlow.java @@ -1,9 +1,11 @@ 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; @@ -17,19 +19,20 @@ public class AuthenticationFlow { private final KeySupplier relyingPartyKeySupplier; public AuthenticationFlow( - URI selfIssuer, - FederationMasterClient federationMasterClient, - OpenIdClient openIdClient, - KeySupplier relyingPartyKeySupplier) { + @NonNull URI selfIssuer, + @NonNull FederationMasterClient federationMasterClient, + @NonNull OpenIdClient openIdClient, + @NonNull KeySupplier relyingPartyKeySupplier) { this.selfIssuer = selfIssuer; this.federationMasterClient = federationMasterClient; this.openIdClient = openIdClient; this.relyingPartyKeySupplier = relyingPartyKeySupplier; } - public SelectSectoralIdpStep start(Session session) { + @NonNull + public SelectSectoralIdpStep start(@NonNull Session session) { - return new SelectSectoralIdpStep( + return new SelectSectoralIdpStepImpl( selfIssuer, federationMasterClient, openIdClient, diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java index 833c431..cb5afd9 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/IdTokenJWS.java @@ -4,10 +4,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.nimbusds.jose.JWSObject; -public record IdTokenJWS(JWSObject jws, Payload payload) { +public record IdTokenJWS(JWSObject jws, IdToken body) { @JsonIgnoreProperties(ignoreUnknown = true) - public record Payload( + public record IdToken( @JsonProperty("iss") String iss, @JsonProperty("sub") String sub, @JsonProperty("aud") String aud, 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..7c3e77f --- /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 index 94afc85..1bd7293 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/SelectSectoralIdpStep.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/SelectSectoralIdpStep.java @@ -1,133 +1,14 @@ package com.oviva.gesundheitsid.auth.steps; -import com.oviva.gesundheitsid.auth.AuthExceptions; -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 SelectSectoralIdpStep { +public interface SelectSectoralIdpStep { - private final URI selfIssuer; - private final FederationMasterClient fedMasterClient; - private final OpenIdClient openIdClient; - private final KeySupplier relyingPartyEncKeySupplier; + @NonNull + List fetchIdpOptions(); - private final URI callbackUri; - private final String nonce; - private final String codeChallengeS256; - private final String state; - private final List scopes; - - public SelectSectoralIdpStep( - 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; - } - - public List fetchIdpOptions() { - return fedMasterClient.listAvailableIdps(); - } - - public 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 TrustedSectoralIdpStep( - 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(); - } + @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 index 4ab4df1..3a4e3e7 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/TrustedSectoralIdpStep.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/auth/steps/TrustedSectoralIdpStep.java @@ -1,90 +1,14 @@ package com.oviva.gesundheitsid.auth.steps; -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.JWEObject; -import com.nimbusds.jose.crypto.MultiDecrypter; import com.oviva.gesundheitsid.auth.IdTokenJWS; -import com.oviva.gesundheitsid.auth.IdTokenJWS.Payload; -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 TrustedSectoralIdpStep { +public interface TrustedSectoralIdpStep { - private final OpenIdClient openIdClient; + @NonNull + URI idpRedirectUri(); - private final URI selfIssuer; - private final URI idpRedirectUri; - private final URI callbackUri; - private final EntityStatementJWS trustedIdpEntityStatement; - private final KeySupplier relyingPartyEncKeySupplier; - - public TrustedSectoralIdpStep( - OpenIdClient openIdClient, - URI selfIssuer, - URI idpRedirectUri, - URI callbackUri, - EntityStatementJWS trustedIdpEntityStatement, - KeySupplier relyingPartyEncKeySupplier) { - this.openIdClient = openIdClient; - this.selfIssuer = selfIssuer; - this.idpRedirectUri = idpRedirectUri; - this.callbackUri = callbackUri; - this.trustedIdpEntityStatement = trustedIdpEntityStatement; - this.relyingPartyEncKeySupplier = relyingPartyEncKeySupplier; - } - - public URI idpRedirectUri() { - return idpRedirectUri; - } - - public IdTokenJWS exchangeSectoralIdpCode(@NonNull String code, @NonNull String codeVerifier) { - - if (trustedIdpEntityStatement == null) { - throw new IllegalStateException("flow has no trusted IDP statement, state not persisted?"); - } - - if (callbackUri == null) { - throw new IllegalStateException("flow has no callback_uri, state not persisted?"); - } - - 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 ECDHDecrypter(relyingPartyEncKeySupplier.get().priv()); - 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 new RuntimeException("bad signature from IDP on id token"); - } - - var payload = - signedJws - .getPayload() - .toType(new JsonPayloadTransformer<>(Payload.class, JsonCodec::readValue)); - return new IdTokenJWS(signedJws, payload); - - } catch (JOSEException | ParseException e) { - throw new RuntimeException(e); - } - } + @NonNull + IdTokenJWS exchangeSectoralIdpCode(@NonNull String code, @NonNull String codeVerifier); } 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 index 1f5b322..be530bd 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowTest.java @@ -1,179 +1,29 @@ package com.oviva.gesundheitsid.auth; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; -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.crypto.KeySupplier; +import com.oviva.gesundheitsid.fedclient.FederationMasterClient; 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 AuthenticationFlowTest { @Test - @Disabled("e2e") - void flowIntegrationTest() throws IOException, InterruptedException { + 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); - // 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"); + var flow = new AuthenticationFlow(self, fedmasterClient, openIdClient, keySupplier); - // this URI must be listed in your entity statement, configure as needed - var redirectUri = URI.create("http://localhost:8080"); + var step = flow.start(new Session(null, null, null, null, List.of())); - // 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.payload())); - } - - 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); + 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/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)); } }