From feb67836fa0ce2d7848a03b42ca7a8543235c08e Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Fri, 18 Oct 2024 11:19:31 +0200 Subject: [PATCH] EPA-158: [ehealthid] Pick right OpenID Provider JWKS for IdToken verification (#105) --- .../oviva/ehealthid/relyingparty/Main.java | 1 + .../steps/SelectSectoralIdpStepImpl.java | 3 +- .../steps/TrustedSectoralIdpStepImpl.java | 10 +- .../fedclient/FederationExceptions.java | 35 +++ .../fedclient/FederationMasterClient.java | 3 + .../fedclient/FederationMasterClientImpl.java | 72 ++++++ .../api/CachedFederationApiClient.java | 10 + .../fedclient/api/EntityStatement.java | 35 ++- .../fedclient/api/ExtendedJWKSet.java | 17 ++ .../fedclient/api/ExtendedJWKSetJWS.java | 43 ++++ .../fedclient/api/FederationApiClient.java | 3 + .../api/FederationApiClientImpl.java | 8 + .../util/ExtendedJWKSetDeserializer.java | 52 ++++ .../com/oviva/ehealthid/util/JoseModule.java | 2 + .../auth/AuthenticationFlowExampleTest.java | 1 + .../steps/TrustedSectoralIdpStepImplTest.java | 14 +- .../FederationMasterClientImplTest.java | 236 +++++++++++++++++- .../api/EntityStatementBuilderTest.java | 3 +- .../fedclient/api/ExtendedJWKSetJWSTest.java | 23 ++ 19 files changed, 558 insertions(+), 13 deletions(-) create mode 100644 ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSet.java create mode 100644 ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWS.java create mode 100644 ehealthid/src/main/java/com/oviva/ehealthid/util/ExtendedJWKSetDeserializer.java create mode 100644 ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWSTest.java diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java index c200a5c..7d2eafc 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java @@ -273,6 +273,7 @@ private AuthenticationFlow buildAuthFlow( new FederationApiClientImpl(fedHttpClient), new InMemoryCacheImpl<>(clock, ttl), new InMemoryCacheImpl<>(clock, ttl), + new InMemoryCacheImpl<>(clock, ttl), new InMemoryCacheImpl<>(clock, ttl)); var fedmasterClient = new FederationMasterClientImpl(fedmaster, federationApiClient, clock); diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java b/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java index bf54d1a..546704e 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/SelectSectoralIdpStepImpl.java @@ -90,7 +90,8 @@ public List fetchIdpOptions() { redirectUri, callbackUri, trustedIdpEntityStatement, - relyingPartyEncKeySupplier); + relyingPartyEncKeySupplier, + fedMasterClient); } private URI buildAuthorizationUrl(String parRequestUri, EntityStatement trustedEntityStatement) { diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImpl.java b/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImpl.java index ca31141..e1094ac 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImpl.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImpl.java @@ -9,6 +9,7 @@ import com.oviva.ehealthid.auth.steps.TrustedSectoralIdpStep; import com.oviva.ehealthid.crypto.JwsVerifier; import com.oviva.ehealthid.crypto.KeySupplier; +import com.oviva.ehealthid.fedclient.FederationMasterClient; import com.oviva.ehealthid.fedclient.api.EntityStatementJWS; import com.oviva.ehealthid.fedclient.api.OpenIdClient; import com.oviva.ehealthid.util.JsonCodec; @@ -26,6 +27,7 @@ public class TrustedSectoralIdpStepImpl implements TrustedSectoralIdpStep { private final URI callbackUri; private final EntityStatementJWS trustedIdpEntityStatement; private final KeySupplier relyingPartyEncKeySupplier; + private final FederationMasterClient federationMasterClient; public TrustedSectoralIdpStepImpl( @NonNull OpenIdClient openIdClient, @@ -33,13 +35,15 @@ public TrustedSectoralIdpStepImpl( @NonNull URI idpRedirectUri, @NonNull URI callbackUri, @NonNull EntityStatementJWS trustedIdpEntityStatement, - @NonNull KeySupplier relyingPartyEncKeySupplier) { + @NonNull KeySupplier relyingPartyEncKeySupplier, + @NonNull FederationMasterClient federationMasterClient) { this.openIdClient = openIdClient; this.selfIssuer = selfIssuer; this.idpRedirectUri = idpRedirectUri; this.callbackUri = callbackUri; this.trustedIdpEntityStatement = trustedIdpEntityStatement; this.relyingPartyEncKeySupplier = relyingPartyEncKeySupplier; + this.federationMasterClient = federationMasterClient; } @Override @@ -69,7 +73,9 @@ public IdTokenJWS exchangeSectoralIdpCode(@NonNull String code, @NonNull String var signedJws = jweObject.getPayload().toJWSObject(); - if (!JwsVerifier.verify(trustedIdpEntityStatement.body().jwks(), signedJws)) { + var idpSigningKeys = + federationMasterClient.resolveOpenIdProviderJwks(trustedIdpEntityStatement); + if (!JwsVerifier.verify(idpSigningKeys, signedJws)) { throw AuthExceptions.badIdTokenSignature(trustedIdpEntityStatement.body().sub()); } diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java index 0508468..1a84d1b 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java @@ -76,4 +76,39 @@ public static FederationException untrustedFederationStatement(String sub) { "federation statement untrusted: sub=%s".formatted(sub), FederationException.Reason.UNTRUSTED_IDP); } + + public static FederationException noOpenIdProviderKeys(String sub) { + return new FederationException( + "no keys in .metadata.openid_provider jwks or signed_jwks_uri found: sub=%s".formatted(sub), + FederationException.Reason.INVALID_ENTITY_STATEMENT); + } + + public static FederationException missingOpenIdProvider(String sub) { + return new FederationException( + "missing .metadata.openid_provider in entitystatement: sub=%s".formatted(sub), + FederationException.Reason.INVALID_ENTITY_STATEMENT); + } + + public static FederationException notASignedJwks(String actualType) { + return new FederationException( + "JWS is not of type jwks-set statement but rather '%s'".formatted(actualType), + FederationException.Reason.INVALID_ENTITY_STATEMENT); + } + + public static FederationException expiredSignedJwks(String sub, String signedJwksUri) { + return new FederationException( + "expired signed jwks: sub=%s signed_jwks_uri=%s".formatted(sub, signedJwksUri), + FederationException.Reason.UNTRUSTED_IDP); + } + + public static FederationException invalidSignedJwks(String sub, String signedJwksUri) { + return new FederationException( + "invalid signed jwks: sub=%s signed_jwks_uri=%s".formatted(sub, signedJwksUri), + FederationException.Reason.UNTRUSTED_IDP); + } + + public static FederationException badSignedJwks(Exception cause) { + return new FederationException( + "failed to parse signed jwks", cause, FederationException.Reason.INVALID_ENTITY_STATEMENT); + } } diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClient.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClient.java index 084aa7c..c85d998 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClient.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClient.java @@ -1,5 +1,6 @@ package com.oviva.ehealthid.fedclient; +import com.nimbusds.jose.jwk.JWKSet; import com.oviva.ehealthid.fedclient.api.EntityStatementJWS; import java.net.URI; import java.util.List; @@ -9,4 +10,6 @@ public interface FederationMasterClient { List listAvailableIdps(); EntityStatementJWS establishIdpTrust(URI issuer); + + JWKSet resolveOpenIdProviderJwks(EntityStatementJWS es); } diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClientImpl.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClientImpl.java index c33fcd8..fba1d2f 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClientImpl.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationMasterClientImpl.java @@ -1,5 +1,6 @@ package com.oviva.ehealthid.fedclient; +import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSet; import com.oviva.ehealthid.fedclient.api.EntityStatement; import com.oviva.ehealthid.fedclient.api.EntityStatementJWS; @@ -8,7 +9,9 @@ import edu.umd.cs.findbugs.annotations.NonNull; import java.net.URI; import java.time.Clock; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; public class FederationMasterClientImpl implements FederationMasterClient { @@ -33,6 +36,75 @@ public List listAvailableIdps() { .toList(); } + @Override + public JWKSet resolveOpenIdProviderJwks(@NonNull EntityStatementJWS es) { + + // https://openid.net/specs/openid-federation-1_0.html#section-5.2.1.1 + // https://gemspec.gematik.de/docs/gemSpec/gemSpec_IDP_Sek/latest/#A_22655-02 + + var op = + Optional.of(es) + .map(EntityStatementJWS::body) + .map(EntityStatement::metadata) + .map(EntityStatement.Metadata::openidProvider); + + if (op.isEmpty()) { + throw FederationExceptions.missingOpenIdProvider(es.body().sub()); + } + + List allKeys = new ArrayList<>(); + + // embedded keys + op.map(EntityStatement.OpenidProvider::jwks).map(JWKSet::getKeys).ifPresent(allKeys::addAll); + + // from signed_jwks_uri + op.map(EntityStatement.OpenidProvider::signedJwksUri) + .flatMap( + u -> fetchOpenIdProviderJwksFromSignedJwksUri(es.body().sub(), u, es.body().jwks())) + .ifPresent(allKeys::addAll); + + // Note: OpenID federation also supports a `jwks_uri`, the GesundheitsID does not though + if (allKeys.isEmpty()) { + throw FederationExceptions.noOpenIdProviderKeys(es.body().sub()); + } + + return new JWKSet(allKeys); + } + + @NonNull + private Optional> fetchOpenIdProviderJwksFromSignedJwksUri( + @NonNull String issuer, @NonNull String signedJwksUri, @NonNull JWKSet idpTrustStore) { + + return Optional.of(signedJwksUri) + .map(URI::create) + .map(apiClient::fetchSignedJwks) + .map( + jws -> { + if (!jws.isValidAt(clock.instant())) { + throw FederationExceptions.expiredSignedJwks(issuer, signedJwksUri); + } + + if (!jws.verifySignature(idpTrustStore)) { + throw FederationExceptions.invalidSignedJwks(issuer, signedJwksUri); + } + + if (!matchesIfPresent(issuer, jws.body().iss())) { + throw FederationExceptions.invalidSignedJwks(issuer, signedJwksUri); + } + return jws; + }) + .map(s -> s.body().toJWKSet()) + .map(JWKSet::getKeys); + } + + private boolean matchesIfPresent(String expected, String actual) { + if (actual == null || actual.isEmpty()) { + return true; + } + + return expected.equals(actual); + } + @Override public EntityStatementJWS establishIdpTrust(URI issuer) { diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/CachedFederationApiClient.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/CachedFederationApiClient.java index 8d06011..6abc935 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/CachedFederationApiClient.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/CachedFederationApiClient.java @@ -12,16 +12,19 @@ public class CachedFederationApiClient implements FederationApiClient { private final Cache federationStatementCache; + private final Cache signedJwksCache; private final Cache idpListCache; public CachedFederationApiClient( FederationApiClient delegate, Cache entityStatementCache, Cache federationStatementCache, + Cache signedJwksCache, Cache idpListCache) { this.delegate = delegate; this.entityStatementCache = entityStatementCache; this.federationStatementCache = federationStatementCache; + this.signedJwksCache = signedJwksCache; this.idpListCache = idpListCache; } @@ -46,4 +49,11 @@ public IdpListJWS fetchIdpList(URI idpListUrl) { return entityStatementCache.computeIfAbsent( entityUrl.toString(), k -> delegate.fetchEntityConfiguration(entityUrl)); } + + @NonNull + @Override + public ExtendedJWKSetJWS fetchSignedJwks(URI signedJwksUrl) { + return signedJwksCache.computeIfAbsent( + signedJwksUrl.toString(), k -> delegate.fetchSignedJwks(signedJwksUrl)); + } } diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/EntityStatement.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/EntityStatement.java index 7bf08ab..6568883 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/EntityStatement.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/EntityStatement.java @@ -77,7 +77,12 @@ 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, + + // keys + @JsonProperty("jwks") JWKSet jwks, + // additional location for relying party keys, e.g. for signing tokens + @JsonProperty("signed_jwks_uri") String signedJwksUri) { public static Builder create() { return new Builder(); @@ -94,6 +99,9 @@ public static final class Builder { private List grantTypesSupported; private List userTypeSupported; + private JWKSet jwks; + private String signedJwksUri; + private Builder() {} public Builder pushedAuthorizationRequestEndpoint(String pushedAuthorizationRequestEndpoint) { @@ -137,6 +145,16 @@ public Builder userTypeSupported(List userTypeSupported) { return this; } + public Builder jwks(JWKSet jwks) { + this.jwks = jwks; + return this; + } + + public Builder signedJwksUri(String signedJwksUri) { + this.signedJwksUri = signedJwksUri; + return this; + } + public OpenidProvider build() { return new OpenidProvider( pushedAuthorizationRequestEndpoint, @@ -146,7 +164,9 @@ public OpenidProvider build() { authorizationEndpoint, scopesSupported, grantTypesSupported, - userTypeSupported); + userTypeSupported, + jwks, + signedJwksUri); } } } @@ -298,7 +318,11 @@ public record OpenIdRelyingParty( @JsonProperty("id_token_signed_response_alg") String idTokenSignedResponseAlg, @JsonProperty("id_token_encrypted_response_alg") String idTokenEncryptedResponseAlg, @JsonProperty("id_token_encrypted_response_enc") String idTokenEncryptedResponseEnc, + + // keys @JsonProperty("jwks") JWKSet jwks, + // additional location for relying party keys, e.g. for signing tokens + @JsonProperty("signed_jwks_uri") String signedJwksUri, @JsonProperty("default_acr_values") List defaultAcrValues, @JsonProperty("token_endpoint_auth_methods_supported") List tokenEndpointAuthMethodsSupported, @@ -326,6 +350,7 @@ public static final class Builder { private String idTokenEncryptedResponseEnc; private JWKSet jwks; + private String signedJwksUri; private List defaultAcrValues; private List tokenEndpointAuthMethodsSupported; private String tokenEndpointAuthMethod; @@ -393,6 +418,11 @@ public Builder jwks(JWKSet jwks) { return this; } + public Builder signedJwksUri(String signedJwksUri) { + this.signedJwksUri = signedJwksUri; + return this; + } + public Builder defaultAcrValues(List defaultAcrValues) { this.defaultAcrValues = defaultAcrValues; return this; @@ -423,6 +453,7 @@ public OpenIdRelyingParty build() { idTokenEncryptedResponseAlg, idTokenEncryptedResponseEnc, jwks, + signedJwksUri, defaultAcrValues, tokenEndpointAuthMethodsSupported, tokenEndpointAuthMethod); diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSet.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSet.java new file mode 100644 index 0000000..cc57232 --- /dev/null +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSet.java @@ -0,0 +1,17 @@ +package com.oviva.ehealthid.fedclient.api; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import java.util.List; + +// slight variation of a JWKSet :/ +// https://openid.net/specs/openid-connect-federation-1_0-21.html#name-openid-connect-and-oauth2-m +public record ExtendedJWKSet(long exp, String iss, List keys) { + + public JWKSet toJWKSet() { + if (keys == null) { + return new JWKSet(); + } + return new JWKSet(keys); + } +} diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWS.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWS.java new file mode 100644 index 0000000..1a3138f --- /dev/null +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWS.java @@ -0,0 +1,43 @@ +package com.oviva.ehealthid.fedclient.api; + +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.ehealthid.crypto.JwsVerifier; +import com.oviva.ehealthid.fedclient.FederationExceptions; +import com.oviva.ehealthid.util.JsonCodec; +import com.oviva.ehealthid.util.JsonPayloadTransformer; +import java.text.ParseException; +import java.time.Instant; + +public record ExtendedJWKSetJWS(JWSObject jws, ExtendedJWKSet body) implements TemporalValid { + + public static final String JWKS_TYPE = "jwk-set+json"; + + public static ExtendedJWKSetJWS parse(String wire) { + try { + + var jws = JWSObject.parse(wire); + + if (!JWKS_TYPE.equals(jws.getHeader().getType().getType())) { + throw FederationExceptions.notASignedJwks(jws.getHeader().getType().getType()); + } + + var es = + jws.getPayload() + .toType(new JsonPayloadTransformer<>(ExtendedJWKSet.class, JsonCodec::readValue)); + return new ExtendedJWKSetJWS(jws, es); + } catch (ParseException e) { + throw FederationExceptions.badSignedJwks(e); + } + } + + public boolean verifySignature(JWKSet jwks) { + return JwsVerifier.verify(jwks, jws); + } + + @Override + public boolean isValidAt(Instant pointInTime) { + var epoch = pointInTime.getEpochSecond(); + return body.exp() == 0 || epoch < body.exp(); + } +} diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClient.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClient.java index 1f0a1c7..977130b 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClient.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClient.java @@ -14,4 +14,7 @@ EntityStatementJWS fetchFederationStatement( @NonNull EntityStatementJWS fetchEntityConfiguration(URI entityUrl); + + @NonNull + ExtendedJWKSetJWS fetchSignedJwks(URI signedJwksUrl); } diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClientImpl.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClientImpl.java index f0db11e..a380d8b 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClientImpl.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/api/FederationApiClientImpl.java @@ -14,6 +14,7 @@ public class FederationApiClientImpl implements FederationApiClient { public static final String ENTITY_STATEMENT_MEDIA_TYPE = "application/entity-statement+jwt"; + public static final String SIGNED_JWKS_MEDIA_TYPE = "application/jwk-set+jwt"; public static final String WELLKNOWN_FEDERATION_DOCUMENT = "openid-federation"; public static final String WELLKNOWN_PATH = ".well-known"; @@ -55,6 +56,13 @@ public IdpListJWS fetchIdpList(URI idpListUrl) { return EntityStatementJWS.parse(body); } + @NonNull + @Override + public ExtendedJWKSetJWS fetchSignedJwks(URI signedJwksUrl) { + var body = doGetRequest(signedJwksUrl, SIGNED_JWKS_MEDIA_TYPE, null); + return ExtendedJWKSetJWS.parse(body); + } + private String doGetRequest(URI uri, String accept, List params) { List
headers = new ArrayList<>(); diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/util/ExtendedJWKSetDeserializer.java b/ehealthid/src/main/java/com/oviva/ehealthid/util/ExtendedJWKSetDeserializer.java new file mode 100644 index 0000000..de4635c --- /dev/null +++ b/ehealthid/src/main/java/com/oviva/ehealthid/util/ExtendedJWKSetDeserializer.java @@ -0,0 +1,52 @@ +package com.oviva.ehealthid.util; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.nimbusds.jose.jwk.JWK; +import com.oviva.ehealthid.fedclient.api.ExtendedJWKSet; +import java.io.IOException; +import java.util.List; + +public class ExtendedJWKSetDeserializer extends StdDeserializer { + + public ExtendedJWKSetDeserializer() { + super(ExtendedJWKSet.class); + } + + @Override + public ExtendedJWKSet deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + + var node = jsonParser.getCodec().readTree(jsonParser); + + var keysNode = node.get("keys"); + var keys = + jsonParser + .getCodec() + .readValue(keysNode.traverse(jsonParser.getCodec()), new TypeReference>() {}); + + var exp = parseLongField(jsonParser, node, "exp"); + var iss = parseStringField(jsonParser, node, "iss"); + + return new ExtendedJWKSet(exp, iss, keys); + } + + private String parseStringField(JsonParser jsonParser, TreeNode node, String fieldName) + throws IOException { + + return jsonParser + .getCodec() + .readValue(node.get(fieldName).traverse(jsonParser.getCodec()), String.class); + } + + private long parseLongField(JsonParser jsonParser, TreeNode node, String fieldName) + throws IOException { + + return jsonParser + .getCodec() + .readValue(node.get(fieldName).traverse(jsonParser.getCodec()), Long.class); + } +} diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/util/JoseModule.java b/ehealthid/src/main/java/com/oviva/ehealthid/util/JoseModule.java index 46f6cbe..8c0f249 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/util/JoseModule.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/util/JoseModule.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ser.std.StdDelegatingSerializer; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.ehealthid.fedclient.api.ExtendedJWKSet; import com.oviva.ehealthid.util.JWKSetDeserializer.JWKDeserializer; public class JoseModule extends SimpleModule { @@ -12,6 +13,7 @@ public JoseModule() { super("jose"); addDeserializer(JWK.class, new JWKDeserializer(JWK.class)); addDeserializer(JWKSet.class, new JWKSetDeserializer(JWKSet.class)); + addDeserializer(ExtendedJWKSet.class, new ExtendedJWKSetDeserializer()); addSerializer(new StdDelegatingSerializer(JWKSet.class, new JWKSetConverter())); } } diff --git a/ehealthid/src/test/java/com/oviva/ehealthid/auth/AuthenticationFlowExampleTest.java b/ehealthid/src/test/java/com/oviva/ehealthid/auth/AuthenticationFlowExampleTest.java index 5fd5731..408f345 100644 --- a/ehealthid/src/test/java/com/oviva/ehealthid/auth/AuthenticationFlowExampleTest.java +++ b/ehealthid/src/test/java/com/oviva/ehealthid/auth/AuthenticationFlowExampleTest.java @@ -74,6 +74,7 @@ void flowIntegrationTest() throws IOException, InterruptedException { new FederationApiClientImpl(httpClient), new InMemoryCacheImpl<>(clock, ttl), new InMemoryCacheImpl<>(clock, ttl), + new InMemoryCacheImpl<>(clock, ttl), new InMemoryCacheImpl<>(clock, ttl)); var fedmasterClient = new FederationMasterClientImpl(fedmaster, federationApiClient, clock); diff --git a/ehealthid/src/test/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java b/ehealthid/src/test/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java index e04e099..7b846ee 100644 --- a/ehealthid/src/test/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java +++ b/ehealthid/src/test/java/com/oviva/ehealthid/auth/internal/steps/TrustedSectoralIdpStepImplTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.when; import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.ehealthid.fedclient.FederationMasterClient; import com.oviva.ehealthid.fedclient.api.EntityStatement; import com.oviva.ehealthid.fedclient.api.EntityStatement.Metadata; import com.oviva.ehealthid.fedclient.api.EntityStatement.OpenidProvider; @@ -23,7 +24,7 @@ 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); + var sut = new TrustedSectoralIdpStepImpl(null, null, redirectUri, null, null, null, null); // when & then assertEquals(redirectUri, sut.idpRedirectUri()); @@ -35,6 +36,7 @@ void exchangeSectoralIdpCode() throws ParseException { var selfIssuer = URI.create("https://fachdienst.example.com"); var callbackUri = selfIssuer.resolve("/callback"); + var fedmasterClient = mock(FederationMasterClient.class); var openIdClient = mock(OpenIdClient.class); var sectoralIdp = URI.create("https://gsi.dev.gematik.solutions"); @@ -71,7 +73,15 @@ void exchangeSectoralIdpCode() throws ParseException { var jwks = loadEncryptionKeys(); var sut = new TrustedSectoralIdpStepImpl( - openIdClient, selfIssuer, redirectUri, callbackUri, idpJws, jwks::getKeyByKeyId); + openIdClient, + selfIssuer, + redirectUri, + callbackUri, + idpJws, + jwks::getKeyByKeyId, + fedmasterClient); + + when(fedmasterClient.resolveOpenIdProviderJwks(idpJws)).thenReturn(sectoralIdpJwks); // when var idToken = sut.exchangeSectoralIdpCode(code, verifier); diff --git a/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/FederationMasterClientImplTest.java b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/FederationMasterClientImplTest.java index 7ffbc9e..04ee6ea 100644 --- a/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/FederationMasterClientImplTest.java +++ b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/FederationMasterClientImplTest.java @@ -7,15 +7,12 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSet; -import com.oviva.ehealthid.fedclient.api.EntityStatement; +import com.oviva.ehealthid.fedclient.api.*; import com.oviva.ehealthid.fedclient.api.EntityStatement.FederationEntity; import com.oviva.ehealthid.fedclient.api.EntityStatement.Metadata; -import com.oviva.ehealthid.fedclient.api.EntityStatementJWS; -import com.oviva.ehealthid.fedclient.api.FederationApiClient; -import com.oviva.ehealthid.fedclient.api.IdpList; import com.oviva.ehealthid.fedclient.api.IdpList.IdpEntity; -import com.oviva.ehealthid.fedclient.api.IdpListJWS; import com.oviva.ehealthid.test.ECKeyGenerator; import com.oviva.ehealthid.util.JsonCodec; import com.oviva.ehealthid.util.JwsUtils; @@ -363,6 +360,235 @@ void establishTrust() { assertEquals(entityStatementJWS.body().sub(), issuer.toString()); } + @Test + void fetchOpenIdProviderJwks_missingOpenIdProvider() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + + var entityStatement = openIdProviderEntityConfiguration(issuer, sectoralIdpKeypair, null); + assertThrows( + FederationException.class, () -> client.resolveOpenIdProviderJwks(entityStatement)); + } + + @Test + void fetchOpenIdProviderJwks_noKeys() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + + var entityStatement = + openIdProviderEntityConfiguration( + issuer, + sectoralIdpKeypair, + EntityStatement.OpenidProvider.create().jwks(new JWKSet()).build()); + + assertThrows( + FederationException.class, () -> client.resolveOpenIdProviderJwks(entityStatement)); + } + + @Test + void fetchOpenIdProviderJwks_embeddedJwks() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + + var openIdProviderJwks = new JWKSet(ECKeyGenerator.generate()).toPublicJWKSet(); + + var entityStatement = + openIdProviderEntityConfiguration( + issuer, + sectoralIdpKeypair, + EntityStatement.OpenidProvider.create().jwks(openIdProviderJwks).build()); + + var got = client.resolveOpenIdProviderJwks(entityStatement); + + assertEquals(openIdProviderJwks, got); + } + + @Test + void fetchOpenIdProviderJwks_signedJwksUri() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + var signedJwksUri = URI.create("https://idp-tk.example.com/signed-jwks.jose"); + + var openIdProviderJwks = new JWKSet(ECKeyGenerator.generate()).toPublicJWKSet(); + var signedJwks = + signedJwks(issuer, NOW.plusSeconds(40), openIdProviderJwks.getKeys(), sectoralIdpKeypair); + + when(federationApiClient.fetchSignedJwks(signedJwksUri)).thenReturn(signedJwks); + + var entityStatement = + openIdProviderEntityConfiguration( + issuer, + sectoralIdpKeypair, + EntityStatement.OpenidProvider.create() + .signedJwksUri(signedJwksUri.toString()) + .build()); + + // when + var got = client.resolveOpenIdProviderJwks(entityStatement); + + // then + assertEquals(openIdProviderJwks, got); + } + + @Test + void fetchOpenIdProviderJwks_signedJwksUri_noIssuer() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + var signedJwksUri = URI.create("https://idp-tk.example.com/signed-jwks.jose"); + + var openIdProviderJwks = new JWKSet(ECKeyGenerator.generate()).toPublicJWKSet(); + var signedJwks = + signedJwks(null, NOW.plusSeconds(40), openIdProviderJwks.getKeys(), sectoralIdpKeypair); + + when(federationApiClient.fetchSignedJwks(signedJwksUri)).thenReturn(signedJwks); + + var entityStatement = + openIdProviderEntityConfiguration( + issuer, + sectoralIdpKeypair, + EntityStatement.OpenidProvider.create() + .signedJwksUri(signedJwksUri.toString()) + .build()); + + // when + var got = client.resolveOpenIdProviderJwks(entityStatement); + + // then + assertEquals(openIdProviderJwks, got); + } + + @Test + void fetchOpenIdProviderJwks_signedJwksUri_badIssuer() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + var badIssuer = URI.create("https://mallory.example.com"); + var signedJwksUri = URI.create("https://idp-tk.example.com/signed-jwks.jose"); + + var openIdProviderJwks = new JWKSet(ECKeyGenerator.generate()).toPublicJWKSet(); + var signedJwks = + signedJwks( + badIssuer, NOW.plusSeconds(40), openIdProviderJwks.getKeys(), sectoralIdpKeypair); + + when(federationApiClient.fetchSignedJwks(signedJwksUri)).thenReturn(signedJwks); + + var entityStatement = + openIdProviderEntityConfiguration( + issuer, + sectoralIdpKeypair, + EntityStatement.OpenidProvider.create() + .signedJwksUri(signedJwksUri.toString()) + .build()); + + // when & then + assertThrows( + FederationException.class, () -> client.resolveOpenIdProviderJwks(entityStatement)); + } + + @Test + void fetchOpenIdProviderJwks_signedJwksUri_expired() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + var signedJwksUri = URI.create("https://idp-tk.example.com/signed-jwks.jose"); + + var openIdProviderJwks = new JWKSet(ECKeyGenerator.generate()).toPublicJWKSet(); + var thePast = NOW.minusSeconds(60); + var signedJwks = signedJwks(issuer, thePast, openIdProviderJwks.getKeys(), sectoralIdpKeypair); + + when(federationApiClient.fetchSignedJwks(signedJwksUri)).thenReturn(signedJwks); + + var entityStatement = + openIdProviderEntityConfiguration( + issuer, + sectoralIdpKeypair, + EntityStatement.OpenidProvider.create() + .signedJwksUri(signedJwksUri.toString()) + .build()); + + // when & then + assertThrows( + FederationException.class, () -> client.resolveOpenIdProviderJwks(entityStatement)); + } + + @Test + void fetchOpenIdProviderJwks_signedJwksUri_badSignature() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var sectoralIdpKeypair = ECKeyGenerator.generate(); + var unrelatedKeypair = ECKeyGenerator.generate(); + var issuer = URI.create("https://idp-tk.example.com"); + var signedJwksUri = URI.create("https://idp-tk.example.com/signed-jwks.jose"); + + var openIdProviderJwks = new JWKSet(ECKeyGenerator.generate()).toPublicJWKSet(); + var signedJwks = + signedJwks(issuer, NOW.plusSeconds(17), openIdProviderJwks.getKeys(), unrelatedKeypair); + + when(federationApiClient.fetchSignedJwks(signedJwksUri)).thenReturn(signedJwks); + + var entityStatement = + openIdProviderEntityConfiguration( + issuer, + sectoralIdpKeypair, + EntityStatement.OpenidProvider.create() + .signedJwksUri(signedJwksUri.toString()) + .build()); + + // when & then + assertThrows( + FederationException.class, () -> client.resolveOpenIdProviderJwks(entityStatement)); + } + + private ExtendedJWKSetJWS signedJwks(URI iss, Instant expiry, List keys, ECKey signingKey) { + + var publicJwks = new JWKSet(keys).toPublicJWKSet().getKeys(); + + var body = + new ExtendedJWKSet( + expiry.getEpochSecond(), iss == null ? null : iss.toString(), publicJwks); + + var signed = JwsUtils.toJws(signingKey, JsonCodec.writeValueAsString(body)); + + return new ExtendedJWKSetJWS(signed, body); + } + + private EntityStatementJWS openIdProviderEntityConfiguration( + URI sub, ECKey sectoralIdpKeyPair, EntityStatement.OpenidProvider op) { + + var body = + EntityStatement.create() + .iss(sub.toString()) + .sub(sub.toString()) + .exp(NOW.plusSeconds(60)) + .jwks(toPublicJwks(sectoralIdpKeyPair)) + .metadata(Metadata.create().openidProvider(op).build()) + .build(); + + var signed = JwsUtils.toJws(sectoralIdpKeyPair, JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + private EntityStatementJWS badSignedSectoralIdpEntityConfiguration( URI sub, ECKey sectoralIdpKeyPair, ECKey actualJwksKeys) { diff --git a/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/EntityStatementBuilderTest.java b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/EntityStatementBuilderTest.java index 84c4843..e1c3a9a 100644 --- a/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/EntityStatementBuilderTest.java +++ b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/EntityStatementBuilderTest.java @@ -51,6 +51,7 @@ void build() { .grantTypesSupported(List.of("direct", "code")) .userTypeSupported(List.of("IP")) .requirePushedAuthorizationRequests(true) + .signedJwksUri("https://aok-testfalen.example.com/jwks.json") .build()) .openIdRelyingParty( OpenIdRelyingParty.create() @@ -79,7 +80,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","kid":"1272H4X20HLaS_zwI8c7ho5REoZP1uBUvcfm7bJ3jpQ","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","kid":"1272H4X20HLaS_zwI8c7ho5REoZP1uBUvcfm7bJ3jpQ","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]},"default_acr_values":["gematik-ehealth-loa-high"],"token_endpoint_auth_methods_supported":["self_signed_tls_client_auth"],"token_endpoint_auth_method":"self_signed_tls_client_auth"},"federation_entity":{"name":"Example Inc.","contacts":"alice@example.com","homepage_uri":"https://dev.example.com"}}}""", + {"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","kid":"1272H4X20HLaS_zwI8c7ho5REoZP1uBUvcfm7bJ3jpQ","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"],"signed_jwks_uri":"https://aok-testfalen.example.com/jwks.json"},"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","kid":"1272H4X20HLaS_zwI8c7ho5REoZP1uBUvcfm7bJ3jpQ","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]},"default_acr_values":["gematik-ehealth-loa-high"],"token_endpoint_auth_methods_supported":["self_signed_tls_client_auth"],"token_endpoint_auth_method":"self_signed_tls_client_auth"},"federation_entity":{"name":"Example Inc.","contacts":"alice@example.com","homepage_uri":"https://dev.example.com"}}}""", JsonCodec.writeValueAsString(es)); } } diff --git a/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWSTest.java b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWSTest.java new file mode 100644 index 0000000..0e36829 --- /dev/null +++ b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/ExtendedJWKSetJWSTest.java @@ -0,0 +1,23 @@ +package com.oviva.ehealthid.fedclient.api; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ExtendedJWKSetJWSTest { + + @Test + void parse() { + + var eJwks = + ExtendedJWKSetJWS.parse( + """ + eyJraWQiOiJqd3RzaWduZXItMjAyNCIsInR5cCI6Imp3ay1zZXQranNvbiIsImFsZyI6IkVTMjU2In0.eyJpc3MiOiJodHRwczovL2lkYnJva2VyLnRrLmVoZWFsdGgtaWQuZGUiLCJleHAiOjE3MjkyNTY5NzUsImlhdCI6MTcyOTE3MDU3NSwia2V5cyI6W3sia3R5IjoiRUMiLCJ4NXQjUzI1NiI6IjNZNHU1TS1IMGJtSE5GTjdqZER3V3NrNktsU01tOFUtbUEzMXM1Szg4OVEiLCJuYmYiOjE3MDc4MTU1MDQsInVzZSI6InNpZyIsImNydiI6IlAtMjU2Iiwia2lkIjoiand0c2lnbmVyLTIwMjQiLCJ4NWMiOlsiTUlJQi9qQ0NBYU9nQXdJQkFnSUVTSXlhSmpBTUJnZ3Foa2pPUFFRREFnVUFNSFF4Q3pBSkJnTlZCQWdUQWtKWE1SRXdEd1lEVlFRSEV3aEZhRzVwYm1kbGJqRUxNQWtHQTFVRUJoTUNSRVV4RERBS0JnTlZCQW9UQTBsQ1RURVlNQllHQTFVRUN4TVBVMlZyU1VSUUlGQnliMlFnVkVWRk1SMHdHd1lEVlFRREV4UktWMVJUYVdkdVpYSXRNakF5TkNCUVZTQlVTekFlRncweU5EQXlNVE13T1RFeE5EUmFGdzB5TlRBek1UY3dPVEV4TkRSYU1IUXhDekFKQmdOVkJBZ1RBa0pYTVJFd0R3WURWUVFIRXdoRmFHNXBibWRsYmpFTE1Ba0dBMVVFQmhNQ1JFVXhEREFLQmdOVkJBb1RBMGxDVFRFWU1CWUdBMVVFQ3hNUFUyVnJTVVJRSUZCeWIyUWdWRVZGTVIwd0d3WURWUVFERXhSS1YxUlRhV2R1WlhJdE1qQXlOQ0JRVlNCVVN6QlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJJOUJQNUdaOG1hMG5GQUMySE1jQTVDWnhsWkFodHVGY2xpaEk2Vm5KVXVzZjBBN1hhVWpicktYNDJsbVJ5ci9seWpuWFBYQktWd0p0UWJlcUxFVGZYeWpJVEFmTUIwR0ExVWREZ1FXQkJSTDNxZUpFWU51T2tmUmhubmtHbTMySFl6Uml6QU1CZ2dxaGtqT1BRUURBZ1VBQTBjQU1FUUNJQ2JPQldyTGlNSGduT2tNZjJSWWduTXVmZitKWnZqWXVCRjZiRVlja1pOQ0FpQlF1SmhZaTl0cXJrVkJNZjhRVFdacUI5K2hBM2lvQ3dXTzFTNFM2S2RSZVE9PSJdLCJ4IjoiajBFX2tabnlaclNjVUFMWWN4d0RrSm5HVmtDRzI0VnlXS0VqcFdjbFM2dyIsInkiOiJmMEE3WGFVamJyS1g0MmxtUnlyX2x5am5YUFhCS1Z3SnRRYmVxTEVUZlh3IiwiZXhwIjoxNzQyMjAyNzA0LCJhbGciOiJFUzI1NiJ9LHsia3R5IjoiRUMiLCJ4NXQjUzI1NiI6IkFPd1E1QTl6N1drRF96X2FXUVI1Um9RdlRyUGdLNVlFQnZDNVB2UjRzR3MiLCJuYmYiOjE3MDc4MTU1NzMsInVzZSI6InNpZyIsImNydiI6IlAtMjU2Iiwia2lkIjoidG9rZW5zaWduZXItMjAyNCIsIng1YyI6WyJNSUlDQWpDQ0FhZWdBd0lCQWdJRU5hUm1TekFNQmdncWhrak9QUVFEQWdVQU1IWXhDekFKQmdOVkJBZ1RBa0pYTVJFd0R3WURWUVFIRXdoRmFHNXBibWRsYmpFTE1Ba0dBMVVFQmhNQ1JFVXhEREFLQmdOVkJBb1RBMGxDVFRFWU1CWUdBMVVFQ3hNUFUyVnJTVVJRSUZCeWIyUWdWRVZGTVI4d0hRWURWUVFERXhaVWIydGxibE5wWjI1bGNpMHlNREkwSUZCVklGUkxNQjRYRFRJME1ESXhNekE1TVRJMU0xb1hEVEkxTURNeE56QTVNVEkxTTFvd2RqRUxNQWtHQTFVRUNCTUNRbGN4RVRBUEJnTlZCQWNUQ0VWb2JtbHVaMlZ1TVFzd0NRWURWUVFHRXdKRVJURU1NQW9HQTFVRUNoTURTVUpOTVJnd0ZnWURWUVFMRXc5VFpXdEpSRkFnVUhKdlpDQlVSVVV4SHpBZEJnTlZCQU1URmxSdmEyVnVVMmxuYm1WeUxUSXdNalFnVUZVZ1ZFc3dXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBU3BzLzU0MWRvb3hHVVg0dTM2bmYwbU5KSFhVc3JNT2FneFA2clZFdmtoY3RjUE5DUGJ2MjdGaS9UZE1COFVzSDM3ZUxUOElxanAreXMrTnpCQnV2bkNveUV3SHpBZEJnTlZIUTRFRmdRVUJRN3VncmhWa0pDYzBqdjVNRTlPanR6U3Evd3dEQVlJS29aSXpqMEVBd0lGQUFOSEFEQkVBaUJDTmJFeEtWMVROa2Y0bGVNOU43RzJWMFBHeVVpUzE5RkI1QXpqb0d4UndnSWdCdUgwK2RTMjN1WUtsV0R2ZUpLM2VPdkVpZDFyN0VHTDkrYWNSQVFUUCtZPSJdLCJ4IjoicWJQLWVOWGFLTVJsRi1MdC1wMzlKalNSMTFMS3pEbW9NVC1xMVJMNUlYSSIsInkiOiIxdzgwSTl1X2JzV0w5TjB3SHhTd2ZmdDR0UHdpcU9uN0t6NDNNRUc2LWNJIiwiZXhwIjoxNzQyMjAyNzczLCJhbGciOiJFUzI1NiJ9XX0.PN4Oo4x9GKuj4MjARDRj7zTv_xa094UpuuI9VSG1pGCS3DNFJuARw-8nPQLwYKSIkImGVXim5N2ZJyohQX3Zsg + """); + + assertTrue(eJwks.isValidAt(Instant.ofEpochSecond(1729256000L))); + assertEquals("https://idbroker.tk.ehealth-id.de", eJwks.body().iss()); + assertEquals(2, eJwks.body().toJWKSet().size()); + } +}