From 3dd6e77bd8cc1458f29618a610f4000329a64c7d Mon Sep 17 00:00:00 2001 From: Thomas Richner Date: Mon, 5 Feb 2024 17:02:24 +0100 Subject: [PATCH] ARC-1233: Basic working version with GesundheitsID --- .gitignore | 1 + README.md | 3 +- .../oviva/gesundheitsid/crypto/ECKeyPair.java | 6 - .../fedclient/api/EntityStatement.java | 28 ++ .../fedclient/api/EntityStatementJWS.java | 2 +- .../oviva/gesundheitsid/util/JsonCodec.java | 2 + .../oviva/gesundheitsid/util/JwksUtils.java | 23 ++ .../oviva/gesundheitsid/util}/JwsUtils.java | 9 +- .../auth/AuthenticationFlowExampleTest.java | 2 +- .../gesundheitsid/crypto/JwsVerifierTest.java | 51 +-- .../FederationMasterClientImplTest.java | 124 ++++--- .../api/EntityStatementBuilderTest.java | 11 +- .../fedclient/api/EntityStatementJWSTest.java | 2 +- .../fedclient/api/EntityStatementTest.java | 24 ++ .../gesundheitsid/test/ECKeyGenerator.java | 49 +++ .../test/ECKeyPairGenerator.java | 57 --- .../oviva/gesundheitsid/test/JwksUtils.java | 51 --- oidc-server/pom.xml | 6 + .../gesundheitsid/relyingparty/Main.java | 74 +++- .../relyingparty/fed/FederationConfig.java | 100 ++++++ .../relyingparty/fed/FederationEndpoint.java | 85 +++++ .../relyingparty/poc/Environment.java | 32 ++ .../poc/GematikHeaderDecoratorHttpClient.java | 33 ++ .../relyingparty/svc/InMemorySessionRepo.java | 8 +- .../relyingparty/svc/SessionRepo.java | 10 +- .../relyingparty/svc/TokenIssuer.java | 6 +- .../relyingparty/svc/TokenIssuerImpl.java | 27 +- .../gesundheitsid/relyingparty/ws/App.java | 22 +- .../relyingparty/ws/AuthEndpoint.java | 250 +++++++++++++ .../relyingparty/ws/OpenIdEndpoint.java | 185 +--------- ...a => EnvFederationConfigProviderTest.java} | 2 +- .../fed/FederationEndpointTest.java | 107 ++++++ .../svc/InMemoryCodeRepoTest.java | 4 +- .../svc/InMemorySessionRepoTest.java | 6 +- .../relyingparty/svc/TokenIssuerImplTest.java | 46 ++- .../EntityStatementJwsContentMatcher.java | 82 +++++ .../relyingparty/ws/AuthEndpointTest.java | 329 ++++++++++++++++++ .../relyingparty/ws/OpenIdEndpointTest.java | 293 +--------------- 38 files changed, 1436 insertions(+), 716 deletions(-) delete mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java create mode 100644 gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JwksUtils.java rename gesundheitsid/src/{test/java/com/oviva/gesundheitsid/test => main/java/com/oviva/gesundheitsid/util}/JwsUtils.java (81%) create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementTest.java create mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyGenerator.java delete mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java delete mode 100644 gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java create mode 100644 oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationConfig.java create mode 100644 oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpoint.java create mode 100644 oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/poc/Environment.java create mode 100644 oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/poc/GematikHeaderDecoratorHttpClient.java create mode 100644 oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpoint.java rename oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/cfg/{EnvConfigProviderTest.java => EnvFederationConfigProviderTest.java} (95%) create mode 100644 oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpointTest.java create mode 100644 oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/test/EntityStatementJwsContentMatcher.java create mode 100644 oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpointTest.java diff --git a/.gitignore b/.gitignore index 7fce934..ed4d3d0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ gesundheitsid/env.properties gesundheitsid/dependency-reduced-pom.xml .flattened-pom.xml *_jwks.json +env.properties diff --git a/README.md b/README.md index 7b39251..372f40b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 1. Setup your test environment, your own issuer **MUST** serve a **VALID** and **TRUSTED** entity statement. See [Gematik docs](https://wiki.gematik.de/pages/viewpage.action?pageId=544316583) -2. Setup the file `.env.properties` to provide +2. Setup the file `env.properties` to provide the [X-Authorization header](https://wiki.gematik.de/display/IDPKB/Fachdienste+Test-Umgebungen) for the Gematik 3. Setup the JWK sets for signing and encryption keys @@ -94,6 +94,7 @@ Setting up a proxy with a header filter can get around that limitation though. ## Helpful Links +- [Gematik Sectoral IDP Specifications v2.0.1](https://fachportal.gematik.de/fachportal-import/files/gemSpec_IDP_Sek_V2.0.1.pdf) - [AppFlow - Authentication flow to implement](https://wiki.gematik.de/display/IDPKB/App-App+Flow#AppAppFlow-0-FederationMaster) - [Sektoraler IDP - Examples & Reference Implementation](https://wiki.gematik.de/display/IDPKB/Sektoraler+IDP+-+Referenzimplementierung+und+Beispiele) - [OpenID Federation Spec](https://openid.net/specs/openid-federation-1_0.html) diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java deleted file mode 100644 index 650e0b3..0000000 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/crypto/ECKeyPair.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.oviva.gesundheitsid.crypto; - -import java.security.interfaces.ECPrivateKey; -import java.security.interfaces.ECPublicKey; - -public record ECKeyPair(ECPublicKey pub, ECPrivateKey priv) {} 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/main/java/com/oviva/gesundheitsid/util/JwksUtils.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JwksUtils.java new file mode 100644 index 0000000..27605a2 --- /dev/null +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JwksUtils.java @@ -0,0 +1,23 @@ +package com.oviva.gesundheitsid.util; + +import com.nimbusds.jose.jwk.JWKSet; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.ParseException; + +public class JwksUtils { + + private JwksUtils() {} + + public static JWKSet load(Path path) { + + try (var fin = Files.newInputStream(path)) { + return JWKSet.load(fin); + } catch (IOException | ParseException e) { + var fullPath = path.toAbsolutePath(); + throw new RuntimeException( + "failed to load JWKS from '%s' ('%s')".formatted(path, fullPath), e); + } + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwsUtils.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JwsUtils.java similarity index 81% rename from gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwsUtils.java rename to gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JwsUtils.java index e765980..1e0e940 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwsUtils.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/util/JwsUtils.java @@ -1,4 +1,4 @@ -package com.oviva.gesundheitsid.test; +package com.oviva.gesundheitsid.util; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -6,16 +6,15 @@ import com.nimbusds.jose.JWSObject; import com.nimbusds.jose.Payload; import com.nimbusds.jose.crypto.ECDSASigner; -import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.ECKey; public class JwsUtils { private JwsUtils() {} - public static JWSObject toJws(JWKSet jwks, String payload) { + public static JWSObject toJws(ECKey key, String payload) { try { - var key = jwks.getKeys().get(0); - var signer = new ECDSASigner(key.toECKey()); + var signer = new ECDSASigner(key); var h = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(key.getKeyID()).build(); diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java index 69c6c4e..5dc6b37 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/auth/AuthenticationFlowExampleTest.java @@ -14,7 +14,7 @@ import com.oviva.gesundheitsid.fedclient.api.UrlFormBodyBuilder; import com.oviva.gesundheitsid.test.Environment; import com.oviva.gesundheitsid.test.GematikHeaderDecoratorHttpClient; -import com.oviva.gesundheitsid.test.JwksUtils; +import com.oviva.gesundheitsid.util.JwksUtils; import java.io.IOException; import java.net.URI; import java.net.URLDecoder; diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java index 3eb6ed0..99bd0d8 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java @@ -1,26 +1,36 @@ package com.oviva.gesundheitsid.crypto; -import static com.oviva.gesundheitsid.test.JwksUtils.toJwks; -import static com.oviva.gesundheitsid.test.JwsUtils.*; -import static com.oviva.gesundheitsid.test.JwsUtils.garbageSignature; -import static com.oviva.gesundheitsid.test.JwsUtils.tamperSignature; +import static com.oviva.gesundheitsid.util.JwsUtils.*; +import static com.oviva.gesundheitsid.util.JwsUtils.garbageSignature; +import static com.oviva.gesundheitsid.util.JwsUtils.tamperSignature; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSObject; import com.nimbusds.jose.Payload; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWKSet; -import com.oviva.gesundheitsid.test.ECKeyPairGenerator; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import java.text.ParseException; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; class JwsVerifierTest { - private static ECKeyPair ECKEY = ECKeyPairGenerator.generate(); + private static ECKey ECKEY; + private static JWKSet JWKS; + + @BeforeAll + static void beforeAll() throws JOSEException { + ECKEY = new ECKeyGenerator(Curve.P_256).keyIDFromThumbprint(true).generate(); + JWKS = new JWKSet(ECKEY); + } @Test void verifyEmptyJwks() { @@ -38,36 +48,30 @@ void verifyNoJwks() { @Test void verify() throws ParseException { - var jwks = toJwks(ECKEY); - - var jws = toJws(jwks, "hello world?").serialize(); + var jws = toJws(ECKEY, "hello world?").serialize(); var in = JWSObject.parse(jws); - assertTrue(JwsVerifier.verify(jwks, in)); + assertTrue(JwsVerifier.verify(JWKS, in)); } @Test void verifyBadSignature() throws ParseException { - var jwks = toJwks(ECKEY); - - var jws = toJws(jwks, "test").serialize(); + var jws = toJws(ECKEY, "test").serialize(); jws = tamperSignature(jws); var in = JWSObject.parse(jws); // when & then - assertFalse(JwsVerifier.verify(jwks, in)); + assertFalse(JwsVerifier.verify(JWKS, in)); } @Test - void verifyUnknownKey() throws ParseException { - - var trustedJwks = toJwks(ECKEY); + void verifyUnknownKey() throws ParseException, JOSEException { - var signerJwks = toJwks(ECKeyPairGenerator.generate()); + var signerJwks = new ECKeyGenerator(Curve.P_256).generate(); var jws = toJws(signerJwks, "test").serialize(); @@ -76,32 +80,29 @@ void verifyUnknownKey() throws ParseException { var in = JWSObject.parse(jws); // when & then - assertFalse(JwsVerifier.verify(trustedJwks, in)); + assertFalse(JwsVerifier.verify(JWKS, in)); } @Test void verifyGarbageSignature() throws ParseException { - var jwks = toJwks(ECKEY); - var jws = toJws(jwks, "test").serialize(); + var jws = toJws(ECKEY, "test").serialize(); jws = garbageSignature(jws); var in = JWSObject.parse(jws); // when & then - assertFalse(JwsVerifier.verify(jwks, in)); + assertFalse(JwsVerifier.verify(JWKS, in)); } @Test void verify_badAlg() { - var jwks = toJwks(ECKEY); - var h = new JWSHeader(JWSAlgorithm.RS256); var in = new JWSObject(h, new Payload("hello?")); // when - var e = assertThrows(UnsupportedOperationException.class, () -> JwsVerifier.verify(jwks, in)); + var e = assertThrows(UnsupportedOperationException.class, () -> JwsVerifier.verify(JWKS, in)); // then assertEquals("only supports ES256, found: RS256", e.getMessage()); diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java index d3ab529..42e41cb 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java @@ -4,7 +4,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -import com.oviva.gesundheitsid.crypto.ECKeyPair; +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.oviva.gesundheitsid.fedclient.api.EntityStatement; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.FederationEntity; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.Metadata; @@ -13,10 +16,9 @@ import com.oviva.gesundheitsid.fedclient.api.IdpList; import com.oviva.gesundheitsid.fedclient.api.IdpList.IdpEntity; import com.oviva.gesundheitsid.fedclient.api.IdpListJWS; -import com.oviva.gesundheitsid.test.ECKeyPairGenerator; -import com.oviva.gesundheitsid.test.JwksUtils; -import com.oviva.gesundheitsid.test.JwsUtils; +import com.oviva.gesundheitsid.test.ECKeyGenerator; import com.oviva.gesundheitsid.util.JsonCodec; +import com.oviva.gesundheitsid.util.JwsUtils; import java.net.URI; import java.time.Clock; import java.time.Instant; @@ -86,7 +88,7 @@ void establishTrust_expiredFedmasterConfig() { var issuer = URI.create("https://idp-tk.example.com"); - var fedmasterKeypair = ECKeyPairGenerator.example(); + var fedmasterKeypair = ECKeyGenerator.example(); var fedmasterEntityConfigurationJws = expiredFedmasterConfiguration(fedmasterKeypair); @@ -109,8 +111,8 @@ void establishTrust_badFedmasterConfigSignature() { var issuer = URI.create("https://idp-tk.example.com"); - var fedmasterKeypair = ECKeyPairGenerator.example(); - var unrelatedKeypair = ECKeyPairGenerator.generate(); + var fedmasterKeypair = ECKeyGenerator.example(); + var unrelatedKeypair = ECKeyGenerator.generate(); var fedmasterEntityConfigurationJws = badSignatureFedmasterConfiguration(fedmasterKeypair, unrelatedKeypair); @@ -134,16 +136,16 @@ void establishTrust_configurationWithUnknownSignature() { var issuer = URI.create("https://idp-tk.example.com"); var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); - var fedmasterKeypair = ECKeyPairGenerator.example(); + var fedmasterKeypair = ECKeyGenerator.example(); var fedmasterEntityConfigurationJws = federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); - var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedSectoralIdpKeypair = ECKeyGenerator.generate(); var trustedFederationStatement = trustedFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); - var untrustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var untrustedSectoralIdpKeypair = ECKeyGenerator.generate(); var sectoralEntityConfiguration = sectoralIdpEntityConfiguration(issuer, untrustedSectoralIdpKeypair); @@ -165,23 +167,25 @@ void establishTrust_configurationWithUnknownSignature() { } @Test - void establishTrust_configurationWithBadJwks() { + void establishTrust_configurationWithBadJwks() throws JOSEException { var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); var issuer = URI.create("https://idp-tk.example.com"); var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); - var fedmasterKeypair = ECKeyPairGenerator.example(); + var fedmasterKeypair = ECKeyGenerator.example(); var fedmasterEntityConfigurationJws = federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); - var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedSectoralIdpKeypair = + new com.nimbusds.jose.jwk.gen.ECKeyGenerator(Curve.P_256).generate(); var trustedFederationStatement = trustedFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); - var untrustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var untrustedSectoralIdpKeypair = + new com.nimbusds.jose.jwk.gen.ECKeyGenerator(Curve.P_256).generate(); var sectoralEntityConfiguration = badSignedSectoralIdpEntityConfiguration( issuer, trustedSectoralIdpKeypair, untrustedSectoralIdpKeypair); @@ -200,8 +204,7 @@ void establishTrust_configurationWithBadJwks() { var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); // then - assertEquals( - "entity statement of 'https://idp-tk.example.com' has a bad signature", e.getMessage()); + assertEquals("federation statement untrusted: sub=https://idp-tk.example.com", e.getMessage()); } @Test @@ -212,12 +215,12 @@ void establishTrust_configurationExpired() { var issuer = URI.create("https://idp-tk.example.com"); var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); - var fedmasterKeypair = ECKeyPairGenerator.example(); + var fedmasterKeypair = ECKeyGenerator.example(); var fedmasterEntityConfigurationJws = federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); - var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedSectoralIdpKeypair = ECKeyGenerator.generate(); var trustedFederationStatement = trustedFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); @@ -251,12 +254,12 @@ void establishTrust_expiredFederationStatement() { var issuer = URI.create("https://idp-tk.example.com"); var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); - var fedmasterKeypair = ECKeyPairGenerator.example(); + var fedmasterKeypair = ECKeyGenerator.example(); var fedmasterEntityConfigurationJws = federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); - var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedSectoralIdpKeypair = ECKeyGenerator.generate(); var trustedFederationStatement = expiredFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); @@ -284,14 +287,14 @@ void establishTrust_badSignatureFederationStatement() { var issuer = URI.create("https://idp-tk.example.com"); var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); - var fedmasterKeypair = ECKeyPairGenerator.example(); + var fedmasterKeypair = ECKeyGenerator.example(); var fedmasterEntityConfigurationJws = federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); - var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedSectoralIdpKeypair = ECKeyGenerator.generate(); - var badKeypair = ECKeyPairGenerator.generate(); + var badKeypair = ECKeyGenerator.generate(); var trustedFederationStatement = trustedFederationStatement(issuer, trustedSectoralIdpKeypair, badKeypair); @@ -318,12 +321,12 @@ void establishTrust() { var issuer = URI.create("https://idp-tk.example.com"); var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); - var fedmasterKeypair = ECKeyPairGenerator.example(); + var fedmasterKeypair = ECKeyGenerator.example(); var fedmasterEntityConfigurationJws = federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); - var sectoralIdpKeypair = ECKeyPairGenerator.generate(); + var sectoralIdpKeypair = ECKeyGenerator.generate(); var trustedFederationStatement = trustedFederationStatement(issuer, sectoralIdpKeypair, fedmasterKeypair); var sectoralEntityConfiguration = sectoralIdpEntityConfiguration(issuer, sectoralIdpKeypair); @@ -346,9 +349,9 @@ void establishTrust() { } private EntityStatementJWS badSignedSectoralIdpEntityConfiguration( - URI sub, ECKeyPair sectoralIdpKeyPair, ECKeyPair actualJwksKeys) { + URI sub, ECKey sectoralIdpKeyPair, ECKey actualJwksKeys) { - var publicJwks = JwksUtils.toPublicJwks(actualJwksKeys); + var publicJwks = toPublicJwks(actualJwksKeys); var body = EntityStatement.create() @@ -358,15 +361,14 @@ private EntityStatementJWS badSignedSectoralIdpEntityConfiguration( .jwks(publicJwks) .build(); - var signed = - JwsUtils.toJws(JwksUtils.toJwks(sectoralIdpKeyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(sectoralIdpKeyPair, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } - private EntityStatementJWS expiredIdpEntityConfiguration(URI sub, ECKeyPair sectoralIdpKeyPair) { + private EntityStatementJWS expiredIdpEntityConfiguration(URI sub, ECKey sectoralIdpKeyPair) { - var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + var publicJwks = toPublicJwks(sectoralIdpKeyPair); var body = EntityStatement.create() @@ -376,15 +378,14 @@ private EntityStatementJWS expiredIdpEntityConfiguration(URI sub, ECKeyPair sect .jwks(publicJwks) .build(); - var signed = - JwsUtils.toJws(JwksUtils.toJwks(sectoralIdpKeyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(sectoralIdpKeyPair, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } - private EntityStatementJWS sectoralIdpEntityConfiguration(URI sub, ECKeyPair sectoralIdpKeyPair) { + private EntityStatementJWS sectoralIdpEntityConfiguration(URI sub, ECKey sectoralIdpKeyPair) { - var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + var publicJwks = toPublicJwks(sectoralIdpKeyPair); var body = EntityStatement.create() @@ -394,16 +395,15 @@ private EntityStatementJWS sectoralIdpEntityConfiguration(URI sub, ECKeyPair sec .jwks(publicJwks) .build(); - var signed = - JwsUtils.toJws(JwksUtils.toJwks(sectoralIdpKeyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(sectoralIdpKeyPair, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } private EntityStatementJWS expiredFederationStatement( - URI sub, ECKeyPair sectoralIdpKeyPair, ECKeyPair fedmasterKeyPair) { + URI sub, ECKey sectoralIdpKeyPair, ECKey fedmasterKeyPair) { - var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + var publicJwks = toPublicJwks(sectoralIdpKeyPair); var body = EntityStatement.create() @@ -413,16 +413,15 @@ private EntityStatementJWS expiredFederationStatement( .jwks(publicJwks) .build(); - var signed = - JwsUtils.toJws(JwksUtils.toJwks(fedmasterKeyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(fedmasterKeyPair, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } private EntityStatementJWS trustedFederationStatement( - URI sub, ECKeyPair sectoralIdpKeyPair, ECKeyPair fedmasterKeyPair) { + URI sub, ECKey sectoralIdpKeyPair, ECKey fedmasterKeyPair) { - var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + var publicJwks = toPublicJwks(sectoralIdpKeyPair); var body = EntityStatement.create() @@ -432,16 +431,14 @@ private EntityStatementJWS trustedFederationStatement( .jwks(publicJwks) .build(); - var signed = - JwsUtils.toJws(JwksUtils.toJwks(fedmasterKeyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(fedmasterKeyPair, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } - private EntityStatementJWS federationFetchFedmasterConfiguration( - URI fetchUrl, ECKeyPair keyPair) { + private EntityStatementJWS federationFetchFedmasterConfiguration(URI fetchUrl, ECKey key) { - var publicJwks = JwksUtils.toPublicJwks(keyPair); + var publicJwks = toPublicJwks(key); var body = EntityStatement.create() @@ -458,14 +455,14 @@ private EntityStatementJWS federationFetchFedmasterConfiguration( .build()) .build(); - var signed = JwsUtils.toJws(JwksUtils.toJwks(keyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(key, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } - private EntityStatementJWS expiredFedmasterConfiguration(ECKeyPair keyPair) { + private EntityStatementJWS expiredFedmasterConfiguration(ECKey key) { - var publicJwks = JwksUtils.toPublicJwks(keyPair); + var publicJwks = toPublicJwks(key); var body = EntityStatement.create() @@ -475,15 +472,14 @@ private EntityStatementJWS expiredFedmasterConfiguration(ECKeyPair keyPair) { .jwks(publicJwks) .build(); - var signed = JwsUtils.toJws(JwksUtils.toJwks(keyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(key, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } - private EntityStatementJWS badSignatureFedmasterConfiguration( - ECKeyPair keyPair, ECKeyPair unrelatedKeyPair) { + private EntityStatementJWS badSignatureFedmasterConfiguration(ECKey key, ECKey unrelatedKey) { - var publicJwks = JwksUtils.toPublicJwks(keyPair); + var publicJwks = toPublicJwks(key); var body = EntityStatement.create() @@ -493,9 +489,23 @@ private EntityStatementJWS badSignatureFedmasterConfiguration( .jwks(publicJwks) .build(); - var signed = - JwsUtils.toJws(JwksUtils.toJwks(unrelatedKeyPair), JsonCodec.writeValueAsString(body)); + var signed = JwsUtils.toJws(unrelatedKey, JsonCodec.writeValueAsString(body)); return new EntityStatementJWS(signed, body); } + + public static JWKSet toPublicJwks(ECKey key) { + try { + + if (key.getKeyID() == null || key.getKeyID().isBlank()) { + key = new ECKey.Builder(key).keyIDFromThumbprint().build(); + } + + var pub = key.toPublicJWK(); + + return new JWKSet(pub); + } catch (JOSEException e) { + throw new IllegalArgumentException("bad key", e); + } + } } diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java index eaf3c5f..7b1c7eb 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementBuilderTest.java @@ -2,14 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -import com.nimbusds.jose.jwk.Curve; -import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWKSet; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.FederationEntity; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.Metadata; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenIdRelyingParty; import com.oviva.gesundheitsid.fedclient.api.EntityStatement.OpenidProvider; -import com.oviva.gesundheitsid.test.ECKeyPairGenerator; +import com.oviva.gesundheitsid.test.ECKeyGenerator; import com.oviva.gesundheitsid.util.JsonCodec; import java.time.Instant; import java.util.List; @@ -24,8 +22,7 @@ void build() { var now = Instant.parse("2023-01-04T00:14:00.000Z"); - var key = ECKeyPairGenerator.example(); - var jwk = new ECKey.Builder(Curve.P_256, key.pub()).build(); + var jwk = ECKeyGenerator.example().toPublicJWK(); var es = EntityStatement.create() @@ -35,7 +32,7 @@ void build() { .iat(now.minusSeconds(100)) .nbf(now.minusSeconds(1)) .authorityHints(List.of(fedmaster)) - .jwks(new JWKSet(List.of(jwk))) + .jwks(new JWKSet(jwk)) .metadata( Metadata.create() .federationEntity( @@ -78,7 +75,7 @@ void build() { assertEquals( """ - {"iss":"https://aok-testfalen.example.com","sub":"https://aok-testfalen.example.com","iat":1672791140,"exp":1672791840,"nbf":1672791239,"jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]},"authority_hints":["https://fedmaster.example.com"],"metadata":{"openid_provider":{"pushed_authorization_request_endpoint":"https://aok-testfalen.example.com/par","issuer":"https://aok-testfalen.example.com","require_pushed_authorization_requests":true,"token_endpoint":"https://aok-testfalen.example.com/token","authorization_endpoint":"https://aok-testfalen.example.com/auth","scopes_supported":["openid","email"],"grant_types_supported":["direct","code"],"user_type_supported":["IP"]},"openid_relying_party":{"organization_name":"Example Inc.","client_name":"https://aok-testfalen.example.com","redirect_uris":["https://aok-testfalen.example.com/callback","http://localhost:8080/test"],"response_types":["code","token"],"client_registration_types":["magic"],"grant_types":["direct"],"require_pushed_authorization_requests":true,"scope":"openid closedid","id_token_signed_response_alg":"P256","id_token_encrypted_response_alg":"ecdh-es","id_token_encrypted_response_enc":"AES123","jwks":{"keys":[{"kty":"EC","crv":"P-256","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]}},"federation_entity":{"name":"Example Inc.","contacts":"alice@example.com","homepage_uri":"https://dev.example.com","federation_fetch_endpoint":null,"federation_list_endpoint":null,"idp_list_endpoint":null}}}""", + {"iss":"https://aok-testfalen.example.com","sub":"https://aok-testfalen.example.com","iat":1672791140,"exp":1672791840,"nbf":1672791239,"jwks":{"keys":[{"kty":"EC","crv":"P-256","kid":"1272H4X20HLaS_zwI8c7ho5REoZP1uBUvcfm7bJ3jpQ","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]},"authority_hints":["https://fedmaster.example.com"],"metadata":{"openid_provider":{"pushed_authorization_request_endpoint":"https://aok-testfalen.example.com/par","issuer":"https://aok-testfalen.example.com","require_pushed_authorization_requests":true,"token_endpoint":"https://aok-testfalen.example.com/token","authorization_endpoint":"https://aok-testfalen.example.com/auth","scopes_supported":["openid","email"],"grant_types_supported":["direct","code"],"user_type_supported":["IP"]},"openid_relying_party":{"organization_name":"Example Inc.","client_name":"https://aok-testfalen.example.com","redirect_uris":["https://aok-testfalen.example.com/callback","http://localhost:8080/test"],"response_types":["code","token"],"client_registration_types":["magic"],"grant_types":["direct"],"require_pushed_authorization_requests":true,"scope":"openid closedid","id_token_signed_response_alg":"P256","id_token_encrypted_response_alg":"ecdh-es","id_token_encrypted_response_enc":"AES123","jwks":{"keys":[{"kty":"EC","crv":"P-256","kid":"1272H4X20HLaS_zwI8c7ho5REoZP1uBUvcfm7bJ3jpQ","x":"dM9WxN8ihxfAUq9aqrhAdPxoGDYt1Lk7eNK09vsU414","y":"Qusto6wrCXlSpJ9NwOGwx2TEpXZp_rCho7InBqYyZTA"}]}},"federation_entity":{"name":"Example Inc.","contacts":"alice@example.com","homepage_uri":"https://dev.example.com"}}}""", JsonCodec.writeValueAsString(es)); } } diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWSTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWSTest.java index ec69487..f10108d 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWSTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/api/EntityStatementJWSTest.java @@ -1,7 +1,7 @@ package com.oviva.gesundheitsid.fedclient.api; import static com.oviva.gesundheitsid.test.B64Utils.toB64; -import static com.oviva.gesundheitsid.test.JwsUtils.tamperSignature; +import static com.oviva.gesundheitsid.util.JwsUtils.tamperSignature; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; 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/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyGenerator.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyGenerator.java new file mode 100644 index 0000000..97217fe --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyGenerator.java @@ -0,0 +1,49 @@ +package com.oviva.gesundheitsid.test; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import java.security.SecureRandom; +import java.util.Random; + +public class ECKeyGenerator { + + private static final ECKey EXAMPLE = generateExample(); + + private ECKeyGenerator() {} + + public static ECKey generate() { + return generateP256(null); + } + + public static ECKey example() { + return EXAMPLE; + } + + private static ECKey generateExample() { + var notVerySecureRandom = new NotSoSecureRandom(); + return generateP256(notVerySecureRandom); + } + + private static ECKey generateP256(SecureRandom sr) { + try { + return new com.nimbusds.jose.jwk.gen.ECKeyGenerator(Curve.P_256) + .secureRandom(sr) + .keyIDFromThumbprint(true) + .generate(); + + } catch (JOSEException e) { + throw new RuntimeException("failed to generate key", e); + } + } + + private static class NotSoSecureRandom extends SecureRandom { + + private final Random prng = new Random(1337L); + + @Override + public void nextBytes(byte[] bytes) { + prng.nextBytes(bytes); + } + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java deleted file mode 100644 index bb9a1c4..0000000 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/ECKeyPairGenerator.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.oviva.gesundheitsid.test; - -import static org.mockito.Mockito.spy; - -import com.oviva.gesundheitsid.crypto.ECKeyPair; -import java.security.InvalidAlgorithmParameterException; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.security.interfaces.ECPrivateKey; -import java.security.interfaces.ECPublicKey; -import java.security.spec.ECGenParameterSpec; -import java.util.Random; - -public class ECKeyPairGenerator { - - private static ECKeyPair EXAMPLE = generateExample(); - - private ECKeyPairGenerator() {} - - public static ECKeyPair generate() { - return generateP256(null); - } - - public static ECKeyPair example() { - return EXAMPLE; - } - - private static ECKeyPair generateExample() { - var notVerySecureRandom = spy(new NotSoSecureRandom()); - return generateP256(notVerySecureRandom); - } - - private static ECKeyPair generateP256(SecureRandom sr) { - try { - var ecGenSpec = new ECGenParameterSpec("secp256r1"); - var keyPairGenerator = KeyPairGenerator.getInstance("EC"); - keyPairGenerator.initialize(ecGenSpec, sr); - var pair = keyPairGenerator.generateKeyPair(); - - return new ECKeyPair((ECPublicKey) pair.getPublic(), (ECPrivateKey) pair.getPrivate()); - - } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { - throw new RuntimeException(e); - } - } - - private static class NotSoSecureRandom extends SecureRandom { - - private final Random prng = new Random(1337L); - - @Override - public void nextBytes(byte[] bytes) { - prng.nextBytes(bytes); - } - } -} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java deleted file mode 100644 index 2140dd6..0000000 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.oviva.gesundheitsid.test; - -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.oviva.gesundheitsid.crypto.ECKeyPair; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.text.ParseException; -import java.util.List; - -public class JwksUtils { - - private JwksUtils() {} - - public static JWKSet load(Path path) { - - try (var fin = Files.newInputStream(path)) { - return JWKSet.load(fin); - } catch (IOException | ParseException e) { - throw new RuntimeException("failed to load JWKS from '%s'".formatted(path), e); - } - } - - public static JWKSet toJwks(ECKeyPair pair) { - - try { - var jwk = - new ECKey.Builder(Curve.P_256, pair.pub()) - .privateKey(pair.priv()) - .keyIDFromThumbprint() - .build(); - - return new JWKSet(List.of(jwk)); - } catch (JOSEException e) { - throw new IllegalArgumentException("bad key", e); - } - } - - public static JWKSet toPublicJwks(ECKeyPair pair) { - try { - var jwk = new ECKey.Builder(Curve.P_256, pair.pub()).keyIDFromThumbprint().build(); - - return new JWKSet(List.of(jwk)); - } catch (JOSEException e) { - throw new IllegalArgumentException("bad key", e); - } - } -} 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 112bc14..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 @@ -1,16 +1,29 @@ package com.oviva.gesundheitsid.relyingparty; +import com.oviva.gesundheitsid.auth.AuthenticationFlow; +import com.oviva.gesundheitsid.fedclient.FederationMasterClientImpl; +import com.oviva.gesundheitsid.fedclient.api.CachedFederationApiClient; +import com.oviva.gesundheitsid.fedclient.api.FederationApiClientImpl; +import com.oviva.gesundheitsid.fedclient.api.InMemoryCacheImpl; +import com.oviva.gesundheitsid.fedclient.api.JavaHttpClient; +import com.oviva.gesundheitsid.fedclient.api.OpenIdClient; import com.oviva.gesundheitsid.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; import com.oviva.gesundheitsid.relyingparty.svc.KeyStore; import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuerImpl; import com.oviva.gesundheitsid.relyingparty.ws.App; +import com.oviva.gesundheitsid.util.JwksUtils; import jakarta.ws.rs.SeBootstrap; import jakarta.ws.rs.SeBootstrap.Configuration; import java.net.URI; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; import java.util.List; import java.util.concurrent.ExecutionException; import org.slf4j.Logger; @@ -65,9 +78,39 @@ public void run(ConfigProvider configProvider) throws ExecutionException, Interr var tokenIssuer = new TokenIssuerImpl(config.baseUri(), keyStore, new InMemoryCodeRepo()); var sessionRepo = new InMemorySessionRepo(); + // setup your environment, your own issuer MUST serve a _valid_ and _trusted_ entity + // configuration + // see: https://wiki.gematik.de/pages/viewpage.action?pageId=544316583 + var fedmaster = URI.create("https://app-test.federationmaster.de"); + + // TODO replace with `baseUri` + var self = URI.create("https://idp-test.oviva.io/auth/realms/master/ehealthid"); + + 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), + new App(config, federationConfig, sessionRepo, keyStore, tokenIssuer, authFlow), Configuration.builder().host("0.0.0.0").port(config.port()).build()) .toCompletableFuture() .get(); @@ -78,4 +121,33 @@ public void run(ConfigProvider configProvider) throws ExecutionException, Interr // wait forever Thread.currentThread().join(); } + + private AuthenticationFlow buildAuthFlow(URI selfIssuer, URI fedmaster) { + + // path to the JWKS containing the private keys to decrypt ID tokens, the public part + // is in your entity configuration + var relyingPartyEncryptionJwks = JwksUtils.load(Path.of("./relying-party-enc_jwks.json")); + + // setup the file `.env.properties` to provide the X-Authorization header for the Gematik + // test environment + // see: https://wiki.gematik.de/display/IDPKB/Fachdienste+Test-Umgebungen + var httpClient = new GematikHeaderDecoratorHttpClient(new JavaHttpClient()); + + // setup as needed + var clock = Clock.systemUTC(); + var ttl = Duration.ofMinutes(5); + + var federationApiClient = + new CachedFederationApiClient( + new FederationApiClientImpl(httpClient), + new InMemoryCacheImpl<>(clock, ttl), + new InMemoryCacheImpl<>(clock, ttl), + new InMemoryCacheImpl<>(clock, ttl)); + + var fedmasterClient = new FederationMasterClientImpl(fedmaster, federationApiClient, clock); + var openIdClient = new OpenIdClient(httpClient); + + return new AuthenticationFlow( + selfIssuer, fedmasterClient, openIdClient, relyingPartyEncryptionJwks::getKeyByKeyId); + } } 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..1ba146e --- /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 signing, _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 new file mode 100644 index 0000000..2952626 --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/fed/FederationEndpoint.java @@ -0,0 +1,85 @@ +package com.oviva.gesundheitsid.relyingparty.fed; + +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 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 { + + static final String MEDIA_TYPE_ENTITY_STATEMENT = "application/entity-statement+jwt"; + private final FederationConfig federationConfig; + + public FederationEndpoint(FederationConfig federationConfig) { + this.federationConfig = federationConfig; + } + + @Path("/.well-known/openid-federation") + @GET + @Produces(MEDIA_TYPE_ENTITY_STATEMENT) + public Response get() { + + var federationEntityJwks = federationConfig.entitySigningKeys().toPublicJWKSet(); + var relyingPartyJwks = federationConfig.relyingPartyEncKeys().toPublicJWKSet(); + + var now = Instant.now(); + var exp = now.plus(federationConfig.ttl()); + + var jws = + EntityStatement.create() + .iat(now) + .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) + .responseTypes(List.of("code")) + .grantTypes(List.of("authorization_code")) + .requirePushedAuthorizationRequests(true) + .idTokenSignedResponseAlg("ES256") + .idTokenEncryptedResponseAlg("ECDH-ES") + .idTokenEncryptedResponseEnc("A256GCM") + .scope( + String.join( + " ", + "openid", + "urn:telematik:email", + "urn:telematik:versicherter")) // add urn:telematik:display_name + .redirectUris(federationConfig.redirectUris()) + .build()) + .federationEntity( + FederationEntity.create().name(federationConfig.appName()).build()) + .build()) + .jwks(federationEntityJwks) + .build() + .sign(federationConfig.entitySigningKey()); + + 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/poc/Environment.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/poc/Environment.java new file mode 100644 index 0000000..6f4343d --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/poc/Environment.java @@ -0,0 +1,32 @@ +package com.oviva.gesundheitsid.relyingparty.poc; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Environment { + + private static Logger logger = LoggerFactory.getLogger(Environment.class); + + public static String gematikAuthHeader() { + + // for testing in TU + var name = "GEMATIK_AUTH_HEADER"; + var header = System.getenv(name); + if (header != null && !header.isBlank()) { + return header; + } + + var prop = new Properties(); + try (var br = Files.newBufferedReader(Path.of("./env.properties"))) { + prop.load(br); + return prop.getProperty(name); + } catch (IOException e) { + logger.atInfo().setCause(e).log("failed to load environment from properties"); + } + return null; + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/poc/GematikHeaderDecoratorHttpClient.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/poc/GematikHeaderDecoratorHttpClient.java new file mode 100644 index 0000000..4bd75ff --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/poc/GematikHeaderDecoratorHttpClient.java @@ -0,0 +1,33 @@ +package com.oviva.gesundheitsid.relyingparty.poc; + +import com.oviva.gesundheitsid.fedclient.api.HttpClient; +import java.util.ArrayList; + +public class GematikHeaderDecoratorHttpClient implements HttpClient { + + private static final String HOST_GEMATIK_IDP = "gsi.dev.gematik.solutions"; + private final HttpClient delegate; + + public GematikHeaderDecoratorHttpClient(HttpClient delegate) { + this.delegate = delegate; + } + + @Override + public Response call(Request req) { + + if (req.uri().getHost().equals(HOST_GEMATIK_IDP)) { + if (Environment.gematikAuthHeader() == null || Environment.gematikAuthHeader().isBlank()) { + throw new RuntimeException( + "missing 'GEMATIK_AUTH_HEADER' environment value against '%s'" + .formatted(HOST_GEMATIK_IDP)); + } + + var headers = new ArrayList<>(req.headers()); + headers.add(new Header("X-Authorization", Environment.gematikAuthHeader())); + + req = new Request(req.uri(), req.method(), headers, req.body()); + } + + return delegate.call(req); + } +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepo.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepo.java index 36eddc4..4df31c3 100644 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepo.java +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepo.java @@ -20,7 +20,13 @@ public String save(@NonNull Session session) { var id = IdGenerator.generateID(); session = new Session( - id, session.state(), session.nonce(), session.redirectUri(), session.clientId()); + id, + session.state(), + session.nonce(), + session.redirectUri(), + session.clientId(), + session.codeVerifier(), + session.trustedSectoralIdpStep()); repo.put(id, session); diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/SessionRepo.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/SessionRepo.java index d495f92..28da822 100644 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/SessionRepo.java +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/SessionRepo.java @@ -1,5 +1,6 @@ package com.oviva.gesundheitsid.relyingparty.svc; +import com.oviva.gesundheitsid.auth.steps.TrustedSectoralIdpStep; import edu.umd.cs.findbugs.annotations.NonNull; import java.net.URI; @@ -9,5 +10,12 @@ public interface SessionRepo { Session load(@NonNull String sessionId); - record Session(String id, String state, String nonce, URI redirectUri, String clientId) {} + record Session( + String id, + String state, + String nonce, + URI redirectUri, + String clientId, + String codeVerifier, + TrustedSectoralIdpStep trustedSectoralIdpStep) {} } diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java index 71979a0..d2110a7 100644 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuer.java @@ -1,5 +1,6 @@ package com.oviva.gesundheitsid.relyingparty.svc; +import com.oviva.gesundheitsid.auth.IdTokenJWS; import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo.Session; import edu.umd.cs.findbugs.annotations.NonNull; import java.net.URI; @@ -7,7 +8,7 @@ public interface TokenIssuer { - Code issueCode(Session session); + Code issueCode(Session session, IdTokenJWS federatedIdToken); Token redeem(@NonNull String code, String redirectUri, String clientId); @@ -17,7 +18,8 @@ record Code( Instant expiresAt, URI redirectUri, String nonce, - String clientId) {} + String clientId, + IdTokenJWS federatedIdToken) {} record Token(String accessToken, String idToken, long expiresInSeconds) {} } diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImpl.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImpl.java index 97eefd6..5a34497 100644 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImpl.java +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImpl.java @@ -6,6 +6,7 @@ import com.nimbusds.jose.crypto.ECDSASigner; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; +import com.oviva.gesundheitsid.auth.IdTokenJWS; import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo.Session; import com.oviva.gesundheitsid.relyingparty.util.IdGenerator; import edu.umd.cs.findbugs.annotations.NonNull; @@ -33,7 +34,7 @@ public TokenIssuerImpl(URI issuer, KeyStore keyStore, CodeRepo codeRepo) { } @Override - public Code issueCode(Session session) { + public Code issueCode(Session session, IdTokenJWS idTokenJWS) { var code = IdGenerator.generateID(); var value = new Code( @@ -42,7 +43,8 @@ public Code issueCode(Session session) { clock.instant().plus(TTL), session.redirectUri(), session.nonce(), - session.clientId()); + session.clientId(), + idTokenJWS); codeRepo.save(value); return value; } @@ -62,7 +64,7 @@ public Token redeem(@NonNull String code, String redirectUri, String clientId) { var accessTokenTtl = Duration.ofMinutes(5); return new Token( issueAccessToken(accessTokenTtl, redeemed.clientId()), - issueIdToken(redeemed.clientId(), redeemed.nonce()), + issueIdToken(redeemed.clientId(), redeemed.nonce(), redeemed.federatedIdToken()), accessTokenTtl.getSeconds()); } @@ -83,7 +85,7 @@ private boolean validateCode(Code code, String redirectUri, String clientId) { return code.clientId().equals(clientId); } - private String issueIdToken(String audience, String nonce) { + private String issueIdToken(String audience, String nonce, IdTokenJWS federatedIdToken) { try { var jwk = keyStore.signingKey(); var signer = new ECDSASigner(jwk); @@ -94,7 +96,7 @@ private String issueIdToken(String audience, String nonce) { new JWTClaimsSet.Builder() .issuer(issuer.toString()) .audience(audience) - .subject(UUID.randomUUID().toString()) + .subject(federatedIdToken.body().sub()) // propagate original `sub` .issueTime(Date.from(now)) .expirationTime(Date.from(now.plus(Duration.ofHours(8)))); @@ -102,6 +104,21 @@ private String issueIdToken(String audience, String nonce) { claimsBuilder.claim("nonce", nonce); } + // complete list of scopes and corresponding claims: + // https://fachportal.gematik.de/fachportal-import/files/gemSpec_IDP_Sek_V2.0.1.pdf + // Specification 4.2.4 - A_22989 - + claimsBuilder.claim("urn:telematik:claims:id", federatedIdToken.body().telematikKvnr()); + + // GesundheitsID specific claims according to Gematik reference IDP: + // + // "acr" : "gematik-ehealth-loa-high", + // "amr" : "TODO amr", + // "email" : null, + // "urn:telematik:claims:profession" : "1.2.276.0.76.4.49", + // "urn:telematik:claims:given_name" : null, + // "urn:telematik:claims:id" : "X110411675", + // "urn:telematik:claims:email" : "darius_michael@mail.boedefeld.de" + var claims = claimsBuilder.build(); var signedJWT = 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 a44f2a3..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,8 +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; @@ -13,29 +17,43 @@ public class App extends Application { private final Config config; + private final FederationConfig federationConfig; private final SessionRepo sessionRepo; private final KeyStore keyStore; private final TokenIssuer tokenIssuer; - public App(Config config, SessionRepo sessionRepo, KeyStore keyStore, TokenIssuer tokenIssuer) { + private final AuthenticationFlow authenticationFlow; + + 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; + this.authenticationFlow = authenticationFlow; } @Override public Set getSingletons() { return Set.of( - new OpenIdEndpoint(config, sessionRepo, tokenIssuer, keyStore), + new FederationEndpoint(federationConfig), + new AuthEndpoint(config, sessionRepo, tokenIssuer, authenticationFlow), + new OpenIdEndpoint(config, keyStore), new JacksonJsonProvider(configureObjectMapper())); } 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 new file mode 100644 index 0000000..29686cf --- /dev/null +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpoint.java @@ -0,0 +1,250 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.oviva.gesundheitsid.auth.AuthenticationFlow; +import com.oviva.gesundheitsid.relyingparty.cfg.Config; +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo; +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo.Session; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer; +import com.oviva.gesundheitsid.relyingparty.ws.OpenIdErrorResponses.ErrorCode; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.NewCookie.SameSite; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriBuilder; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.List; + +@Path("/auth") +public class AuthEndpoint { + + private final Config config; + + private final SessionRepo sessionRepo; + private final TokenIssuer tokenIssuer; + + private final AuthenticationFlow authenticationFlow; + + public AuthEndpoint( + Config config, + SessionRepo sessionRepo, + TokenIssuer tokenIssuer, + AuthenticationFlow authenticationFlow) { + this.config = config; + this.sessionRepo = sessionRepo; + this.tokenIssuer = tokenIssuer; + this.authenticationFlow = authenticationFlow; + } + + private static String calculateS256CodeChallenge(String codeVerifier) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private static String generateCodeVerifier() { + var rng = new SecureRandom(); + + var bytes = new byte[32]; + rng.nextBytes(bytes); + return Base64.getUrlEncoder().encodeToString(bytes); + } + + // Authorization Request + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + @GET + public Response auth( + @QueryParam("scope") String scope, + @QueryParam("state") String state, + @QueryParam("response_type") String responseType, + @QueryParam("client_id") String clientId, + @QueryParam("redirect_uri") String redirectUri, + @QueryParam("nonce") String nonce) { + + URI parsedRedirect = null; + try { + parsedRedirect = new URI(redirectUri); + } catch (URISyntaxException e) { + // TODO nice form + return Response.status(Status.BAD_REQUEST) + .entity("bad 'redirect_uri': %s".formatted(parsedRedirect)) + .build(); + } + + if (!"https".equals(parsedRedirect.getScheme())) { + // TODO nice form + return Response.status(Status.BAD_REQUEST) + .entity("not https 'redirect_uri': %s".formatted(parsedRedirect)) + .build(); + } + + if (!config.validRedirectUris().contains(parsedRedirect)) { + // TODO nice form + return Response.status(Status.BAD_REQUEST) + .entity("untrusted 'redirect_uri': %s".formatted(parsedRedirect)) + .build(); + } + + if (!"openid".equals(scope)) { + return OpenIdErrorResponses.redirectWithError( + parsedRedirect, + ErrorCode.INVALID_SCOPE, + state, + "scope '%s' not supported".formatted(scope)); + } + + if (!config.supportedResponseTypes().contains(responseType)) { + return OpenIdErrorResponses.redirectWithError( + parsedRedirect, + ErrorCode.UNSUPPORTED_RESPONSE_TYPE, + state, + "unsupported response type: '%s'".formatted(responseType)); + } + + var verifier = generateCodeVerifier(); // for PKCE + + // === federated flow starts + + // 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"); + + // these should come from the client in the real world + var codeChallenge = calculateS256CodeChallenge(verifier); + + // ==== 1) start a new flow + var relyingPartyCallback = config.baseUri().resolve("/auth/callback"); + + var step1 = + authenticationFlow.start( + new AuthenticationFlow.Session( + "test", "test", relyingPartyCallback, codeChallenge, scopes)); + + // ==== 2) get the list of available IDPs + var idps = step1.fetchIdpOptions(); + + // ==== 3) select and IDP + + // for now we hardcode the reference IDP from Gematik + var sektoralerIdpIss = "https://gsi.dev.gematik.solutions"; + + var step2 = step1.redirectToSectoralIdp(sektoralerIdpIss); + + var federatedLogin = step2.idpRedirectUri(); + + // store session + var session = new Session(null, state, nonce, parsedRedirect, clientId, verifier, step2); + var sessionId = sessionRepo.save(session); + + // TODO: trigger actual flow + return Response.seeOther(federatedLogin).cookie(createSessionCookie(sessionId)).build(); + } + + private NewCookie createSessionCookie(String sessionId) { + return new NewCookie.Builder("session_id") + .value(sessionId) + .secure(true) + .httpOnly(true) + .sameSite(SameSite.LAX) + .maxAge(-1) // session scoped + .path("/auth") + .build(); + } + + @GET + @Path("/callback") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response callback( + @CookieParam("session_id") String sessionId, @QueryParam("code") String code) { + + if (sessionId == null || sessionId.isBlank()) { + // TODO: nice UI + return Response.status(Status.BAD_REQUEST) + .entity("Session missing!") + .type(MediaType.TEXT_PLAIN_TYPE) + .build(); + } + + var session = sessionRepo.load(sessionId); + if (session == null) { + // TODO: nice UI + return Response.status(Status.BAD_REQUEST) + .entity("Session not found!") + .type(MediaType.TEXT_PLAIN_TYPE) + .build(); + } + + var idToken = + session.trustedSectoralIdpStep().exchangeSectoralIdpCode(code, session.codeVerifier()); + + var issued = tokenIssuer.issueCode(session, idToken); + + var redirectUri = + UriBuilder.fromUri(session.redirectUri()) + .queryParam("code", issued.code()) + .queryParam("state", session.state()) + .build(); + + return Response.seeOther(redirectUri).build(); + } + + // Access Token Request + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + @POST + @Path("/token") + @Produces(MediaType.APPLICATION_JSON) + public Response token( + @FormParam("code") String code, + @FormParam("grant_type") String grantType, + @FormParam("redirect_uri") String redirectUri, + @FormParam("client_id") String clientId) { + + if (!"authorization_code".equals(grantType)) { + return Response.status(Status.BAD_REQUEST).entity("bad 'grant_type': " + grantType).build(); + } + + var redeemed = tokenIssuer.redeem(code, redirectUri, clientId); + if (redeemed == null) { + return Response.status(Status.BAD_REQUEST).entity("invalid code").build(); + } + + var cacheControl = new CacheControl(); + cacheControl.setNoStore(true); + + return Response.ok( + new TokenResponse( + redeemed.accessToken(), + "Bearer", + null, + (int) redeemed.expiresInSeconds(), + redeemed.idToken())) + .cacheControl(cacheControl) + .build(); + } + + public record TokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("id_token") String idToken) {} +} diff --git a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpoint.java b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpoint.java index 8087ff6..b100bf9 100644 --- a/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpoint.java +++ b/oidc-server/src/main/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpoint.java @@ -1,30 +1,14 @@ package com.oviva.gesundheitsid.relyingparty.ws; -import com.fasterxml.jackson.annotation.JsonProperty; import com.nimbusds.jose.jwk.JWKSet; import com.oviva.gesundheitsid.relyingparty.cfg.Config; import com.oviva.gesundheitsid.relyingparty.svc.KeyStore; -import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo; -import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo.Session; -import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer; -import com.oviva.gesundheitsid.relyingparty.ws.OpenIdErrorResponses.ErrorCode; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; -import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.CacheControl; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.NewCookie; -import jakarta.ws.rs.core.NewCookie.SameSite; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; -import jakarta.ws.rs.core.UriBuilder; -import java.net.URI; -import java.net.URISyntaxException; import java.time.Duration; import java.util.List; @@ -32,17 +16,10 @@ public class OpenIdEndpoint { private final Config config; - - private final SessionRepo sessionRepo; - private final TokenIssuer tokenIssuer; - private final KeyStore keyStore; - public OpenIdEndpoint( - Config config, SessionRepo sessionRepo, TokenIssuer tokenIssuer, KeyStore keyStore) { + public OpenIdEndpoint(Config config, KeyStore keyStore) { this.config = config; - this.sessionRepo = sessionRepo; - this.tokenIssuer = tokenIssuer; this.keyStore = keyStore; } @@ -68,125 +45,6 @@ public Response openIdConfiguration() { return Response.ok(body).build(); } - // 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, - @QueryParam("response_type") String responseType, - @QueryParam("client_id") String clientId, - @QueryParam("redirect_uri") String redirectUri, - @QueryParam("nonce") String nonce) { - - URI parsedRedirect = null; - try { - parsedRedirect = new URI(redirectUri); - } catch (URISyntaxException e) { - // TODO nice form - return Response.status(Status.BAD_REQUEST) - .entity("bad 'redirect_uri': %s".formatted(parsedRedirect)) - .build(); - } - - if (!"https".equals(parsedRedirect.getScheme())) { - // TODO nice form - return Response.status(Status.BAD_REQUEST) - .entity("not https 'redirect_uri': %s".formatted(parsedRedirect)) - .build(); - } - - if (!config.validRedirectUris().contains(parsedRedirect)) { - // TODO nice form - return Response.status(Status.BAD_REQUEST) - .entity("untrusted 'redirect_uri': %s".formatted(parsedRedirect)) - .build(); - } - - if (!"openid".equals(scope)) { - return OpenIdErrorResponses.redirectWithError( - parsedRedirect, - ErrorCode.INVALID_SCOPE, - state, - "scope '%s' not supported".formatted(scope)); - } - - if (!config.supportedResponseTypes().contains(responseType)) { - return OpenIdErrorResponses.redirectWithError( - parsedRedirect, - ErrorCode.UNSUPPORTED_RESPONSE_TYPE, - state, - "unsupported response type: '%s'".formatted(responseType)); - } - - var session = new Session(null, state, nonce, parsedRedirect, clientId); - var sessionId = sessionRepo.save(session); - - // TODO: trigger actual flow - return Response.ok( - """ - - -
-
- -
-
- - - """, - MediaType.TEXT_HTML_TYPE) - .cookie(createSessionCookie(sessionId)) - .build(); - } - - private NewCookie createSessionCookie(String sessionId) { - return new NewCookie.Builder("session_id") - .value(sessionId) - .secure(true) - .httpOnly(true) - .sameSite(SameSite.LAX) - .maxAge(-1) // session scoped - .path("/auth") - .build(); - } - - @POST - @Path("/auth/callback") - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response callback(@CookieParam("session_id") String sessionId) { - - if (sessionId == null || sessionId.isBlank()) { - // TODO: nice UI - return Response.status(Status.BAD_REQUEST) - .entity("Session missing!") - .type(MediaType.TEXT_PLAIN_TYPE) - .build(); - } - - var session = sessionRepo.load(sessionId); - if (session == null) { - // TODO: nice UI - return Response.status(Status.BAD_REQUEST) - .entity("Session not found!") - .type(MediaType.TEXT_PLAIN_TYPE) - .build(); - } - - // TODO: verify login - - var issued = tokenIssuer.issueCode(session); - - var redirectUri = - UriBuilder.fromUri(session.redirectUri()) - .queryParam("code", issued.code()) - .queryParam("state", session.state()) - .build(); - - return Response.seeOther(redirectUri).build(); - } - @GET @Path("/jwks.json") @Produces(MediaType.APPLICATION_JSON) @@ -198,45 +56,4 @@ public Response jwks() { return Response.ok(new JWKSet(List.of(key))).cacheControl(cacheControl).build(); } - - // Access Token Request - // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 - @POST - @Path("/token") - @Produces(MediaType.APPLICATION_JSON) - public Response token( - @FormParam("code") String code, - @FormParam("grant_type") String grantType, - @FormParam("redirect_uri") String redirectUri, - @FormParam("client_id") String clientId) { - - if (!"authorization_code".equals(grantType)) { - return Response.status(Status.BAD_REQUEST).entity("bad 'grant_type': " + grantType).build(); - } - - var redeemed = tokenIssuer.redeem(code, redirectUri, clientId); - if (redeemed == null) { - return Response.status(Status.BAD_REQUEST).entity("invalid code").build(); - } - - var cacheControl = new CacheControl(); - cacheControl.setNoStore(true); - - return Response.ok( - new TokenResponse( - redeemed.accessToken(), - "Bearer", - null, - (int) redeemed.expiresInSeconds(), - redeemed.idToken())) - .cacheControl(cacheControl) - .build(); - } - - public record TokenResponse( - @JsonProperty("access_token") String accessToken, - @JsonProperty("token_type") String tokenType, - @JsonProperty("refresh_token") String refreshToken, - @JsonProperty("expires_in") int expiresIn, - @JsonProperty("id_token") String idToken) {} } 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/svc/InMemoryCodeRepoTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepoTest.java index 25aec0b..58c2521 100644 --- a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepoTest.java +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemoryCodeRepoTest.java @@ -21,7 +21,7 @@ void saveAndRemove() { var redirect = URI.create("https://example.com/callback"); var clientId = "app"; - var code = new Code(id, issuedAt, expiresAt, redirect, null, clientId); + var code = new Code(id, issuedAt, expiresAt, redirect, null, clientId, null); sut.save(code); @@ -45,7 +45,7 @@ void remove_twice() { var id = "4929"; - var code = new Code(id, null, null, null, null, null); + var code = new Code(id, null, null, null, null, null, null); sut.save(code); diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepoTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepoTest.java index d3f23b4..8bb4e0d 100644 --- a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepoTest.java +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/InMemorySessionRepoTest.java @@ -11,7 +11,7 @@ class InMemorySessionRepoTest { @Test void save() { var sut = new InMemorySessionRepo(); - var session = new SessionRepo.Session(null, null, null, null, null); + var session = new SessionRepo.Session(null, null, null, null, null, null, null); var id1 = sut.save(session); assertNotNull(id1); @@ -25,7 +25,7 @@ void save() { @Test void save_alreadySaved() { var sut = new InMemorySessionRepo(); - var session = new SessionRepo.Session("1", null, null, null, null); + var session = new SessionRepo.Session("1", null, null, null, null, null, null); assertThrows(IllegalStateException.class, () -> sut.save(session)); } @@ -40,7 +40,7 @@ void load() { var redirectUri = URI.create("https://example.com/callback"); var clientId = "app"; - var session = new SessionRepo.Session(null, state, nonce, redirectUri, clientId); + var session = new SessionRepo.Session(null, state, nonce, redirectUri, clientId, null, null); var id = sut.save(session); diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImplTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImplTest.java index 2279e17..2e9cc75 100644 --- a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImplTest.java +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/svc/TokenIssuerImplTest.java @@ -12,6 +12,8 @@ import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.oviva.gesundheitsid.auth.IdTokenJWS; +import com.oviva.gesundheitsid.auth.IdTokenJWS.IdToken; import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Code; import java.net.URI; import java.text.ParseException; @@ -30,11 +32,11 @@ void issueCode_unique() { var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); - var session = new SessionRepo.Session(null, null, null, null, null); + var session = new SessionRepo.Session(null, null, null, null, null, null, null); // when - var c1 = sut.issueCode(session); - var c2 = sut.issueCode(session); + var c1 = sut.issueCode(session, null); + var c2 = sut.issueCode(session, null); // then assertNotNull(c1); @@ -53,10 +55,10 @@ void issueCode_notExpired() { var sut = new TokenIssuerImpl(issuer, keyStore, codeRepo); - var session = new SessionRepo.Session(null, null, null, null, null); + var session = new SessionRepo.Session(null, null, null, null, null, null, null); // when - var c1 = sut.issueCode(session); + var c1 = sut.issueCode(session, null); // then var now = Instant.now(); @@ -76,10 +78,10 @@ void issueCode_propagatesValues() { var redirectUri = URI.create("https://myapp.example.com/callback"); var clientId = "myapp"; - var session = new SessionRepo.Session(null, null, nonce, redirectUri, clientId); + var session = new SessionRepo.Session(null, null, nonce, redirectUri, clientId, null, null); // when - var code = sut.issueCode(session); + var code = sut.issueCode(session, null); // then assertEquals(nonce, code.nonce()); @@ -97,10 +99,10 @@ void issueCode_saves() { var redirectUri = URI.create("https://myapp.example.com/callback"); var clientId = "myapp"; - var session = new SessionRepo.Session(null, null, nonce, redirectUri, clientId); + var session = new SessionRepo.Session(null, null, nonce, redirectUri, clientId, null, null); // when - var code = sut.issueCode(session); + var code = sut.issueCode(session, null); // then verify(codeRepo).save(code); @@ -137,8 +139,16 @@ void redeem_twice() throws JOSEException { var redirectUri = URI.create("https://myapp.example.com"); var clientId = "myapp"; + var federatedIdToken = + new IdTokenJWS( + null, + new IdToken( + null, "tobias", null, 0, 0, 0, null, null, null, null, null, null, null, null)); + var id = UUID.randomUUID().toString(); - var code = new Code(id, null, Instant.now().plusSeconds(10), redirectUri, null, clientId); + var code = + new Code( + id, null, Instant.now().plusSeconds(10), redirectUri, null, clientId, federatedIdToken); when(codeRepo.remove(id)).thenReturn(Optional.of(code), Optional.empty()); @@ -168,7 +178,21 @@ void redeem_idToken() throws JOSEException, ParseException { var redirectUri = URI.create("https://myapp.example.com/callback"); var clientId = "myapp"; - var code = new Code(id, null, Instant.now().plusSeconds(10), redirectUri, nonce, clientId); + var federatedIdToken = + new IdTokenJWS( + null, + new IdToken( + null, "tobias", null, 0, 0, 0, null, null, null, null, null, null, null, null)); + + var code = + new Code( + id, + null, + Instant.now().plusSeconds(10), + redirectUri, + nonce, + clientId, + federatedIdToken); when(codeRepo.remove(id)).thenReturn(Optional.of(code)); 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)); + }); + } +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpointTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpointTest.java new file mode 100644 index 0000000..ec8d323 --- /dev/null +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/AuthEndpointTest.java @@ -0,0 +1,329 @@ +package com.oviva.gesundheitsid.relyingparty.ws; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.oviva.gesundheitsid.auth.AuthenticationFlow; +import com.oviva.gesundheitsid.auth.steps.SelectSectoralIdpStep; +import com.oviva.gesundheitsid.auth.steps.TrustedSectoralIdpStep; +import com.oviva.gesundheitsid.relyingparty.cfg.Config; +import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Code; +import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Token; +import com.oviva.gesundheitsid.relyingparty.ws.AuthEndpoint.TokenResponse; +import jakarta.ws.rs.core.Response.Status; +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class AuthEndpointTest { + + private static final URI BASE_URI = URI.create("https://idp.example.com"); + private static final URI REDIRECT_URI = URI.create("https://myapp.example.com"); + + @Test + void auth_badScopes() { + var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); + + var sut = new AuthEndpoint(config, null, null, null); + + var scope = "openid email"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + + // when + try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { + // then + assertEquals(Status.SEE_OTHER.getStatusCode(), res.getStatus()); + var location = res.getHeaderString("location"); + assertEquals( + "https://myapp.example.com?error=invalid_scope&error_description=scope+%%27openid+email%%27+not+supported&state=%s" + .formatted(state), + location); + } + } + + @Test + void auth_malformedRedirect() { + var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); + + var sut = new AuthEndpoint(config, null, null, null); + + var scope = "openid email"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + var redirectUri = "httpyolo://yolo!"; + + // when + try (var res = sut.auth(scope, state, responseType, clientId, redirectUri, nonce)) { + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void auth_untrustedRedirect() { + var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); + + var sut = new AuthEndpoint(config, null, null, null); + + var scope = "openid email"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + var redirectUri = "https://bad.example.com/evil"; + + // when + try (var res = sut.auth(scope, state, responseType, clientId, redirectUri, nonce)) { + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + + // according to spec we _MUST NOT_ redirect if we don't trust the redirect + assertNull(res.getHeaderString("location")); + } + } + + @Test + void auth_badResponseType() { + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var sessionRepo = mock(SessionRepo.class); + var sut = new AuthEndpoint(config, sessionRepo, null, null); + + var scope = "openid"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "badtype"; + var clientId = "myapp"; + + var sessionId = UUID.randomUUID().toString(); + when(sessionRepo.save(any())).thenReturn(sessionId); + + // when + try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { + + // then + assertEquals(Status.SEE_OTHER.getStatusCode(), res.getStatus()); + var location = res.getHeaderString("location"); + assertEquals( + "https://myapp.example.com?error=unsupported_response_type&error_description=unsupported+response+type%3A+%27badtype%27&state=" + + state, + location); + } + } + + @Test + void auth_success() { + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var idpRedirectUrl = URI.create("https://federated-idp.example.com"); + + var trustedIdpStep = mock(TrustedSectoralIdpStep.class); + when(trustedIdpStep.idpRedirectUri()).thenReturn(idpRedirectUrl); + + var selectIdpStep = mock(SelectSectoralIdpStep.class); + when(selectIdpStep.fetchIdpOptions()).thenReturn(List.of()); + when(selectIdpStep.redirectToSectoralIdp(any())).thenReturn(trustedIdpStep); + + var authFlow = mock(AuthenticationFlow.class); + when(authFlow.start(any())).thenReturn(selectIdpStep); + + var sessionRepo = mock(SessionRepo.class); + var sut = new AuthEndpoint(config, sessionRepo, null, authFlow); + + var scope = "openid"; + var state = UUID.randomUUID().toString(); + var nonce = UUID.randomUUID().toString(); + var responseType = "code"; + var clientId = "myapp"; + + var sessionId = UUID.randomUUID().toString(); + when(sessionRepo.save(any())).thenReturn(sessionId); + + // when + try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { + + // then + assertEquals(Status.SEE_OTHER.getStatusCode(), res.getStatus()); + var sessionCookie = res.getCookies().get("session_id"); + assertEquals(sessionId, sessionCookie.getValue()); + + var location = res.getLocation(); + assertEquals(idpRedirectUrl, location); + } + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {" ", " \n\t"}) + void callback_noSessionId(String sessionId) { + + var config = new Config(0, null, null, null); + + var sut = new AuthEndpoint(config, null, null, null); + + // when + try (var res = sut.callback(sessionId, "")) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void callback_unknownSession() { + + var config = new Config(0, null, null, null); + + var sessionRepo = mock(SessionRepo.class); + + var sut = new AuthEndpoint(config, sessionRepo, null, null); + + var sessionId = UUID.randomUUID().toString(); + + when(sessionRepo.load(sessionId)).thenReturn(null); + + // when + try (var res = sut.callback(sessionId, null)) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void callback() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var sessionRepo = mock(SessionRepo.class); + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new AuthEndpoint(config, sessionRepo, tokenIssuer, null); + + var sessionId = UUID.randomUUID().toString(); + + var state = "mySuperDuperState"; + var nonce = "20e5ed8b-f96b-48de-ae73-4460bcfc35a1"; + var clientId = "myapp"; + + var trustedIdpStep = mock(TrustedSectoralIdpStep.class); + + var session = + new SessionRepo.Session( + sessionId, state, nonce, REDIRECT_URI, clientId, null, trustedIdpStep); + when(sessionRepo.load(sessionId)).thenReturn(session); + + var code = "6238e4504332468aa0c12e300787fded"; + + when(trustedIdpStep.exchangeSectoralIdpCode(code, null)).thenReturn(null); + + var issued = new Code(code, null, null, REDIRECT_URI, nonce, clientId, null); + when(tokenIssuer.issueCode(session, null)).thenReturn(issued); + + // when + try (var res = sut.callback(sessionId, null)) { + + // then + assertEquals( + "https://myapp.example.com?code=6238e4504332468aa0c12e300787fded&state=mySuperDuperState", + res.getHeaderString("location")); + } + } + + @Test + void token_badGrantType() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new AuthEndpoint(config, null, tokenIssuer, null); + + var clientId = "myapp"; + + var grantType = "yolo"; + + var code = "6238e4504332468aa0c12e300787fded"; + + when(tokenIssuer.redeem(code, null, null)).thenReturn(null); + + // when + try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void token_badCode() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new AuthEndpoint(config, null, tokenIssuer, null); + + var clientId = "myapp"; + + var grantType = "authorization_code"; + + var code = "6238e4504332468aa0c12e300787fded"; + + when(tokenIssuer.redeem(code, REDIRECT_URI.toString(), clientId)).thenReturn(null); + + // when + try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { + + // then + assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); + } + } + + @Test + void token() { + + var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); + + var tokenIssuer = mock(TokenIssuer.class); + + var sut = new AuthEndpoint(config, null, tokenIssuer, null); + + var clientId = "myapp"; + + var grantType = "authorization_code"; + + var idToken = UUID.randomUUID().toString(); + var accessToken = UUID.randomUUID().toString(); + var expiresIn = 3600; + + var code = "6238e4504332468aa0c12e300787fded"; + var token = new Token(accessToken, idToken, expiresIn); + when(tokenIssuer.redeem(code, REDIRECT_URI.toString(), clientId)).thenReturn(token); + + // when + try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { + + // then + assertEquals(Status.OK.getStatusCode(), res.getStatus()); + var got = res.readEntity(TokenResponse.class); + + assertEquals(idToken, got.idToken()); + assertEquals(accessToken, got.accessToken()); + assertEquals(expiresIn, got.expiresIn()); + } + } +} diff --git a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java index bfb8228..d9956ee 100644 --- a/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java +++ b/oidc-server/src/test/java/com/oviva/gesundheitsid/relyingparty/ws/OpenIdEndpointTest.java @@ -1,7 +1,6 @@ package com.oviva.gesundheitsid.relyingparty.ws; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -9,31 +8,20 @@ import com.nimbusds.jose.jwk.JWKSet; import com.oviva.gesundheitsid.relyingparty.cfg.Config; import com.oviva.gesundheitsid.relyingparty.svc.KeyStore; -import com.oviva.gesundheitsid.relyingparty.svc.SessionRepo; -import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer; -import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Code; -import com.oviva.gesundheitsid.relyingparty.svc.TokenIssuer.Token; -import com.oviva.gesundheitsid.relyingparty.ws.OpenIdEndpoint.TokenResponse; -import jakarta.ws.rs.core.Response.Status; import java.net.URI; import java.text.ParseException; import java.util.List; -import java.util.UUID; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullSource; -import org.junit.jupiter.params.provider.ValueSource; class OpenIdEndpointTest { private static final URI BASE_URI = URI.create("https://idp.example.com"); - private static final URI REDIRECT_URI = URI.create("https://myapp.example.com"); @Test void openIdConfiguration() { var config = new Config(0, BASE_URI, null, null); - var sut = new OpenIdEndpoint(config, null, null, null); + var sut = new OpenIdEndpoint(config, null); // when OpenIdConfiguration body; @@ -49,199 +37,6 @@ void openIdConfiguration() { assertEquals(List.of("ES256"), body.idTokenSigningAlgValuesSupported()); } - @Test - void auth_badScopes() { - var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); - - var sut = new OpenIdEndpoint(config, null, null, null); - - var scope = "openid email"; - var state = UUID.randomUUID().toString(); - var nonce = UUID.randomUUID().toString(); - var responseType = "code"; - var clientId = "myapp"; - - // when - try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { - // then - assertEquals(Status.SEE_OTHER.getStatusCode(), res.getStatus()); - var location = res.getHeaderString("location"); - assertEquals( - "https://myapp.example.com?error=invalid_scope&error_description=scope+%%27openid+email%%27+not+supported&state=%s" - .formatted(state), - location); - } - } - - @Test - void auth_malformedRedirect() { - var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); - - var sut = new OpenIdEndpoint(config, null, null, null); - - var scope = "openid email"; - var state = UUID.randomUUID().toString(); - var nonce = UUID.randomUUID().toString(); - var responseType = "code"; - var clientId = "myapp"; - var redirectUri = "httpyolo://yolo!"; - - // when - try (var res = sut.auth(scope, state, responseType, clientId, redirectUri, nonce)) { - // then - assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); - } - } - - @Test - void auth_untrustedRedirect() { - var config = new Config(0, BASE_URI, null, List.of(REDIRECT_URI)); - - var sut = new OpenIdEndpoint(config, null, null, null); - - var scope = "openid email"; - var state = UUID.randomUUID().toString(); - var nonce = UUID.randomUUID().toString(); - var responseType = "code"; - var clientId = "myapp"; - var redirectUri = "https://bad.example.com/evil"; - - // when - try (var res = sut.auth(scope, state, responseType, clientId, redirectUri, nonce)) { - // then - assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); - - // according to spec we _MUST NOT_ redirect if we don't trust the redirect - assertNull(res.getHeaderString("location")); - } - } - - @Test - void auth_badResponseType() { - var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); - - var sessionRepo = mock(SessionRepo.class); - var sut = new OpenIdEndpoint(config, sessionRepo, null, null); - - var scope = "openid"; - var state = UUID.randomUUID().toString(); - var nonce = UUID.randomUUID().toString(); - var responseType = "badtype"; - var clientId = "myapp"; - - var sessionId = UUID.randomUUID().toString(); - when(sessionRepo.save(any())).thenReturn(sessionId); - - // when - try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { - - // then - assertEquals(Status.SEE_OTHER.getStatusCode(), res.getStatus()); - var location = res.getHeaderString("location"); - assertEquals( - "https://myapp.example.com?error=unsupported_response_type&error_description=unsupported+response+type%3A+%27badtype%27&state=" - + state, - location); - } - } - - @Test - void auth_success() { - var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); - - var sessionRepo = mock(SessionRepo.class); - var sut = new OpenIdEndpoint(config, sessionRepo, null, null); - - var scope = "openid"; - var state = UUID.randomUUID().toString(); - var nonce = UUID.randomUUID().toString(); - var responseType = "code"; - var clientId = "myapp"; - - var sessionId = UUID.randomUUID().toString(); - when(sessionRepo.save(any())).thenReturn(sessionId); - - // when - try (var res = sut.auth(scope, state, responseType, clientId, REDIRECT_URI.toString(), nonce)) { - - // then - assertEquals(Status.OK.getStatusCode(), res.getStatus()); - var sessionCookie = res.getCookies().get("session_id"); - assertEquals(sessionId, sessionCookie.getValue()); - } - } - - @ParameterizedTest - @NullSource - @ValueSource(strings = {" ", " \n\t"}) - void callback_noSessionId(String sessionId) { - - var config = new Config(0, null, null, null); - - var sut = new OpenIdEndpoint(config, null, null, null); - - // when - try (var res = sut.callback(sessionId)) { - - // then - assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); - } - } - - @Test - void callback_unknownSession() { - - var config = new Config(0, null, null, null); - - var sessionRepo = mock(SessionRepo.class); - - var sut = new OpenIdEndpoint(config, sessionRepo, null, null); - - var sessionId = UUID.randomUUID().toString(); - - when(sessionRepo.load(sessionId)).thenReturn(null); - - // when - try (var res = sut.callback(sessionId)) { - - // then - assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); - } - } - - @Test - void callback() { - - var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); - - var sessionRepo = mock(SessionRepo.class); - var tokenIssuer = mock(TokenIssuer.class); - - var sut = new OpenIdEndpoint(config, sessionRepo, tokenIssuer, null); - - var sessionId = UUID.randomUUID().toString(); - - var state = "mySuperDuperState"; - var nonce = "20e5ed8b-f96b-48de-ae73-4460bcfc35a1"; - var clientId = "myapp"; - - var session = new SessionRepo.Session(sessionId, state, nonce, REDIRECT_URI, clientId); - when(sessionRepo.load(sessionId)).thenReturn(session); - - var code = "6238e4504332468aa0c12e300787fded"; - var issued = new Code(code, null, null, REDIRECT_URI, nonce, clientId); - when(tokenIssuer.issueCode(session)).thenReturn(issued); - - // when - try (var res = sut.callback(sessionId)) { - - // then - assertEquals( - "https://myapp.example.com?code=6238e4504332468aa0c12e300787fded&state=mySuperDuperState", - res.getHeaderString("location")); - } - } - @Test void jwks() throws ParseException { @@ -253,95 +48,11 @@ void jwks() throws ParseException { var keyStore = mock(KeyStore.class); when(keyStore.signingKey()).thenReturn(key); - var sut = new OpenIdEndpoint(null, null, null, keyStore); + var sut = new OpenIdEndpoint(null, keyStore); try (var res = sut.jwks()) { var jwks = res.readEntity(JWKSet.class); assertEquals(key, jwks.getKeys().get(0)); } } - - @Test - void token_badGrantType() { - - var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); - - var tokenIssuer = mock(TokenIssuer.class); - - var sut = new OpenIdEndpoint(config, null, tokenIssuer, null); - - var clientId = "myapp"; - - var grantType = "yolo"; - - var code = "6238e4504332468aa0c12e300787fded"; - - when(tokenIssuer.redeem(code, null, null)).thenReturn(null); - - // when - try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { - - // then - assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); - } - } - - @Test - void token_badCode() { - - var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); - - var tokenIssuer = mock(TokenIssuer.class); - - var sut = new OpenIdEndpoint(config, null, tokenIssuer, null); - - var clientId = "myapp"; - - var grantType = "authorization_code"; - - var code = "6238e4504332468aa0c12e300787fded"; - - when(tokenIssuer.redeem(code, REDIRECT_URI.toString(), clientId)).thenReturn(null); - - // when - try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { - - // then - assertEquals(Status.BAD_REQUEST.getStatusCode(), res.getStatus()); - } - } - - @Test - void token() { - - var config = new Config(0, BASE_URI, List.of("code"), List.of(REDIRECT_URI)); - - var tokenIssuer = mock(TokenIssuer.class); - - var sut = new OpenIdEndpoint(config, null, tokenIssuer, null); - - var clientId = "myapp"; - - var grantType = "authorization_code"; - - var idToken = UUID.randomUUID().toString(); - var accessToken = UUID.randomUUID().toString(); - var expiresIn = 3600; - - var code = "6238e4504332468aa0c12e300787fded"; - var token = new Token(accessToken, idToken, expiresIn); - when(tokenIssuer.redeem(code, REDIRECT_URI.toString(), clientId)).thenReturn(token); - - // when - try (var res = sut.token(code, grantType, REDIRECT_URI.toString(), clientId)) { - - // then - assertEquals(Status.OK.getStatusCode(), res.getStatus()); - var got = res.readEntity(TokenResponse.class); - - assertEquals(idToken, got.idToken()); - assertEquals(accessToken, got.accessToken()); - assertEquals(expiresIn, got.expiresIn()); - } - } }