diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java
index 6173dd2..0768453 100644
--- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java
+++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java
@@ -2,7 +2,16 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSObject;
+import com.nimbusds.jose.Payload;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWKSet;
+import com.oviva.gesundheitsid.util.JsonCodec;
import java.time.Instant;
import java.util.List;
@@ -38,6 +47,25 @@ public static Builder create() {
return new Builder();
}
+ public JWSObject sign(ECKey key) {
+ try {
+ var signer = new ECDSASigner(key);
+
+ var h =
+ new JWSHeader.Builder(JWSAlgorithm.ES256)
+ .type(new JOSEObjectType(EntityStatementJWS.ENTITY_STATEMENT_TYP))
+ .keyID(key.getKeyID())
+ .build();
+
+ var jwsObject = new JWSObject(h, new Payload(JsonCodec.writeValueAsString(this)));
+ jwsObject.sign(signer);
+
+ return jwsObject;
+ } catch (JOSEException e) {
+ throw new IllegalArgumentException("failed to sign entity statement", e);
+ }
+ }
+
@JsonIgnoreProperties(ignoreUnknown = true)
public record OpenidProvider(
@JsonProperty("pushed_authorization_request_endpoint")
diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWS.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWS.java
index 0a2f06f..199d24c 100644
--- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWS.java
+++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWS.java
@@ -11,7 +11,7 @@
public record EntityStatementJWS(JWSObject jws, EntityStatement body) implements TemporalValid {
- private static final String ENTITY_STATEMENT_TYP = "entity-statement+jwt";
+ public static final String ENTITY_STATEMENT_TYP = "entity-statement+jwt";
public static EntityStatementJWS parse(String wire) {
try {
diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java
index 1cc387f..3adbced 100644
--- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java
+++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JsonCodec.java
@@ -1,5 +1,6 @@
package com.oviva.gesundheitsid.util;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -13,6 +14,7 @@ public class JsonCodec {
var om = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
om.registerModule(new JoseModule());
+ om.setSerializationInclusion(Include.NON_NULL);
JsonCodec.om = om;
}
diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementTest.java
new file mode 100644
index 0000000..ac0f043
--- /dev/null
+++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementTest.java
@@ -0,0 +1,24 @@
+package com.oviva.gesundheitsid.fedclient.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.oviva.gesundheitsid.test.ECKeyGenerator;
+import org.junit.jupiter.api.Test;
+
+class EntityStatementTest {
+
+ @Test
+ void sign_roundTrip() {
+
+ var key = ECKeyGenerator.example();
+
+ var sub = "hello world!";
+
+ // when
+ var jws = EntityStatement.create().sub(sub).build().sign(key);
+
+ // then
+ var got = EntityStatementJWS.parse(jws.serialize());
+ assertEquals(sub, got.body().sub());
+ }
+}
diff --git a/oidc-server/pom.xml b/oidc-server/pom.xml
index 82d8b67..054c154 100644
--- a/oidc-server/pom.xml
+++ b/oidc-server/pom.xml
@@ -135,6 +135,12 @@
+
+ io.rest-assured
+ rest-assured
+ test
+
+
org.jsoup
jsoup
diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java
index 09412ba..790a674 100644
--- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java
+++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/Main.java
@@ -10,6 +10,7 @@
import com.oviva.gesundheitsid.relyingparty.cfg.Config;
import com.oviva.gesundheitsid.relyingparty.cfg.ConfigProvider;
import com.oviva.gesundheitsid.relyingparty.cfg.EnvConfigProvider;
+import com.oviva.gesundheitsid.relyingparty.fed.FederationConfig;
import com.oviva.gesundheitsid.relyingparty.poc.GematikHeaderDecoratorHttpClient;
import com.oviva.gesundheitsid.relyingparty.svc.InMemoryCodeRepo;
import com.oviva.gesundheitsid.relyingparty.svc.InMemorySessionRepo;
@@ -81,12 +82,35 @@ public void run(ConfigProvider configProvider) throws ExecutionException, Interr
// configuration
// see: https://wiki.gematik.de/pages/viewpage.action?pageId=544316583
var fedmaster = URI.create("https://app-test.federationmaster.de");
+
+ // TODO replace with `baseUri`
var self = URI.create("https://idp-test.oviva.io/auth/realms/master/ehealthid");
- var authFlow = buildAuthFlow(self, fedmaster);
+
+ var authFlow = buildAuthFlow(baseUri, fedmaster);
+
+ // TODO make path configurable
+ var relyingPartyEncryptionJwks = JwksUtils.load(Path.of("./relying-party-enc_jwks.json"));
+ var relyingPartySigningJwks = JwksUtils.load(Path.of("./relying-party-sig_jwks.json"));
+
+ var federationConfig =
+ FederationConfig.create()
+ .sub(baseUri)
+ .iss(baseUri)
+ .appName("Oviva Direkt")
+ .federationMaster(fedmaster)
+ .entitySigningKey(relyingPartySigningJwks.getKeys().get(0).toECKey())
+ .entitySigningKeys(relyingPartySigningJwks.toPublicJWKSet())
+ .relyingPartyEncKeys(relyingPartyEncryptionJwks.toPublicJWKSet())
+
+ // TODO: bump up to hours, once we're confident it's correct ;)
+ // the spec says ~1 day
+ .ttl(Duration.ofMinutes(5))
+ .redirectUris(List.of(baseUri.resolve("/auth/callback").toString()))
+ .build();
var instance =
SeBootstrap.start(
- new App(config, sessionRepo, keyStore, tokenIssuer, authFlow),
+ new App(config, federationConfig, sessionRepo, keyStore, tokenIssuer, authFlow),
Configuration.builder().host("0.0.0.0").port(config.port()).build())
.toCompletableFuture()
.get();
@@ -100,12 +124,6 @@ public void run(ConfigProvider configProvider) throws ExecutionException, Interr
private AuthenticationFlow buildAuthFlow(URI selfIssuer, URI fedmaster) {
- // this URI must be listed in your entity statement, configure as needed
- var redirectUri = URI.create("http://localhost:8080");
-
- // those _MUST_ be at most the ones you requested when handing in the entity statement
- var scopes = List.of("openid", "urn:telematik:email", "urn:telematik:versicherter");
-
// path to the JWKS containing the private keys to decrypt ID tokens, the public part
// is in your entity configuration
var relyingPartyEncryptionJwks = JwksUtils.load(Path.of("./relying-party-enc_jwks.json"));
diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/Config.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/Config.java
deleted file mode 100644
index 3abe1e2..0000000
--- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/Config.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.oviva.gesundheitsid.relyingparty.fed;
-
-import com.nimbusds.jose.jwk.ECKey;
-import java.net.URI;
-import java.time.Duration;
-import java.util.List;
-
-public record Config(
- String iss,
- String sub,
- URI federationMaster,
- ECKey entitySigningKey,
- ECKey relyingPartyEncKey,
- Duration ttl,
- List redirectUris) {
-
- public static Builder create() {
- return new Builder();
- }
-
- public Builder toBuilder() {
- var b = new Builder();
- b.sub(sub);
- b.iss(iss);
- b.federationMaster(federationMaster);
- b.entitySigningKey(entitySigningKey);
- b.relyingPartyEncKey(relyingPartyEncKey);
- b.ttl(ttl);
- b.redirectUris(List.copyOf(redirectUris));
- return b;
- }
-
- public static final class Builder {
-
- private String iss;
- private String sub;
- private URI federationMaster;
- private ECKey entitySigningKey;
-
- private ECKey relyingPartyEncKey;
- private Duration ttl;
- private List redirectUris;
-
- public Builder() {}
-
- public Builder iss(String iss) {
- this.iss = iss;
- return this;
- }
-
- public Builder sub(String sub) {
- this.sub = sub;
- return this;
- }
-
- public Builder federationMaster(URI federationMaster) {
- this.federationMaster = federationMaster;
- return this;
- }
-
- public Builder entitySigningKey(ECKey keyPair) {
- this.entitySigningKey = keyPair;
- return this;
- }
-
- public Builder relyingPartyEncKey(ECKey keyPair) {
- this.relyingPartyEncKey = keyPair;
- return this;
- }
-
- public Builder ttl(Duration ttl) {
- this.ttl = ttl;
- return this;
- }
-
- public Builder redirectUris(List redirectUris) {
- this.redirectUris = redirectUris;
- return this;
- }
-
- public Config build() {
- return new Config(
- iss, sub, federationMaster, entitySigningKey, relyingPartyEncKey, ttl, redirectUris);
- }
- }
-}
diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationConfig.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationConfig.java
new file mode 100644
index 0000000..20f1b76
--- /dev/null
+++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationConfig.java
@@ -0,0 +1,100 @@
+package com.oviva.gesundheitsid.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;
+
+public record FederationConfig(
+ URI iss,
+ URI sub,
+ URI federationMaster,
+ JWKSet entitySigningKeys,
+
+ // the actual private key used for singing, _MUST_ be part of `entitySigningKeys`
+ ECKey entitySigningKey,
+ JWKSet relyingPartyEncKeys,
+ Duration ttl,
+ List redirectUris,
+ String appName) {
+
+ public static Builder create() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+
+ private URI iss;
+ private URI sub;
+ private URI federationMaster;
+
+ private ECKey entitySigningKey;
+ private JWKSet entitySigningKeys;
+
+ private JWKSet relyingPartyEncKeys;
+ private Duration ttl;
+ private List redirectUris;
+ private String appName;
+
+ public Builder() {}
+
+ public Builder iss(URI iss) {
+ this.iss = iss;
+ return this;
+ }
+
+ public Builder sub(URI sub) {
+ this.sub = sub;
+ return this;
+ }
+
+ public Builder federationMaster(URI federationMaster) {
+ this.federationMaster = 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 relyingPartyEncKeys(JWKSet jwks) {
+ this.relyingPartyEncKeys = jwks;
+ return this;
+ }
+
+ public Builder ttl(Duration ttl) {
+ this.ttl = ttl;
+ return this;
+ }
+
+ public Builder redirectUris(List redirectUris) {
+ this.redirectUris = redirectUris;
+ return this;
+ }
+
+ public Builder appName(String appName) {
+ this.appName = appName;
+ return this;
+ }
+
+ public FederationConfig build() {
+ return new FederationConfig(
+ iss,
+ sub,
+ federationMaster,
+ entitySigningKeys,
+ entitySigningKey,
+ relyingPartyEncKeys,
+ ttl,
+ redirectUris,
+ appName);
+ }
+ }
+}
diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpoint.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpoint.java
index d98eb06..2952626 100644
--- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpoint.java
+++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpoint.java
@@ -1,49 +1,52 @@
package com.oviva.gesundheitsid.relyingparty.fed;
-import com.nimbusds.jose.jwk.JWKSet;
import com.oviva.gesundheitsid.fedclient.api.EntityStatement;
import com.oviva.gesundheitsid.fedclient.api.EntityStatement.FederationEntity;
import com.oviva.gesundheitsid.fedclient.api.EntityStatement.Metadata;
import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenIdRelyingParty;
-import com.oviva.gesundheitsid.util.JsonCodec;
-import com.oviva.gesundheitsid.util.JwsUtils;
import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.Response;
import java.time.Instant;
import java.util.List;
+@Path("/")
public class FederationEndpoint {
- private static final String MEDIA_TYPE_ENTITY_STATEMENT = "application/entity-statement+jwt";
- private final Config config;
+ static final String MEDIA_TYPE_ENTITY_STATEMENT = "application/entity-statement+jwt";
+ private final FederationConfig federationConfig;
- public FederationEndpoint(Config config) {
- this.config = config;
+ public FederationEndpoint(FederationConfig federationConfig) {
+ this.federationConfig = federationConfig;
}
+ @Path("/.well-known/openid-federation")
@GET
@Produces(MEDIA_TYPE_ENTITY_STATEMENT)
public Response get() {
- var federationEntityJwks = new JWKSet(config.entitySigningKey().toPublicJWK());
- var relyingPartyJwks = new JWKSet(config.relyingPartyEncKey().toPublicJWK());
+ var federationEntityJwks = federationConfig.entitySigningKeys().toPublicJWKSet();
+ var relyingPartyJwks = federationConfig.relyingPartyEncKeys().toPublicJWKSet();
var now = Instant.now();
+ var exp = now.plus(federationConfig.ttl());
- var es =
+ var jws =
EntityStatement.create()
- .iss(config.iss())
- .sub(config.sub())
.iat(now)
- .exp(now.plus(config.ttl()))
- .authorityHints(List.of(config.federationMaster().toString()))
+ .nbf(now)
+ .exp(exp)
+ .iss(federationConfig.iss().toString())
+ .sub(federationConfig.sub().toString())
+ .authorityHints(List.of(federationConfig.federationMaster().toString()))
.metadata(
Metadata.create()
.openIdRelyingParty(
OpenIdRelyingParty.create()
+ .clientName(federationConfig.appName())
.jwks(relyingPartyJwks)
- .clientName("OvivaDirekt")
.responseTypes(List.of("code"))
.grantTypes(List.of("authorization_code"))
.requirePushedAuthorizationRequests(true)
@@ -56,14 +59,27 @@ public Response get() {
"openid",
"urn:telematik:email",
"urn:telematik:versicherter")) // add urn:telematik:display_name
- .redirectUris(config.redirectUris())
+ .redirectUris(federationConfig.redirectUris())
.build())
- .federationEntity(FederationEntity.create().name("OvivaDirekt").build())
+ .federationEntity(
+ FederationEntity.create().name(federationConfig.appName()).build())
.build())
.jwks(federationEntityJwks)
- .build();
+ .build()
+ .sign(federationConfig.entitySigningKey());
- var jws = JwsUtils.toJws(config.entitySigningKey(), JsonCodec.writeValueAsString(es));
- return Response.ok(jws.serialize()).header("x-kc-provider", "ovi").build();
+ return Response.ok(jws.serialize())
+ .header("x-kc-provider", "ovi")
+ .cacheControl(cacheForTtl(now))
+ .build();
+ }
+
+ private CacheControl cacheForTtl(Instant now) {
+
+ var cacheUntil = now.plusSeconds((long) (federationConfig.ttl().getSeconds() * 0.8));
+
+ var cc = new CacheControl();
+ cc.setMaxAge((int) cacheUntil.getEpochSecond());
+ return cc;
}
}
diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java
index dc767d9..ab290d3 100644
--- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java
+++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/App.java
@@ -1,9 +1,12 @@
package com.oviva.gesundheitsid.relyingparty.ws;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.jakarta.rs.json.JacksonJsonProvider;
import com.oviva.gesundheitsid.auth.AuthenticationFlow;
import com.oviva.gesundheitsid.relyingparty.cfg.Config;
+import com.oviva.gesundheitsid.relyingparty.fed.FederationConfig;
+import com.oviva.gesundheitsid.relyingparty.fed.FederationEndpoint;
import com.oviva.gesundheitsid.relyingparty.svc.KeyStore;
import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo;
import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer;
@@ -14,6 +17,7 @@
public class App extends Application {
private final Config config;
+ private final FederationConfig federationConfig;
private final SessionRepo sessionRepo;
private final KeyStore keyStore;
@@ -23,11 +27,13 @@ public class App extends Application {
public App(
Config config,
+ FederationConfig federationConfig,
SessionRepo sessionRepo,
KeyStore keyStore,
TokenIssuer tokenIssuer,
AuthenticationFlow authenticationFlow) {
this.config = config;
+ this.federationConfig = federationConfig;
this.sessionRepo = sessionRepo;
this.keyStore = keyStore;
this.tokenIssuer = tokenIssuer;
@@ -38,6 +44,7 @@ public App(
public Set