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 getSingletons() { return Set.of( + new FederationEndpoint(federationConfig), new AuthEndpoint(config, sessionRepo, tokenIssuer, authenticationFlow), new OpenIdEndpoint(config, keyStore), new JacksonJsonProvider(configureObjectMapper())); @@ -46,6 +53,7 @@ public Set getSingletons() { private ObjectMapper configureObjectMapper() { var om = new ObjectMapper(); om.registerModule(new JoseModule()); + om.setSerializationInclusion(Include.NON_NULL); return om; } } diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpoint.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpoint.java index e5466c8..29686cf 100644 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpoint.java +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpoint.java @@ -31,7 +31,7 @@ import java.util.Base64; import java.util.List; -@Path("/") +@Path("/auth") public class AuthEndpoint { private final Config config; @@ -73,7 +73,6 @@ private static String generateCodeVerifier() { // Authorization Request // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 @GET - @Path("/auth") public Response auth( @QueryParam("scope") String scope, @QueryParam("state") String state, @@ -172,7 +171,7 @@ private NewCookie createSessionCookie(String sessionId) { } @GET - @Path("/auth/callback") + @Path("/callback") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response callback( @CookieParam("session_id") String sessionId, @QueryParam("code") String code) { diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProviderTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvFederationConfigProviderTest.java similarity index 95% rename from oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProviderTest.java rename to oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvFederationConfigProviderTest.java index 540a3bc..edb0d03 100644 --- a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvConfigProviderTest.java +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/EnvFederationConfigProviderTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; -class EnvConfigProviderTest { +class EnvFederationConfigProviderTest { private static final String PREFIX = "OIDC_SERVER"; diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpointTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpointTest.java new file mode 100644 index 0000000..9eb49ca --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpointTest.java @@ -0,0 +1,107 @@ +package com.oviva.gesundheitsid.relyingparty.fed; + +import static com.oviva.gesundheitsid.relyingparty.test.EntityStatementJwsContentMatcher.jwsPayloadAt; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.oviva.gesundheitsid.fedclient.api.EntityStatementJWS; +import jakarta.ws.rs.SeBootstrap; +import jakarta.ws.rs.SeBootstrap.Configuration; +import jakarta.ws.rs.core.Application; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class FederationEndpointTest { + + private static final URI ISSUER = URI.create("https://fachdienst.example.com"); + private static final URI FEDMASTER = URI.create("https://fedmaster.example.com"); + private static SeBootstrap.Instance server; + + @BeforeAll + static void setUp() throws ExecutionException, InterruptedException, JOSEException { + + var signatureKey = + new ECKeyGenerator(Curve.P_256) + .keyIDFromThumbprint(true) + .keyUse(KeyUse.SIGNATURE) + .generate(); + + var encryptionKey = + new ECKeyGenerator(Curve.P_256) + .keyIDFromThumbprint(true) + .keyUse(KeyUse.ENCRYPTION) + .generate(); + + var config = + FederationConfig.create() + .iss(ISSUER) + .sub(ISSUER) + .redirectUris(List.of(ISSUER + "/callback")) + .appName("My App") + .federationMaster(FEDMASTER) + .relyingPartyEncKeys(new JWKSet(encryptionKey)) + .entitySigningKeys(new JWKSet(signatureKey)) + .entitySigningKey(signatureKey) + .ttl(Duration.ofMinutes(5)) + .build(); + + server = + SeBootstrap.start( + new Application() { + @Override + public Set getSingletons() { + return Set.of(new FederationEndpoint(config)); + } + }, + Configuration.builder().host("127.0.0.1").port(0).build()) + .toCompletableFuture() + .get(); + } + + @AfterAll + static void tearDown() throws ExecutionException, InterruptedException, TimeoutException { + server.stop().toCompletableFuture().get(3, TimeUnit.SECONDS); + } + + @Test + void get_basic() { + given() + .baseUri(server.configuration().baseUri().toString()) + .get("/.well-known/openid-federation") + .then() + .statusCode(200) + .body(jwsPayloadAt("/iss", is(ISSUER.toString()))) + .body(jwsPayloadAt("/sub", is(ISSUER.toString()))); + } + + @Test + void get() { + + var res = + given() + .baseUri(server.configuration().baseUri().toString()) + .get("/.well-known/openid-federation") + .getBody(); + + var body = res.asString(); + + var es = EntityStatementJWS.parse(body); + assertEquals(ISSUER.toString(), es.body().sub()); + assertEquals(ISSUER.toString(), es.body().iss()); + assertEquals(FEDMASTER.toString(), es.body().authorityHints().get(0)); + } +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/test/EntityStatementJwsContentMatcher.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/test/EntityStatementJwsContentMatcher.java new file mode 100644 index 0000000..ed9a7b9 --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/test/EntityStatementJwsContentMatcher.java @@ -0,0 +1,82 @@ +package com.oviva.gesundheitsid.relyingparty.test; + +import com.fasterxml.jackson.core.JsonPointer; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.nimbusds.jose.JWSObject; +import java.io.IOException; +import java.text.ParseException; +import java.util.Optional; +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +public class EntityStatementJwsContentMatcher extends BaseMatcher { + + private final JsonPointer pointer; + private final Matcher matcher; + + private EntityStatementJwsContentMatcher(JsonPointer pointer, Matcher matcher) { + this.pointer = pointer; + this.matcher = matcher; + } + + public static EntityStatementJwsContentMatcher jwsPayloadAt( + String jsonPointer, Matcher matcher) { + return new EntityStatementJwsContentMatcher<>(JsonPointer.compile(jsonPointer), matcher); + } + + @Override + public boolean matches(Object actual) { + if (!(actual instanceof String s)) { + return false; + } + + return getValue(s).map(matcher::matches).orElse(false); + } + + private Optional getValue(String wireJws) { + + try { + var in = JWSObject.parse(wireJws); + + var om = new ObjectMapper(); + var tree = om.readTree(in.getPayload().toBytes()); + + var node = tree.at(pointer); + var value = om.treeToValue(node, new TypeReference() {}); + return Optional.of(value); + } catch (MismatchedInputException e) { + return Optional.empty(); + } catch (ParseException | IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void describeTo(Description description) { + description.appendText("JWS payload JSON pointer '%s' ".formatted(pointer)); + matcher.describeTo(description); + } + + @Override + public void describeMismatch(Object item, Description description) { + if (!(item instanceof String s)) { + description + .appendText("not of type ") + .appendValue(String.class.getName()) + .appendText(" but was ") + .appendValue(item.getClass().getName()); + return; + } + getValue(s) + .ifPresentOrElse( + actual -> { + matcher.describeMismatch(actual, description); + }, + () -> { + description.appendText("value not found at '%s'".formatted(pointer)); + }); + } +}