From 59559d95e86a8f42689a28905c2a2345ba525c11 Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Thu, 7 Nov 2024 11:58:04 +0100 Subject: [PATCH] EPA-164: Make all keymaterial configurable (#113) --- README.md | 49 +++-- TESTING.md | 4 + .../ehealthid/relyingparty/ConfigReader.java | 23 --- .../oviva/ehealthid/relyingparty/Main.java | 92 +++------ .../relyingparty/fed/FederationConfig.java | 71 +------ .../relyingparty/fed/FederationEndpoint.java | 23 ++- .../providers/BasicKeystoreProvider.java | 181 ++++++++++++++++++ .../relyingparty/providers/KeyStores.java | 44 +++++ .../ehealthid/relyingparty/svc/KeyStore.java | 29 --- .../relyingparty/svc/TokenIssuerImpl.java | 16 +- .../{poc => testenv}/Environment.java | 12 +- .../GematikHeaderDecoratorHttpClient.java | 6 +- .../relyingparty/util/KeyGenerator.java | 32 +++- .../oviva/ehealthid/relyingparty/ws/App.java | 22 ++- .../ws/FederationKeysAdapter.java | 41 ++++ .../relyingparty/ws/OpenIdEndpoint.java | 15 +- .../relyingparty/ConfigReaderTest.java | 6 - .../fed/FederationEndpointTest.java | 25 ++- .../providers/BasicKeystoreProviderTest.java | 168 ++++++++++++++++ .../relyingparty/svc/KeyStoreTest.java | 21 -- .../relyingparty/svc/TokenIssuerImplTest.java | 40 ++-- .../test/EmbeddedRelyingParty.java | 4 + .../relyingparty/test/PropertiesUtils.java | 24 +++ .../relyingparty/testenv/EnvironmentTest.java | 62 ++++++ .../GematikHeaderDecoratorHttpClientTest.java | 110 +++++++++++ .../relyingparty/ws/OpenIdEndpointTest.java | 6 +- .../test/resources/fixtures/rp_enc_jwks.json | 2 + .../test/resources/fixtures/rp_sig_jwks.json | 1 + .../com/oviva/ehealthid/util/JwksUtils.java | 2 +- 29 files changed, 841 insertions(+), 290 deletions(-) create mode 100644 ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProvider.java create mode 100644 ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/KeyStores.java delete mode 100644 ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/KeyStore.java rename ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/{poc => testenv}/Environment.java (66%) rename ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/{poc => testenv}/GematikHeaderDecoratorHttpClient.java (87%) create mode 100644 ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/FederationKeysAdapter.java create mode 100644 ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProviderTest.java delete mode 100644 ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/KeyStoreTest.java create mode 100644 ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/PropertiesUtils.java create mode 100644 ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/EnvironmentTest.java create mode 100644 ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/GematikHeaderDecoratorHttpClientTest.java create mode 100644 ehealthid-rp/src/test/resources/fixtures/rp_enc_jwks.json create mode 100644 ehealthid-rp/src/test/resources/fixtures/rp_sig_jwks.json diff --git a/README.md b/README.md index ffe9e4a..f2a31ec 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ a good old OpenID Connect Relying Party (OIDC RP). Identity Providers such as Keycloak can link accounts with OIDC out-of-the-box +## State of Compatibility + +### Productive Environment (PU) + +| Sectoral IdP | End-to-End | Provider | +|------------------------|------------|----------| +| Techniker Krankenkasse | ✅ | IBM | +| Gothaer | 🚫 | RISE | + +> [!NOTE] +> Most providers can not be independently tested as there are no test accounts available. + ## Authentication Flow IDP / Relying Party ```mermaid @@ -122,23 +134,26 @@ Use environment variables to configure the relying party server. (*) required configuration -| Name | Description | Example | -|------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------| -| `EHEALTHID_RP_FEDERATION_ES_JWKS_PATH`* | Path to a JWKS with at least one keypair for signature of the entity statement. All these keys __MUST__ be registered with the federation master. | `./sig_jwks.json` | -| `EHEALTHID_RP_REDIRECT_URIS`* | Valid redirection URIs for OpenID connect. | `https://sso-mydiga.example.com/auth/callback` | -| `EHEALTHID_RP_BASE_URI`* | The external base URI of the relying party. This is also the `issuer` towards the OpenID federation. Additional paths are unsupported for now. | `https://mydiga-rp.example.com` | -| `EHEALTHID_RP_IDP_DISCOVERY_URI`* | The URI of the discovery document of your identity provider. Used to fetch public keys for client authentication. | `https://sso-mydiga.example.com/.well-known/openid-configuration` | -| `EHEALTHID_RP_FEDERATION_MASTER`* | The URI of the federation master. | `https://app-test.federationmaster.de` | -| `EHEALTHID_RP_APP_NAME`* | The application name within the federation. | `Awesome DiGA` | -| `EHEALTHID_RP_HOST` | Host to bind to. | `0.0.0.0` | -| `EHEALTHID_RP_PORT` | Port to bind to. | `1234` | -| `EHEALTHID_RP_ES_TTL` | The time to live for the entity statement. In ISO8601 format. | `PT12H` | -| `EHEALTHID_RP_SCOPES` | The comma separated list of scopes requested in the federation. This __MUST__ match what was registered with the federation master. | `openid,urn:telematik:versicherter` | -| `EHEALTHID_RP_SESSION_STORE_TTL` | The time to live for sessions. In ISO8601 format. | `PT20M` | -| `EHEALTHID_RP_SESSION_STORE_MAX_ENTRIES` | The maximum number of sessions to store. Keeps memory bounded. | `1000` | -| `EHEALTHID_RP_CODE_STORE_TTL` | The time to live for codes, i.e. successful logins where the code is not redeemed yet. In ISO8601 format. | `PT5M` | -| `EHEALTHID_RP_CODE_STORE_MAX_ENTRIES` | The maximum number of codes to store. Keeps memory bounded. | `1000` | -| `EHEALTHID_RP_LOG_LEVEL` | The log level. | `INFO` | +| Name | Description | Example | +|----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------| +| `EHEALTHID_RP_FEDERATION_ES_JWKS_PATH`* | Path to a JWKS with at least one keypair for signature of the entity statement. All these keys __MUST__ be registered with the federation master. | `./sig_jwks.json` | +| `EHEALTHID_RP_OPENID_RP_SIG_JWKS_PATH`* | Path to a JWKS with signing keys of our relying party, i.e. for mTLS client certificates | `./openid_rp_sig_jwks.json` | +| `EHEALTHID_RP_OPENID_RP_ENC_JWKS_PATH`* | Path to a JWKS with the keys used for encryption between the federation and the relying party, i.e. to encrypt id_tokens | `./openid_rp_enc_jwks.json` | +| `EHEALTHID_RP_REDIRECT_URIS`* | Valid redirection URIs for OpenID connect. | `https://sso-mydiga.example.com/auth/callback` | +| `EHEALTHID_RP_BASE_URI`* | The external base URI of the relying party. This is also the `issuer` towards the OpenID federation. Additional paths are unsupported for now. | `https://mydiga-rp.example.com` | +| `EHEALTHID_RP_IDP_DISCOVERY_URI`* | The URI of the discovery document of your identity provider. Used to fetch public keys for client authentication. | `https://sso-mydiga.example.com/.well-known/openid-configuration` | +| `EHEALTHID_RP_FEDERATION_MASTER`* | The URI of the federation master. | `https://app-test.federationmaster.de` | +| `EHEALTHID_RP_APP_NAME`* | The application name within the federation. | `Awesome DiGA` | +| `EHEALTHID_RP_HOST` | Host to bind to. | `0.0.0.0` | +| `EHEALTHID_RP_PORT` | Port to bind to. | `1234` | +| `EHEALTHID_RP_ES_TTL` | The time to live for the entity statement. In ISO8601 format. | `PT12H` | +| `EHEALTHID_RP_SCOPES` | The comma separated list of scopes requested in the federation. This __MUST__ match what was registered with the federation master. | `openid,urn:telematik:versicherter` | +| `EHEALTHID_RP_SESSION_STORE_TTL` | The time to live for sessions. In ISO8601 format. | `PT20M` | +| `EHEALTHID_RP_SESSION_STORE_MAX_ENTRIES` | The maximum number of sessions to store. Keeps memory bounded. | `1000` | +| `EHEALTHID_RP_CODE_STORE_TTL` | The time to live for codes, i.e. successful logins where the code is not redeemed yet. In ISO8601 format. | `PT5M` | +| `EHEALTHID_RP_CODE_STORE_MAX_ENTRIES` | The maximum number of codes to store. Keeps memory bounded. | `1000` | +| `EHEALTHID_RP_LOG_LEVEL` | The log level. | `INFO` | +| `EHEALTHID_RP_OPENID_PROVIDER_SIG_JWKS_PATH` | Path to a JWKS with signing keys for our openIdProvider, for example the id_token issued by the relying party will be signed with it. Will be generated if not configured. | `./openid_provider_sig_jwks.json` | # Generate Keys & Register for Federation diff --git a/TESTING.md b/TESTING.md index 1c266d3..2b44f24 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,3 +1,7 @@ +# Debugging Entity-Statements + +curl https://pta-ehealthid.ovivadiga.com/.well-known/openid-federation | jwt decode -j - | jq -r .payload.metadata.openid_relying_party.jwks.keys[0].x5c[0] | base64 -d | openssl x509 -text + # Library IntegrationTest flow with Gematik Reference IDP diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java index 2dce252..6cea140 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java @@ -1,13 +1,10 @@ package com.oviva.ehealthid.relyingparty; -import com.nimbusds.jose.jwk.JWKSet; import com.oviva.ehealthid.relyingparty.cfg.ConfigProvider; import com.oviva.ehealthid.relyingparty.cfg.RelyingPartyConfig; import com.oviva.ehealthid.relyingparty.fed.FederationConfig; import com.oviva.ehealthid.relyingparty.util.Strings; -import com.oviva.ehealthid.util.JwksUtils; import java.net.URI; -import java.nio.file.Path; import java.time.Duration; import java.util.List; import java.util.Optional; @@ -42,8 +39,6 @@ public ConfigReader(ConfigProvider configProvider) { public Config read() { - var federationEntityStatementJwksPath = loadJwks(CONFIG_FEDERATION_ENTITY_STATEMENT_JWKS_PATH); - var baseUri = configProvider .get(CONFIG_BASE_URI) @@ -83,10 +78,6 @@ public Config read() { .iss(baseUri) .appName(appName) .federationMaster(fedmaster) - - // safety, remove the private key as we don't need it here - .entitySigningKeys(federationEntityStatementJwksPath.toPublicJWKSet()) - .entitySigningKey(federationEntityStatementJwksPath.getKeys().get(0).toECKey()) .ttl(entityStatementTtl) .scopes(getScopes()) .redirectUris(List.of(baseUri.resolve("/auth/callback").toString())) @@ -153,20 +144,6 @@ private int getPortConfig(String configPort, int defaultValue) { .orElse(defaultValue); } - private JWKSet loadJwks(String configName) { - - var path = - configProvider - .get(configName) - .map(Path::of) - .orElseThrow( - () -> - new IllegalArgumentException( - "missing jwks path for '%s'".formatted(configName))); - - return JwksUtils.load(path); - } - public record Config( RelyingPartyConfig relyingParty, FederationConfig federation, 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 7d2eafc..3191b37 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 @@ -2,8 +2,7 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import com.nimbusds.jose.jwk.ECKey; -import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.*; import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import com.oviva.ehealthid.auth.AuthenticationFlow; import com.oviva.ehealthid.fedclient.FederationMasterClientImpl; @@ -16,20 +15,19 @@ import com.oviva.ehealthid.relyingparty.ConfigReader.SessionStoreConfig; import com.oviva.ehealthid.relyingparty.cfg.ConfigProvider; import com.oviva.ehealthid.relyingparty.cfg.EnvConfigProvider; -import com.oviva.ehealthid.relyingparty.poc.GematikHeaderDecoratorHttpClient; +import com.oviva.ehealthid.relyingparty.providers.BasicKeystoreProvider; import com.oviva.ehealthid.relyingparty.svc.AfterCreatedExpiry; import com.oviva.ehealthid.relyingparty.svc.AuthService; import com.oviva.ehealthid.relyingparty.svc.CaffeineCodeRepo; import com.oviva.ehealthid.relyingparty.svc.CaffeineSessionRepo; import com.oviva.ehealthid.relyingparty.svc.ClientAuthenticator; import com.oviva.ehealthid.relyingparty.svc.CodeRepo; -import com.oviva.ehealthid.relyingparty.svc.KeyStore; import com.oviva.ehealthid.relyingparty.svc.SessionRepo; import com.oviva.ehealthid.relyingparty.svc.SessionRepo.Session; import com.oviva.ehealthid.relyingparty.svc.TokenIssuer.Code; import com.oviva.ehealthid.relyingparty.svc.TokenIssuerImpl; +import com.oviva.ehealthid.relyingparty.testenv.GematikHeaderDecoratorHttpClient; import com.oviva.ehealthid.relyingparty.util.DiscoveryJwkSetSource; -import com.oviva.ehealthid.relyingparty.util.KeyGenerator; import com.oviva.ehealthid.relyingparty.util.LoggingHttpClient; import com.oviva.ehealthid.relyingparty.ws.App; import com.oviva.ehealthid.relyingparty.ws.HealthEndpoint; @@ -48,11 +46,12 @@ import java.net.http.HttpClient; import java.time.Clock; import java.time.Duration; -import java.util.List; +import java.util.ArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -124,23 +123,34 @@ public void start() throws ExecutionException, InterruptedException { var config = configReader.read(); - // generate fresh keys for the relying-party - config = replaceRelyingPartyKeys(config); + var keyStores = BasicKeystoreProvider.load(configProvider); - var keyStore = new KeyStore(); var meterRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); var codeRepo = buildCodeRepo(config.codeStoreConfig(), meterRegistry); - var tokenIssuer = new TokenIssuerImpl(config.baseUri(), keyStore, codeRepo); + var tokenIssuer = + new TokenIssuerImpl( + config.baseUri(), + keyStores.openIdProviderJwksKeystore().keys().get(0)::toECKey, + codeRepo); var sessionRepo = buildSessionRepo(config.sessionStore(), meterRegistry); // the relying party signing key is for mTLS - var mTlsClientCertificate = config.federation().relyingPartySigningKey(); + var mTlsClientCertificate = + keyStores.relyingPartySigJwksKeystore(config.federation().sub()).keys().get(0); + + var relyingPartyKeys = + keyStores.relyingPartyEncJwksKeystore().keys().stream() + .map(k -> (JWK) k) + .collect(Collectors.toCollection(ArrayList::new)); + relyingPartyKeys.add(mTlsClientCertificate); + + var relyingPartyJwks = new JWKSet(relyingPartyKeys); var authFlow = buildAuthFlow( config.baseUri(), config.federation().federationMaster(), - config.federation().relyingPartyKeys(), + relyingPartyJwks, mTlsClientCertificate); var discoveryHttpClient = @@ -165,7 +175,7 @@ public void start() throws ExecutionException, InterruptedException { server = SeBootstrap.start( - new App(config, keyStore, tokenIssuer, clientAuthenticator, authService), + new App(config, keyStores, tokenIssuer, clientAuthenticator, authService), Configuration.builder().host(config.host()).port(config.port()).build()) .toCompletableFuture() .get(); @@ -173,6 +183,12 @@ public void start() throws ExecutionException, InterruptedException { var localUri = server.configuration().baseUri(); logger.atInfo().log("Magic at {} ({})", config.baseUri(), localUri); + bootManagementServer(config, meterRegistry); + logger.atInfo().log("Management Server can be found at port {}", config.managementPort()); + } + + private void bootManagementServer( + ConfigReader.Config config, PrometheusMeterRegistry meterRegistry) { managementServer = Undertow.builder() .addHttpListener(config.managementPort(), config.host()) @@ -182,56 +198,6 @@ public void start() throws ExecutionException, InterruptedException { .addExactPath(MetricsEndpoint.PATH, new MetricsEndpoint(meterRegistry))) .build(); managementServer.start(); - - logger.atInfo().log("Management Server can be found at port {}", config.managementPort()); - } - - private ConfigReader.Config replaceRelyingPartyKeys(ConfigReader.Config config) { - - logger.atInfo().log( - "Generating fresh 'openid_relying_party' keys for mTLS and id_token encryption."); - - var signingKey = KeyGenerator.generateSigningKeyWithCertificate(config.federation().sub()); - var encKey = KeyGenerator.generateEncryptionKey(); - - var keys = new JWKSet(List.of(signingKey, encKey)); - - logger - .atDebug() - .addKeyValue("kid", signingKey.getKeyID()) - .addKeyValue("jwk", signingKey.toJSONString()) - .log( - "openid_relying_party signing key, kid={} jwk={}", - signingKey.getKeyID(), - signingKey.toJSONString()); - - logger - .atDebug() - .addKeyValue("kid", encKey.getKeyID()) - .addKeyValue("jwk", encKey.toJSONString()) - .log( - "openid_relying_party encryption key, kid={} jwk={}", - encKey.getKeyID(), - encKey.toJSONString()); - - var fedConfig = - config - .federation() - .builder() - .relyingPartySigningKey(signingKey.toECKey()) - .relyingPartyKeys(keys) - .build(); - - return new ConfigReader.Config( - config.relyingParty(), - fedConfig, - config.host(), - config.port(), - config.managementPort(), - config.baseUri(), - config.idpDiscoveryUri(), - config.sessionStore(), - config.codeStoreConfig()); } private com.oviva.ehealthid.fedclient.api.HttpClient instrumentHttpClient( diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java index cdca809..bd501fc 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java @@ -1,7 +1,5 @@ package com.oviva.ehealthid.relyingparty.fed; -import com.nimbusds.jose.jwk.ECKey; -import com.nimbusds.jose.jwk.JWKSet; import java.net.URI; import java.time.Duration; import java.util.List; @@ -10,16 +8,6 @@ public record FederationConfig( URI iss, URI sub, URI federationMaster, - - // trusted signing keys in the federation - JWKSet entitySigningKeys, - - // the actual private key used for signing, _MUST_ be part of `entitySigningKeys` - ECKey entitySigningKey, - - // keys uses for the relying-party, i.e. mTLS and idToken encryption - JWKSet relyingPartyKeys, - ECKey relyingPartySigningKey, Duration ttl, List redirectUris, List scopes, @@ -30,18 +18,7 @@ public static Builder create() { } public Builder builder() { - return new Builder( - iss, - sub, - federationMaster, - entitySigningKeys, - entitySigningKey, - relyingPartyKeys, - relyingPartySigningKey, - ttl, - redirectUris, - scopes, - appName); + return new Builder(iss, sub, federationMaster, ttl, redirectUris, scopes, appName); } public static final class Builder { @@ -50,15 +27,10 @@ public static final class Builder { private URI sub; private URI federationMaster; - private ECKey entitySigningKey; - private JWKSet entitySigningKeys; - - private JWKSet relyingPartyKeys; private Duration ttl; private List redirectUris; private List scopes; private String appName; - private ECKey relyingPartySigningKey; private Builder() {} @@ -66,10 +38,6 @@ private Builder( URI iss, URI sub, URI federationMaster, - JWKSet entitySigningKeys, - ECKey entitySigningKey, - JWKSet relyingPartyKeys, - ECKey relyingPartySigningKey, Duration ttl, List redirectUris, List scopes, @@ -77,14 +45,10 @@ private Builder( this.iss = iss; this.sub = sub; this.federationMaster = federationMaster; - this.entitySigningKey = entitySigningKey; - this.entitySigningKeys = entitySigningKeys; - this.relyingPartyKeys = relyingPartyKeys; this.ttl = ttl; this.redirectUris = redirectUris; this.scopes = scopes; this.appName = appName; - this.relyingPartySigningKey = relyingPartySigningKey; } public Builder iss(URI iss) { @@ -102,26 +66,6 @@ public Builder federationMaster(URI federationMaster) { return this; } - public Builder entitySigningKey(ECKey signingKey) { - this.entitySigningKey = signingKey; - return this; - } - - public Builder entitySigningKeys(JWKSet jwks) { - this.entitySigningKeys = jwks; - return this; - } - - public Builder relyingPartyKeys(JWKSet jwks) { - this.relyingPartyKeys = jwks; - return this; - } - - public Builder relyingPartySigningKey(ECKey signingKey) { - this.relyingPartySigningKey = signingKey; - return this; - } - public Builder ttl(Duration ttl) { this.ttl = ttl; return this; @@ -143,18 +87,7 @@ public Builder scopes(List scopes) { } public FederationConfig build() { - return new FederationConfig( - iss, - sub, - federationMaster, - entitySigningKeys, - entitySigningKey, - relyingPartyKeys, - relyingPartySigningKey, - ttl, - redirectUris, - scopes, - appName); + return new FederationConfig(iss, sub, federationMaster, ttl, redirectUris, scopes, appName); } } } diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java index 0ef4abb..b408784 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java @@ -1,5 +1,7 @@ package com.oviva.ehealthid.relyingparty.fed; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWKSet; import com.oviva.ehealthid.fedclient.api.EntityStatement; import com.oviva.ehealthid.fedclient.api.EntityStatement.FederationEntity; import com.oviva.ehealthid.fedclient.api.EntityStatement.Metadata; @@ -17,9 +19,11 @@ public class FederationEndpoint { static final String MEDIA_TYPE_ENTITY_STATEMENT = "application/entity-statement+jwt"; private final FederationConfig federationConfig; + private final FederationKeys federationKeys; - public FederationEndpoint(FederationConfig federationConfig) { + public FederationEndpoint(FederationConfig federationConfig, FederationKeys federationKeys) { this.federationConfig = federationConfig; + this.federationKeys = federationKeys; } @Path("/.well-known/openid-federation") @@ -27,8 +31,8 @@ public FederationEndpoint(FederationConfig federationConfig) { @Produces(MEDIA_TYPE_ENTITY_STATEMENT) public Response get() { - var federationEntityJwks = federationConfig.entitySigningKeys().toPublicJWKSet(); - var relyingPartyJwks = federationConfig.relyingPartyKeys().toPublicJWKSet(); + var federationEntityJwks = federationKeys.federationKeys(); + var relyingPartyJwks = federationKeys.relyingPartyJwks(); var now = Instant.now(); var exp = now.plus(federationConfig.ttl()); @@ -71,7 +75,7 @@ public Response get() { .build()) .jwks(federationEntityJwks) .build() - .sign(federationConfig.entitySigningKey()); + .sign(federationKeys.federationSigningKey()); return Response.ok(jws.serialize()) .header("x-kc-provider", "ovi") @@ -79,6 +83,17 @@ public Response get() { .build(); } + public interface FederationKeys { + + /** key used to self-sign the entity key, MUST be registered in the federation */ + JWKSet federationKeys(); + + /** key used to self-sign the entity key, MUST be registered in the federation */ + ECKey federationSigningKey(); + + JWKSet relyingPartyJwks(); + } + private CacheControl cacheForTtl(Instant now) { var cacheUntil = now.plusSeconds((long) (federationConfig.ttl().getSeconds() * 0.8)); diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProvider.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProvider.java new file mode 100644 index 0000000..4a36865 --- /dev/null +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProvider.java @@ -0,0 +1,181 @@ +package com.oviva.ehealthid.relyingparty.providers; + +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.ehealthid.relyingparty.cfg.ConfigProvider; +import com.oviva.ehealthid.relyingparty.util.KeyGenerator; +import com.oviva.ehealthid.util.JwksUtils; +import java.net.URI; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BasicKeystoreProvider { + + private static final Logger logger = LoggerFactory.getLogger(BasicKeystoreProvider.class); + + public static final String CONFIG_FEDERATION_SIG_JWKS_PATH = "federation_es_jwks_path"; + public static final String CONFIG_OPENID_RP_SIG_JWKS_PATH = "openid_rp_sig_jwks_path"; + public static final String CONFIG_OPENID_RP_ENC_JWKS_PATH = "openid_rp_enc_jwks_path"; + public static final String CONFIG_OPENID_PROVIDER_SIG_JWKS_PATH = "openid_provider_sig_jwks_path"; + + public static KeyStores load(ConfigProvider config) { + var openIdProviderSigningKeys = + loadJwks(config, CONFIG_OPENID_PROVIDER_SIG_JWKS_PATH) + .orElseGet( + () -> { + logger.atInfo().log( + "no key for {} configured, generating ephemeral key", + CONFIG_OPENID_PROVIDER_SIG_JWKS_PATH); + return new JWKSet(KeyGenerator.generateSigningKey()); + }); + + return fromKeys( + loadJwks(config, CONFIG_FEDERATION_SIG_JWKS_PATH).orElseThrow(), + mustGetFirstKey(config, CONFIG_OPENID_RP_SIG_JWKS_PATH), + mustGetFirstKey(config, CONFIG_OPENID_RP_ENC_JWKS_PATH), + openIdProviderSigningKeys.getKeys().get(0).toECKey()); + } + + private static ECKey mustGetFirstKey(ConfigProvider configProvider, String config) { + + var loadedJwks = loadJwks(configProvider, config); + + var jwks = + loadedJwks.orElseThrow( + () -> new IllegalArgumentException("JWKSet of '%s' is empty".formatted(config))); + + var firstKey = jwks.getKeys().get(0); + if (!(firstKey instanceof ECKey ecKey)) { + throw new IllegalStateException( + "First key in JWKS of '%s' is not an ECKey".formatted(config)); + } + + if (!ecKey.isPrivate()) { + throw new IllegalStateException( + "First key in JWKS of '%s' is missing private key".formatted(config)); + } + + debugLogJwk(config, ecKey); + + return ecKey; + } + + private static Optional loadJwks(ConfigProvider configProvider, String configName) { + + var config = configProvider.get(configName); + if (config.isEmpty()) { + return Optional.empty(); + } + + var path = + config + .map(Path::of) + .orElseThrow( + () -> new IllegalArgumentException("bad jwks path for '%s'".formatted(configName))); + + var jwks = JwksUtils.load(path); + if (jwks.isEmpty()) { + throw new IllegalArgumentException("JWKSet is present but has no contents"); + } + + return Optional.of(jwks); + } + + private static void debugLogJwk(String purpose, JWK jwk) { + logger + .atDebug() + .addKeyValue("kid", jwk.getKeyID()) + .addKeyValue("jwk", jwk.toJSONString()) + .log("{} signing key, kid={} jwk={}", purpose, jwk.getKeyID(), jwk.toJSONString()); + } + + /** + * @param federationSigningKey the trusted keys of our party in the federation, the first key will + * be used for signing operations + * @param openIdRelyingPartySigningKey signing key of our relying party, i.e. for mTLS client + * certificates + * @param openIdRelyingPartyEncryptionKey encryption key of our relying party, i.e. for ephemeral + * JWE encryption of id_tokens + * @param openIdProviderSigningKey signing key for our openIdProvider, the id_token issued by the + * relying party will be signed with it + */ + public static KeyStores fromKeys( + JWKSet federationSigningKey, + ECKey openIdRelyingPartySigningKey, + ECKey openIdRelyingPartyEncryptionKey, + ECKey openIdProviderSigningKey) { + + var federationJwks = federationSigningKey.getKeys().stream().map(JWK::toECKey).toList(); + + var rpSigningKeyProvider = cachedSigningKeyProvider(openIdRelyingPartySigningKey); + + return new StaticKeyStores( + rpSigningKeyProvider, + openIdRelyingPartyEncryptionKey, + openIdProviderSigningKey, + federationJwks); + } + + private static Function cachedSigningKeyProvider(ECKey openIdRelyingPartySigningKey) { + + var cache = new ConcurrentHashMap(); + + return uri -> + cache.compute( + uri, + (u, key) -> { + if (key == null) { + key = KeyGenerator.addCertificate(openIdRelyingPartySigningKey, u); + } + return key; + }); + } + + private BasicKeystoreProvider() {} + + static class StaticKeyStores implements KeyStores { + + private final Function rpSigKeysProvider; + private final ECKey rpEncKey; + private final ECKey opKey; + + private final List federationSigKey; + + StaticKeyStores( + Function rpSigKeysProvider, + ECKey rpEncKey, + ECKey opKey, + List federationSigKey) { + this.rpSigKeysProvider = rpSigKeysProvider; + this.rpEncKey = rpEncKey; + this.opKey = opKey; + this.federationSigKey = federationSigKey; + } + + @Override + public KeyStore openIdProviderJwksKeystore() { + return () -> List.of(opKey); + } + + @Override + public KeyStore federationSigJwksKeystore() { + return () -> federationSigKey; + } + + @Override + public KeyStore relyingPartyEncJwksKeystore() { + return () -> List.of(rpEncKey); + } + + @Override + public KeyStore relyingPartySigJwksKeystore(URI issuer) { + return () -> List.of(rpSigKeysProvider.apply(issuer)); + } + } +} diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/KeyStores.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/KeyStores.java new file mode 100644 index 0000000..9619008 --- /dev/null +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/providers/KeyStores.java @@ -0,0 +1,44 @@ +package com.oviva.ehealthid.relyingparty.providers; + +import com.nimbusds.jose.jwk.ECKey; +import java.net.URI; +import java.util.List; + +public interface KeyStores { + + /** + * Signing keys for our openIdProvider, for example the id_token issued by the relying party will + * be signed with it. + * + * @return JWKS for the openIdProvider + */ + KeyStore openIdProviderJwksKeystore(); + + /** + * @return the trusted keys of our party in the federation, the first key will be used for signing + * operations such as self-signing the entity-statement + */ + KeyStore federationSigJwksKeystore(); + + /** + * @return the keys used for encryption between the federation and the relying party, i.e. to + * encrypt id_tokens + */ + KeyStore relyingPartyEncJwksKeystore(); + + /** + * @param issuer the issuer of the certificate - i.e. itself for mTLS client certificates or a + * trusted CA + * @return signing keys of our relying party, i.e. for mTLS client certificates + */ + KeyStore relyingPartySigJwksKeystore(URI issuer); + + interface KeyStore { + + /** + * keys in order of preference, i.e. if something is signed the first key in the list will be + * preferred + */ + List keys(); + } +} diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/KeyStore.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/KeyStore.java deleted file mode 100644 index afc5745..0000000 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/KeyStore.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.oviva.ehealthid.relyingparty.svc; - -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.jwk.Curve; -import com.nimbusds.jose.jwk.ECKey; -import com.nimbusds.jose.jwk.KeyUse; -import com.nimbusds.jose.jwk.gen.ECKeyGenerator; - -public class KeyStore { - - private final ECKey signingKey; - - public KeyStore() { - - try { - this.signingKey = - new ECKeyGenerator(Curve.P_256) - .keyIDFromThumbprint(false) - .keyUse(KeyUse.SIGNATURE) - .generate(); - } catch (JOSEException e) { - throw new IllegalStateException("failed to generate EC signing key", e); - } - } - - public ECKey signingKey() { - return signingKey; - } -} diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImpl.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImpl.java index 4977cfd..c9c6971 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImpl.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImpl.java @@ -4,6 +4,7 @@ import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import com.oviva.ehealthid.auth.IdTokenJWS; @@ -15,21 +16,22 @@ import java.time.Duration; import java.util.Date; import java.util.UUID; +import javax.swing.*; public class TokenIssuerImpl implements TokenIssuer { private final URI issuer; - private final KeyStore keyStore; + private final SigningKeyProvider signingKeyProvider; private final Duration TTL = Duration.ofSeconds(60); private final Clock clock = Clock.systemUTC(); private final CodeRepo codeRepo; - public TokenIssuerImpl(URI issuer, KeyStore keyStore, CodeRepo codeRepo) { + public TokenIssuerImpl(URI issuer, SigningKeyProvider signingKeyProvider, CodeRepo codeRepo) { this.issuer = issuer; - this.keyStore = keyStore; + this.signingKeyProvider = signingKeyProvider; this.codeRepo = codeRepo; } @@ -87,7 +89,7 @@ private boolean validateCode(Code code, String redirectUri, String clientId) { String issueIdToken(String audience, String nonce, IdTokenJWS federatedIdToken) { try { - var jwk = keyStore.signingKey(); + var jwk = signingKeyProvider.signingKey(); var signer = new ECDSASigner(jwk); // Prepare JWT with claims set @@ -148,7 +150,7 @@ private String deriveFederatedSubject(IdTokenJWS.IdToken federatedIdToken) { private String issueAccessToken(Duration ttl, String audience) { try { - var jwk = keyStore.signingKey(); + var jwk = signingKeyProvider.signingKey(); var signer = new ECDSASigner(jwk); // Prepare JWT with claims set @@ -173,4 +175,8 @@ private String issueAccessToken(Duration ttl, String audience) { throw new RuntimeException(e); } } + + public interface SigningKeyProvider { + ECKey signingKey(); + } } diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/Environment.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/testenv/Environment.java similarity index 66% rename from ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/Environment.java rename to ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/testenv/Environment.java index 72b2af0..4eb2e7a 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/Environment.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/testenv/Environment.java @@ -1,9 +1,11 @@ -package com.oviva.ehealthid.relyingparty.poc; +package com.oviva.ehealthid.relyingparty.testenv; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; +import java.util.function.Function; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,19 +13,23 @@ public class Environment { private static Logger logger = LoggerFactory.getLogger(Environment.class); + // for easier mocking + static Function getenv = System::getenv; + static Supplier envPath = () -> Path.of("./env.properties"); + private Environment() {} public static String gematikAuthHeader() { // for testing in TU & RU var name = "GEMATIK_AUTH_HEADER"; - var header = System.getenv(name); + var header = getenv.apply(name); if (header != null && !header.isBlank()) { return header; } var prop = new Properties(); - try (var br = Files.newBufferedReader(Path.of("./env.properties"))) { + try (var br = Files.newBufferedReader(envPath.get())) { prop.load(br); return prop.getProperty(name); } catch (IOException e) { diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/GematikHeaderDecoratorHttpClient.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/testenv/GematikHeaderDecoratorHttpClient.java similarity index 87% rename from ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/GematikHeaderDecoratorHttpClient.java rename to ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/testenv/GematikHeaderDecoratorHttpClient.java index 72b8893..cdc5a54 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/GematikHeaderDecoratorHttpClient.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/testenv/GematikHeaderDecoratorHttpClient.java @@ -1,4 +1,4 @@ -package com.oviva.ehealthid.relyingparty.poc; +package com.oviva.ehealthid.relyingparty.testenv; import com.oviva.ehealthid.fedclient.api.HttpClient; import java.util.ArrayList; @@ -6,6 +6,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Currently Gematik's IdP is not publicly accessible and needs a special header. For simplicity, we + * just inject the header if the host matches. + */ public class GematikHeaderDecoratorHttpClient implements HttpClient { private static final Logger logger = diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/util/KeyGenerator.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/util/KeyGenerator.java index 8352b02..9753a92 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/util/KeyGenerator.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/util/KeyGenerator.java @@ -3,7 +3,6 @@ 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.KeyUse; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import com.nimbusds.jose.util.Base64; @@ -23,16 +22,29 @@ public class KeyGenerator { private KeyGenerator() {} - @NonNull - public static JWK generateSigningKeyWithCertificate(@NonNull URI issuer) { + public static ECKey generateSigningKey() { try { - var key = - new ECKeyGenerator(Curve.P_256) - .keyUse(KeyUse.SIGNATURE) - .keyIDFromThumbprint(true) - .generate(); + return new ECKeyGenerator(Curve.P_256) + .keyIDFromThumbprint(true) + .keyUse(KeyUse.SIGNATURE) + .generate(); + } catch (JOSEException e) { + throw new IllegalStateException("failed to generate signing key", e); + } + } + @NonNull + public static ECKey generateSigningKeyWithCertificate(@NonNull URI issuer) { + + var key = generateSigningKey(); + return addCertificate(key, issuer); + } + + @NonNull + public static ECKey addCertificate(ECKey key, @NonNull URI issuer) { + + try { var now = Instant.now(); var nbf = now.minus(Duration.ofHours(3)); @@ -55,12 +67,12 @@ public static JWK generateSigningKeyWithCertificate(@NonNull URI issuer) { | JOSEException | CertificateEncodingException e) { throw new IllegalStateException( - "failed to generate signing key for issuer=%s".formatted(issuer), e); + "failed to add certificate for issuer=%s".formatted(issuer), e); } } @NonNull - public static JWK generateEncryptionKey() { + public static ECKey generateEncryptionKey() { try { return new ECKeyGenerator(Curve.P_256) .keyUse(KeyUse.ENCRYPTION) diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java index 3ab84e7..67de9b4 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java @@ -3,22 +3,25 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.jakarta.rs.json.JacksonJsonProvider; +import com.nimbusds.jose.jwk.ECKey; import com.oviva.ehealthid.relyingparty.ConfigReader.Config; import com.oviva.ehealthid.relyingparty.fed.FederationEndpoint; +import com.oviva.ehealthid.relyingparty.providers.KeyStores; import com.oviva.ehealthid.relyingparty.svc.AuthService; import com.oviva.ehealthid.relyingparty.svc.ClientAuthenticator; -import com.oviva.ehealthid.relyingparty.svc.KeyStore; import com.oviva.ehealthid.relyingparty.svc.TokenIssuer; import com.oviva.ehealthid.util.JoseModule; import jakarta.ws.rs.core.Application; import java.util.Set; +import java.util.function.Supplier; import org.jboss.resteasy.plugins.providers.ByteArrayProvider; import org.jboss.resteasy.plugins.providers.StringTextStar; public class App extends Application { private final Config config; - private final KeyStore keyStore; + private final Supplier openIdProviderSigningKeys; + private final KeyStores keyStores; private final TokenIssuer tokenIssuer; private final ClientAuthenticator clientAuthenticator; @@ -26,25 +29,32 @@ public class App extends Application { public App( Config config, - KeyStore keyStore, + KeyStores keyStores, TokenIssuer tokenIssuer, ClientAuthenticator clientAuthenticator, AuthService authService) { this.config = config; - this.keyStore = keyStore; + this.keyStores = keyStores; this.tokenIssuer = tokenIssuer; this.clientAuthenticator = clientAuthenticator; this.authService = authService; + + this.openIdProviderSigningKeys = + () -> { + var ks = keyStores.openIdProviderJwksKeystore(); + return ks.keys().get(0); + }; } @Override public Set getSingletons() { return Set.of( - new FederationEndpoint(config.federation()), + new FederationEndpoint( + config.federation(), new FederationKeysAdapter(config.federation().sub(), keyStores)), new AuthEndpoint(authService), new TokenEndpoint(tokenIssuer, clientAuthenticator), - new OpenIdEndpoint(config.baseUri(), config.relyingParty(), keyStore), + new OpenIdEndpoint(config.baseUri(), config.relyingParty(), openIdProviderSigningKeys), new JacksonJsonProvider(configureObjectMapper())); } diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/FederationKeysAdapter.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/FederationKeysAdapter.java new file mode 100644 index 0000000..fc0abfb --- /dev/null +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/FederationKeysAdapter.java @@ -0,0 +1,41 @@ +package com.oviva.ehealthid.relyingparty.ws; + +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.ehealthid.relyingparty.fed.FederationEndpoint; +import com.oviva.ehealthid.relyingparty.providers.KeyStores; +import java.net.URI; +import java.util.ArrayList; + +public class FederationKeysAdapter implements FederationEndpoint.FederationKeys { + + private final URI subject; + private final KeyStores keyStores; + + public FederationKeysAdapter(URI subject, KeyStores keyStores) { + this.subject = subject; + this.keyStores = keyStores; + } + + @Override + public JWKSet federationKeys() { + var keys = keyStores.federationSigJwksKeystore().keys().stream().map(k -> (JWK) k).toList(); + return new JWKSet(keys).toPublicJWKSet(); + } + + @Override + public ECKey federationSigningKey() { + return keyStores.federationSigJwksKeystore().keys().get(0); + } + + @Override + public JWKSet relyingPartyJwks() { + var all = new ArrayList(); + keyStores.relyingPartyEncJwksKeystore().keys().stream().map(k -> (JWK) k).forEach(all::add); + keyStores.relyingPartySigJwksKeystore(subject).keys().stream() + .map(k -> (JWK) k) + .forEach(all::add); + return new JWKSet(all).toPublicJWKSet(); + } +} diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpoint.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpoint.java index a93afc9..7dd340a 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpoint.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpoint.java @@ -1,8 +1,8 @@ package com.oviva.ehealthid.relyingparty.ws; +import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWKSet; import com.oviva.ehealthid.relyingparty.cfg.RelyingPartyConfig; -import com.oviva.ehealthid.relyingparty.svc.KeyStore; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; @@ -12,18 +12,23 @@ import java.net.URI; import java.time.Duration; import java.util.List; +import java.util.function.Supplier; @Path("/") public class OpenIdEndpoint { private final URI baseUri; private final RelyingPartyConfig relyingPartyConfig; - private final KeyStore keyStore; + private final Supplier openIdProviderSigningKey; - public OpenIdEndpoint(URI baseUri, RelyingPartyConfig relyingPartyConfig, KeyStore keyStore) { + public OpenIdEndpoint( + URI baseUri, + RelyingPartyConfig relyingPartyConfig, + Supplier openIdProviderSigningKey) { this.baseUri = baseUri; + this.relyingPartyConfig = relyingPartyConfig; - this.keyStore = keyStore; + this.openIdProviderSigningKey = openIdProviderSigningKey; } @GET @@ -53,7 +58,7 @@ public Response openIdConfiguration() { @Path("/jwks.json") @Produces(MediaType.APPLICATION_JSON) public Response jwks() { - var key = keyStore.signingKey().toPublicJWK(); + var key = openIdProviderSigningKey.get().toPublicJWK(); var cacheControl = new CacheControl(); cacheControl.setMaxAge((int) Duration.ofMinutes(30).getSeconds()); diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java index d3e005b..aa8405e 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java @@ -44,12 +44,6 @@ void read_defaults() { assertEquals(baseUri, config.federation().iss().toString()); assertEquals(baseUri, config.federation().sub().toString()); assertEquals(List.of("openid", "urn:telematik:versicherter"), config.federation().scopes()); - - assertNotNull(config.federation().entitySigningKey()); - assertNotNull(config.federation().entitySigningKeys().getKeyByKeyId("test-sig")); - - // these will be generated - assertNull(config.federation().relyingPartyKeys()); } @Test diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java index 94f8ca9..3fe02ee 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java @@ -7,6 +7,7 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; @@ -54,18 +55,34 @@ static void setUp() throws ExecutionException, InterruptedException, JOSEExcepti .appName("My App") .scopes(List.of("openid", "email")) .federationMaster(FEDMASTER) - .relyingPartyKeys(new JWKSet(encryptionKey)) - .entitySigningKeys(new JWKSet(signatureKey)) - .entitySigningKey(signatureKey) .ttl(Duration.ofMinutes(5)) .build(); + var federationKeys = + new FederationEndpoint.FederationKeys() { + + @Override + public JWKSet federationKeys() { + return new JWKSet(signatureKey); + } + + @Override + public ECKey federationSigningKey() { + return signatureKey; + } + + @Override + public JWKSet relyingPartyJwks() { + return new JWKSet(encryptionKey); + } + }; + server = SeBootstrap.start( new Application() { @Override public Set getSingletons() { - return Set.of(new FederationEndpoint(config)); + return Set.of(new FederationEndpoint(config, federationKeys)); } }, Configuration.builder().host("127.0.0.1").port(0).build()) diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProviderTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProviderTest.java new file mode 100644 index 0000000..a3e419e --- /dev/null +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/providers/BasicKeystoreProviderTest.java @@ -0,0 +1,168 @@ +package com.oviva.ehealthid.relyingparty.providers; + +import static org.junit.jupiter.api.Assertions.*; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.oviva.ehealthid.relyingparty.cfg.ConfigProvider; +import com.oviva.ehealthid.relyingparty.test.PropertiesUtils; +import com.oviva.ehealthid.relyingparty.util.KeyGenerator; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BasicKeystoreProviderTest { + + @Test + void getters() { + + var fedKey = KeyGenerator.generateSigningKey(); + + var sub = URI.create("https://myidp.example.com"); + var rpSigKey = KeyGenerator.generateSigningKey(); + var provider = Map.of(sub, rpSigKey); + + var rpEncKey = KeyGenerator.generateSigningKey(); + + var opSigKey = KeyGenerator.generateSigningKey(); + + var sut = + new BasicKeystoreProvider.StaticKeyStores( + provider::get, rpEncKey, opSigKey, List.of(fedKey)); + + assertEquals(fedKey, sut.federationSigJwksKeystore().keys().get(0)); + assertEquals(rpSigKey, sut.relyingPartySigJwksKeystore(sub).keys().get(0)); + assertEquals(rpEncKey, sut.relyingPartyEncJwksKeystore().keys().get(0)); + assertEquals(opSigKey, sut.openIdProviderJwksKeystore().keys().get(0)); + } + + @Test + void fromKeys() { + + var fedKey = KeyGenerator.generateSigningKey(); + + var sub = URI.create("https://myidp.example.com"); + var rpSigKey = KeyGenerator.generateSigningKey(); + rpSigKey = KeyGenerator.addCertificate(rpSigKey, sub); + + var rpEncKey = KeyGenerator.generateSigningKey(); + + var opSigKey = KeyGenerator.generateSigningKey(); + + var sut = BasicKeystoreProvider.fromKeys(new JWKSet(fedKey), rpSigKey, rpEncKey, opSigKey); + + assertEquals( + rpSigKey.getKeyID(), sut.relyingPartySigJwksKeystore(sub).keys().get(0).getKeyID()); + + assertEquals(fedKey, sut.federationSigJwksKeystore().keys().get(0)); + assertEquals(rpEncKey, sut.relyingPartyEncJwksKeystore().keys().get(0)); + assertEquals(opSigKey, sut.openIdProviderJwksKeystore().keys().get(0)); + } + + @Test + void cachedSigKeys() { + + var fedKey = KeyGenerator.generateSigningKey(); + + var sub = URI.create("https://myidp.example.com"); + var rpSigKey = KeyGenerator.generateSigningKey(); + rpSigKey = KeyGenerator.addCertificate(rpSigKey, sub); + + var rpEncKey = KeyGenerator.generateSigningKey(); + + var opSigKey = KeyGenerator.generateSigningKey(); + + var sut = BasicKeystoreProvider.fromKeys(new JWKSet(fedKey), rpSigKey, rpEncKey, opSigKey); + + var firstInvocation = sut.relyingPartySigJwksKeystore(sub).keys(); + var secondInvocation = sut.relyingPartySigJwksKeystore(sub).keys(); + var thirdInvocation = sut.relyingPartySigJwksKeystore(sub).keys(); + + assertEquals(firstInvocation, secondInvocation); + assertEquals(firstInvocation, thirdInvocation); + } + + @Test + void loadFromConfig(@TempDir Path tempDir) throws IOException { + + var k = new JWKSet(KeyGenerator.generateSigningKey()); + var fedKeyName = "fedSigningKey"; + var f = Files.writeString(tempDir.resolve(fedKeyName), k.toString(false)); + + var config = + PropertiesUtils.loadFromString( + """ + federation_es_jwks_path=%s + openid_rp_sig_jwks_path=%s + openid_rp_enc_jwks_path=%s + openid_provider_sig_jwks_path=%s + """ + .formatted(f, f, f, f)); + + ConfigProvider configProvider = c -> Optional.ofNullable(config.get(c)); + + // when + var sut = BasicKeystoreProvider.load(configProvider); + + // then + var federationSigKeys = sut.federationSigJwksKeystore(); + assertEquals(k.getKeys().get(0), federationSigKeys.keys().get(0)); + } + + @Test + void loadFromConfig_badKeys(@TempDir Path tempDir) throws IOException, JOSEException { + + var key = + new RSAKeyGenerator(2048).keyIDFromThumbprint(true).keyUse(KeyUse.SIGNATURE).generate(); + + var k = new JWKSet(key); + var fedKeyName = "fedSigningKey"; + var f = Files.writeString(tempDir.resolve(fedKeyName), k.toString(false)); + + var config = + PropertiesUtils.loadFromString( + """ + federation_es_jwks_path=%s + openid_rp_sig_jwks_path=%s + openid_rp_enc_jwks_path=%s + openid_provider_sig_jwks_path=%s + """ + .formatted(f, f, f, f)); + + ConfigProvider configProvider = c -> Optional.ofNullable(config.get(c)); + + // when & then + assertThrows(IllegalStateException.class, () -> BasicKeystoreProvider.load(configProvider)); + } + + @Test + void loadFromConfig_noPrivateKey(@TempDir Path tempDir) throws IOException { + + var k = new JWKSet(KeyGenerator.generateSigningKey()); + var fedKeyName = "fedSigningKey"; + var f = Files.writeString(tempDir.resolve(fedKeyName), k.toString(true)); + + var config = + PropertiesUtils.loadFromString( + """ + federation_es_jwks_path=%s + openid_rp_sig_jwks_path=%s + openid_rp_enc_jwks_path=%s + openid_provider_sig_jwks_path=%s + """ + .formatted(f, f, f, f)); + + ConfigProvider configProvider = c -> Optional.ofNullable(config.get(c)); + + // when & then + assertThrows(IllegalStateException.class, () -> BasicKeystoreProvider.load(configProvider)); + } +} diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/KeyStoreTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/KeyStoreTest.java deleted file mode 100644 index 7b7dee8..0000000 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/KeyStoreTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.oviva.ehealthid.relyingparty.svc; - -import static org.junit.jupiter.api.Assertions.*; - -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.jwk.KeyUse; -import org.junit.jupiter.api.Test; - -class KeyStoreTest { - - @Test - void signingKey() throws JOSEException { - - var sut = new KeyStore(); - - var key = sut.signingKey(); - - assertEquals(KeyUse.SIGNATURE, key.getKeyUse()); - assertNotNull(key.toECPrivateKey()); - } -} diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImplTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImplTest.java index d13aa46..3324339 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImplTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/svc/TokenIssuerImplTest.java @@ -28,10 +28,10 @@ class TokenIssuerImplTest { @Test void issueCode_unique() { var issuer = URI.create("https://idp.example.com"); - var keyStore = mock(KeyStore.class); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var session = Session.create().build(); @@ -51,10 +51,10 @@ void issueCode_unique() { @Test void issueCode_notExpired() { var issuer = URI.create("https://idp.example.com"); - var keyStore = mock(KeyStore.class); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var session = Session.create().build(); @@ -70,10 +70,10 @@ void issueCode_notExpired() { @Test void issueCode_propagatesValues() { var issuer = URI.create("https://idp.example.com"); - var keyStore = mock(KeyStore.class); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var nonce = UUID.randomUUID().toString(); var redirectUri = URI.create("https://myapp.example.com/callback"); @@ -112,10 +112,10 @@ void issueCode_saves() { @Test void redeem_nonExisting() { var issuer = URI.create("https://idp.example.com"); - var keyStore = mock(KeyStore.class); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var code = UUID.randomUUID().toString(); @@ -130,12 +130,12 @@ void redeem_twice() throws JOSEException { var issuer = URI.create("https://idp.example.com"); var k = genKey(); - var keyStore = mock(KeyStore.class); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); - when(keyStore.signingKey()).thenReturn(k); + when(signingKeyProvider.signingKey()).thenReturn(k); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var redirectUri = URI.create("https://myapp.example.com"); var clientId = "myapp"; @@ -168,11 +168,11 @@ void redeem_idToken() throws JOSEException, ParseException { var issuer = URI.create("https://idp.example.com"); var k = genKey(); - var keyStore = mock(KeyStore.class); - when(keyStore.signingKey()).thenReturn(k); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); + when(signingKeyProvider.signingKey()).thenReturn(k); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var id = UUID.randomUUID().toString(); @@ -217,11 +217,11 @@ void issueIdToken_gemSpecA23035() throws JOSEException, ParseException { var issuer = URI.create("https://idp.example.com"); var k = genKey(); - var keyStore = mock(KeyStore.class); - when(keyStore.signingKey()).thenReturn(k); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); + when(signingKeyProvider.signingKey()).thenReturn(k); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var id = UUID.randomUUID().toString(); @@ -282,11 +282,11 @@ void issueIdToken_mapsAllDefinedClaims() throws JOSEException, ParseException { var issuer = URI.create("https://idp.example.com"); var k = genKey(); - var keyStore = mock(KeyStore.class); - when(keyStore.signingKey()).thenReturn(k); + var signingKeyProvider = mock(TokenIssuerImpl.SigningKeyProvider.class); + when(signingKeyProvider.signingKey()).thenReturn(k); var codeRepo = mock(CodeRepo.class); - var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); + var sut = new TokenIssuerImpl(issuer, signingKeyProvider, codeRepo); var id = UUID.randomUUID().toString(); diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java index 839ba2d..038bd4f 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java @@ -31,8 +31,12 @@ public URI start() throws ExecutionException, InterruptedException { var config = StaticConfig.fromRawProperties( + // + // openid_provider_sig_jwks_path=src/test/resources/fixtures/example_sig_jwks.json """ federation_es_jwks_path=src/test/resources/fixtures/example_sig_jwks.json + openid_rp_sig_jwks_path=src/test/resources/fixtures/rp_sig_jwks.json + openid_rp_enc_jwks_path=src/test/resources/fixtures/rp_enc_jwks.json base_uri=%s idp_discovery_uri=%s redirect_uris=%s diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/PropertiesUtils.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/PropertiesUtils.java new file mode 100644 index 0000000..f26d711 --- /dev/null +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/PropertiesUtils.java @@ -0,0 +1,24 @@ +package com.oviva.ehealthid.relyingparty.test; + +import java.io.IOException; +import java.io.StringReader; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +public class PropertiesUtils { + + public static Map loadFromString(String raw) { + try { + var properties = new Properties(); + properties.load(new StringReader(raw)); + var map = new HashMap(); + for (String name : properties.stringPropertyNames()) { + map.put(name, properties.getProperty(name)); + } + return map; + } catch (IOException e) { + throw new IllegalStateException("failed to parse", e); + } + } +} diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/EnvironmentTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/EnvironmentTest.java new file mode 100644 index 0000000..91f9883 --- /dev/null +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/EnvironmentTest.java @@ -0,0 +1,62 @@ +package com.oviva.ehealthid.relyingparty.testenv; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class EnvironmentTest { + + @Test + void test_envVariable() { + + var authHeader = "mySpecialS3cr3t"; + var env = Map.of("GEMATIK_AUTH_HEADER", authHeader); + + Environment.getenv = env::get; + + var got = Environment.gematikAuthHeader(); + + assertEquals(authHeader, got); + } + + @Test + void test_envFile(@TempDir Path tempDir) throws IOException { + + var authHeader = "t0k3n"; + + var f = tempDir.resolve("env.properties"); + Files.write( + f, + """ + GEMATIK_AUTH_HEADER=%s + """ + .formatted(authHeader) + .getBytes(StandardCharsets.UTF_8)); + + Environment.getenv = s -> null; + Environment.envPath = () -> f; + + var got = Environment.gematikAuthHeader(); + + assertEquals(authHeader, got); + } + + @Test + void test_envFile_notFound(@TempDir Path tempDir) { + + var f = tempDir.resolve("does_not_exist"); + + Environment.getenv = s -> null; + Environment.envPath = () -> f; + + var got = Environment.gematikAuthHeader(); + + assertNull(got); + } +} diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/GematikHeaderDecoratorHttpClientTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/GematikHeaderDecoratorHttpClientTest.java new file mode 100644 index 0000000..9a50eb2 --- /dev/null +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/testenv/GematikHeaderDecoratorHttpClientTest.java @@ -0,0 +1,110 @@ +package com.oviva.ehealthid.relyingparty.testenv; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.oviva.ehealthid.fedclient.api.HttpClient; +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; + +class GematikHeaderDecoratorHttpClientTest { + + @Test + void noDecoration() { + + var env = Map.of("GEMATIK_AUTH_HEADER", "myheader"); + Environment.getenv = env::get; + + var httpClient = mock(HttpClient.class); + var client = new GematikHeaderDecoratorHttpClient(httpClient); + + var uri = URI.create("https://example.com"); + var req = new HttpClient.Request(uri, "GET", List.of(), null); + + // when + client.call(req); + + // then + var captor = ArgumentCaptor.forClass(HttpClient.Request.class); + verify(httpClient).call(captor.capture()); + + var decoratedReq = captor.getValue(); + + var hasAuthHeader = + decoratedReq.headers().stream().anyMatch(h -> "X-Authorization".equals(h.name())); + + assertFalse(hasAuthHeader); + } + + @Test + void worksIfHeaderMissing() { + + var env = Map.of("GEMATIK_AUTH_HEADER", ""); + Environment.getenv = env::get; + + var httpClient = mock(HttpClient.class); + var client = new GematikHeaderDecoratorHttpClient(httpClient); + + var uri = URI.create("https://gsi-ref-mtls.dev.gematik.solutions/PAR_Auth"); + var req = new HttpClient.Request(uri, "GET", List.of(), null); + + // when + client.call(req); + + // then + var captor = ArgumentCaptor.forClass(HttpClient.Request.class); + verify(httpClient).call(captor.capture()); + + var decoratedReq = captor.getValue(); + + var hasAuthHeader = + decoratedReq.headers().stream().anyMatch(h -> "X-Authorization".equals(h.name())); + + assertFalse(hasAuthHeader); + } + + @ParameterizedTest + @ValueSource( + strings = { + "https://gsi-ref.dev.gematik.solutions/.well-known/openid-federation", + "https://gsi-ref-mtls.dev.gematik.solutions/PAR_Auth", + "https://gsi.dev.gematik.solutions/.well-known/openid-federation", + }) + void decoratesRequest(String rawUri) { + + var authHeader = "mySpecialS3cr3t"; + var env = Map.of("GEMATIK_AUTH_HEADER", authHeader); + Environment.getenv = env::get; + + var mockRes = mock(HttpClient.Response.class); + var httpClient = mock(HttpClient.class); + when(httpClient.call(any())).thenReturn(mockRes); + var client = new GematikHeaderDecoratorHttpClient(httpClient); + + var uri = URI.create(rawUri); + var req = new HttpClient.Request(uri, "GET", List.of(), null); + + // when + var res = client.call(req); + + // then + var captor = ArgumentCaptor.forClass(HttpClient.Request.class); + verify(httpClient).call(captor.capture()); + + var decoratedReq = captor.getValue(); + + var header = + decoratedReq.headers().stream() + .filter(h -> "X-Authorization".equals(h.name())) + .findFirst() + .orElseThrow(); + + assertEquals(authHeader, header.value()); + assertEquals(mockRes, res); + } +} diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpointTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpointTest.java index c2fad88..3ed8ca4 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpointTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/OpenIdEndpointTest.java @@ -7,10 +7,10 @@ import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWKSet; import com.oviva.ehealthid.relyingparty.cfg.RelyingPartyConfig; -import com.oviva.ehealthid.relyingparty.svc.KeyStore; import java.net.URI; import java.text.ParseException; import java.util.List; +import java.util.function.Supplier; import org.junit.jupiter.api.Test; class OpenIdEndpointTest { @@ -45,8 +45,8 @@ void jwks() throws ParseException { """ {"kty":"EC","use":"sig","crv":"P-256","x":"yi3EF1QZS1EiAfAAfjoDyZkRnf59H49gUyklmfwKwSY","y":"Y_SGRGjwacDuT8kbcaX1Igyq8aRfJFNBMKLb2yr0x18"} """); - var keyStore = mock(KeyStore.class); - when(keyStore.signingKey()).thenReturn(key); + var keyStore = mock(Supplier.class); + when(keyStore.get()).thenReturn(key); var sut = new OpenIdEndpoint(BASE_URI, null, keyStore); diff --git a/ehealthid-rp/src/test/resources/fixtures/rp_enc_jwks.json b/ehealthid-rp/src/test/resources/fixtures/rp_enc_jwks.json new file mode 100644 index 0000000..22fc1bb --- /dev/null +++ b/ehealthid-rp/src/test/resources/fixtures/rp_enc_jwks.json @@ -0,0 +1,2 @@ +{"keys":[{"kty":"EC","d":"csGZ8A856-8pZEMthpNYjhIIkt7pnU6j7g0faJwBfQs","use":"enc","crv":"P-256","kid":"e5P2aBNG52HT-mUT1_YhVYE_FLvTiOl99R32XSKM0v8","x":"0x89ZshU6k-QdfXdTMd6rGvr_FdpUmHVDHSJFGrtU7M","y":"vDpWen2LCq12aTTb9IuT7oZ_vhkZbELTnfJwAQ9CH1Y"}]} + diff --git a/ehealthid-rp/src/test/resources/fixtures/rp_sig_jwks.json b/ehealthid-rp/src/test/resources/fixtures/rp_sig_jwks.json new file mode 100644 index 0000000..4135889 --- /dev/null +++ b/ehealthid-rp/src/test/resources/fixtures/rp_sig_jwks.json @@ -0,0 +1 @@ +{"keys":[{"kty":"EC","d":"BS05oPBczV3AWATDntZ2qlTdtXgMnJXCM2q3HLvVyNE","use":"sig","crv":"P-256","kid":"k6dh0ZJz_V6RMnvcMPO_joizvupNlczEposYRkXNnlc","x":"zgH2-4cLlz5NUZEcbWdVf1qxVL6QuwHythu7iXUU19w","y":"vZVEAIT9QwM57uNT811d14SqVUkoSjKynaqpTIMypPw"}]} diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/util/JwksUtils.java b/ehealthid/src/main/java/com/oviva/ehealthid/util/JwksUtils.java index 904ce94..8c74519 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/util/JwksUtils.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/util/JwksUtils.java @@ -16,7 +16,7 @@ public static JWKSet load(Path path) { return JWKSet.load(fin); } catch (IOException | ParseException e) { var fullPath = path.toAbsolutePath(); - throw new RuntimeException( + throw new IllegalStateException( "failed to load JWKS from '%s' ('%s')".formatted(path, fullPath), e); } }