diff --git a/README.md b/README.md index d2ea50a598bbed4ca82c6f68b2041b92a00ebb96..d423a7ca100716ec5fd2f4227c7ed755bd0f31a0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ # OSIv2-Postfach-Anbindung für OZG-Cloud-Nachrichten Anbindung des OSIv2-Postfachs für die OZG-Cloud. + + + +## Client-Authentifizierung beim Servicekonto + +Die Client-Authentifizierung beim Authentication-Server (Servicekonto) erfolgt über den OAuth2-Client-Credentials-Flow (siehe [RFC 6749, Sec. 1.3.4](https://www.rfc-editor.org/rfc/rfc6749#section-1.3.4)) +mit `client_id` und `client_secret` in Verbindung mit einem Resource-URI-Parameter (siehe [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)), der den Zugriff des Clients auf den Resource-Server (Postfach-Facade) einschränkt. + +Der Resource-Server liest die Resource-URI aus dem `aud`-Claim (siehe [RFC 9068, Sec. 3](https://datatracker.ietf.org/doc/html/rfc9068#section-3)). + +### Beispiel: + +```bash +curl -v --output auth_response.json \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=OZG-Kopfstelle" \ + --data-urlencode "client_secret=${SH_STAGE_CLIENT_SECRET}" \ + --data-urlencode "scope=default access_urn:dataport:osi:sh:stage:ozgkopfstelle" \ + --data-urlencode "resource=urn:dataport:osi:postfach:rz2:stage:sh" \ + https://idp.serviceportal-stage.schleswig-holstein.de/webidp2/connect/token +``` + +**Beobachtungen:** +- <small>Mit einem ungültigen `resource`-Parameter kommt ein `invalid_target`-Fehler bei der Token-Erstellung.</small> +- <small>Ohne `resource`-Parameter (d.h. ohne `aud`-Claim) kommt `401 Unauthorized` von der Postfach-Facade.</small> +- <small>Ohne `default`-Scope kommt ein `invalid_target`-Fehler bei der Token-Erstellung</small> +- <small>Ohne `access_urn:dataport:osi:sh:stage:ozgkopfstelle` kommt ein `Internal Server Error 500` von der Postfach-Facade.</small> + + + diff --git a/pom.xml b/pom.xml index b8874701f0eb9377c132afa254d160877636b402..e856dcd920bbca1893a1f8fe7ebf3704ec1a03b9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ <parent> <groupId>de.ozgcloud.common</groupId> <artifactId>ozgcloud-common-parent</artifactId> - <version>4.0.1</version> + <version>4.6.0</version> </parent> <groupId>de.ozgcloud.osiv2</groupId> @@ -17,9 +17,8 @@ <description>OSIv2-Postfach-Anbindung für OZG-Cloud-Nachrichten</description> <properties> - <api-lib.version>0.13.0</api-lib.version> + <api-lib.version>0.14.0</api-lib.version> <nachrichten-manager.version>2.14.0</nachrichten-manager.version> - <testcontainers-keycloak.version>3.2.0</testcontainers-keycloak.version> <mockserver-client.version>5.15.0</mockserver-client.version> </properties> <dependencies> @@ -61,23 +60,12 @@ <type>test-jar</type> <scope>test</scope> </dependency> - <dependency> - <groupId>com.github.dasniko</groupId> - <artifactId>testcontainers-keycloak</artifactId> - <version>${testcontainers-keycloak.version}</version> - <scope>test</scope> - </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> </dependency> - <dependency> - <groupId>org.junit.jupiter</groupId> - <artifactId>junit-jupiter-engine</artifactId> - <scope>test</scope> - </dependency> <dependency> <groupId>org.mock-server</groupId> <artifactId>mockserver-client-java</artifactId> @@ -90,13 +78,6 @@ <scope>test</scope> </dependency> - <dependency> - <groupId>org.springframework.cloud</groupId> - <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> - <version>4.1.2</version> - <scope>test</scope> - </dependency> - <!-- commons --> <dependency> <groupId>org.apache.commons</groupId> diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java index 3e7704cbece229f55cc21654f4f5eba368d8ac1c..c26429d8a9fb9e1812f92fa4b96ede47c3d0e280 100644 --- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java +++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteService.java @@ -3,13 +3,17 @@ package de.ozgcloud.nachrichten.postfach.osiv2; import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import de.ozgcloud.nachrichten.postfach.PostfachNachricht; import de.ozgcloud.nachrichten.postfach.PostfachRemoteService; +import lombok.extern.log4j.Log4j2; @Service +@ConditionalOnProperty("ozgcloud.osiv2-postfach.enabled") +@Log4j2 public record OsiPostfachRemoteService( @Qualifier("osi2PostfachWebClient") WebClient webClient ) implements PostfachRemoteService { @@ -18,9 +22,10 @@ public record OsiPostfachRemoteService( @Override public void sendMessage(PostfachNachricht nachricht) { webClient.get() - .uri("/dummy") + .uri("/_metrics") .retrieve() .bodyToMono(String.class) + .doOnNext(metricsString -> LOG.info("Metrics: {}", metricsString)) .block(); // TODO } diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/OsiPostfachProperties.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/OsiPostfachProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..44fd8ef163bbfae627be0353f5346af4a4944dbc --- /dev/null +++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/OsiPostfachProperties.java @@ -0,0 +1,55 @@ +package de.ozgcloud.nachrichten.postfach.osiv2.config; + +import jakarta.annotation.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = OsiPostfachProperties.PREFIX) +public class OsiPostfachProperties { + + static final String PREFIX = "ozgcloud.osiv2-postfach"; + + private boolean enabled; + + @Getter + @Setter + @Configuration + @ConfigurationProperties(prefix = ApiConfiguration.PREFIX) + static class ApiConfiguration { + static final String PREFIX = OsiPostfachProperties.PREFIX + ".api"; + + private String resource; + private String url; + private String tenant; + private String nameIdentifier; + } + + /** + * HTTP proxy configuration. To use basic HTTP authentication configure username and password. (See <a + * href="https://github.com/reactor/reactor-netty/blob/3b74fcf2f5b2bdd129636e9c55608f0187ef7bd4/reactor-netty-core/src/main/java/reactor/netty/transport/ProxyProvider.java#L142">ProxyProvider</a>) + */ + @Getter + @Setter + @Configuration + @ConfigurationProperties(prefix = ProxyConfiguration.PREFIX) + static class ProxyConfiguration { + static final String PREFIX = OsiPostfachProperties.PREFIX + ".http-proxy"; + + private boolean enabled; + + private String host; + private Integer port; + + @Nullable + private String username = null; + @Nullable + private String password = null; + } +} diff --git a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/WebClientConfiguration.java b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/WebClientConfiguration.java index 454f52e0b89cf51700af6a0ed6a723972c1958e7..7faa48b12d86fae1d3e8dd0c6c8463d699e0b169 100644 --- a/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/WebClientConfiguration.java +++ b/src/main/java/de/ozgcloud/nachrichten/postfach/osiv2/config/WebClientConfiguration.java @@ -1,37 +1,56 @@ package de.ozgcloud.nachrichten.postfach.osiv2.config; -import java.util.Objects; - +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.core.env.Environment; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.security.oauth2.client.AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.InMemoryReactiveOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider; import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.endpoint.WebClientReactiveClientCredentialsTokenResponseClient; import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.client.WebClient; +import lombok.RequiredArgsConstructor; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.ProxyProvider; + @Configuration +@RequiredArgsConstructor +@ConditionalOnProperty("ozgcloud.osiv2-postfach.enabled") public class WebClientConfiguration { + private final OsiPostfachProperties.ApiConfiguration apiConfiguration; + private final OsiPostfachProperties.ProxyConfiguration proxyConfiguration; + @Bean("osi2PostfachWebClient") public WebClient osi2PostfachWebClient( - ServerOAuth2AuthorizedClientExchangeFilterFunction serverOAuth2AuthorizedClientExchangeFilterFunction, - Environment environment) { - var url = Objects.requireNonNull( - environment.getProperty("ozgcloud.osiv2-postfach.api.url"), - "ozgcloud.osiv2-postfach.api.url is not set"); + ReactiveClientRegistrationRepository clientRegistrations) { return WebClient.builder() - .baseUrl(url) - .filter(serverOAuth2AuthorizedClientExchangeFilterFunction) + .baseUrl(apiConfiguration.getUrl()) + .clientConnector(new ReactorClientHttpConnector(httpClient())) + .filter(serverOAuth2AuthorizedClientExchangeFilterFunction(clientRegistrations)) .build(); } - @Bean - @Primary - ServerOAuth2AuthorizedClientExchangeFilterFunction serverOAuth2AuthorizedClientExchangeFilterFunction( + @SuppressWarnings("ConstantConditions") + private HttpClient httpClient() { + var webClient = HttpClient.create(); + return proxyConfiguration.isEnabled() ? webClient + .proxy(proxy -> proxy + .type(ProxyProvider.Proxy.HTTP) + .host(proxyConfiguration.getHost()) + .port(proxyConfiguration.getPort()) + .username(proxyConfiguration.getUsername()) + .password(username -> proxyConfiguration.getPassword()) + ) : webClient; + } + + private ServerOAuth2AuthorizedClientExchangeFilterFunction serverOAuth2AuthorizedClientExchangeFilterFunction( ReactiveClientRegistrationRepository clientRegistrations) { var oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager(clientRegistrations)); @@ -39,19 +58,46 @@ public class WebClientConfiguration { return oauth; } - AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager( + private AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager( ReactiveClientRegistrationRepository clientRegistrations) { var clientService = new InMemoryReactiveOAuth2AuthorizedClientService( clientRegistrations); var authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager( clientRegistrations, clientService); - authorizedClientManager.setAuthorizedClientProvider( - ReactiveOAuth2AuthorizedClientProviderBuilder.builder() - .clientCredentials() - .build()); + authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider()); return authorizedClientManager; } + private ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider() { + return ReactiveOAuth2AuthorizedClientProviderBuilder.builder() + .clientCredentials(builder -> + builder.accessTokenResponseClient(clientCredentialsTokenResponseClient()) + ) + .build(); + } + + private WebClientReactiveClientCredentialsTokenResponseClient clientCredentialsTokenResponseClient() { + var client = new WebClientReactiveClientCredentialsTokenResponseClient(); + configureHttpClientForTokenRequests(client); + configureParametersForTokenRequests(client); + return client; + } + + private void configureHttpClientForTokenRequests(WebClientReactiveClientCredentialsTokenResponseClient client) { + client.setWebClient(WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient())) + .build()); + } + + private void configureParametersForTokenRequests(WebClientReactiveClientCredentialsTokenResponseClient client) { + client.addParametersConverter(source -> { + MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(); + // Pass a resource indicator parameter https://datatracker.ietf.org/doc/html/rfc8707 + parameters.add("resource", apiConfiguration.getResource()); + return parameters; + }); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a93d8dad377c3600a25688a2d493602a3e1ba5d8..172d0ad8a7a1e127b094913f30707cc9e805b69e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,12 +10,21 @@ spring: osi2: client-id: 'OZG-Kopfstelle' client-secret: 'changeme' - scope: default, access_urn:some:scope:for:ozgkopfstelle + scope: default, access_urn:dataport:osi:sh:stage:ozgkopfstelle authorization-grant-type: 'client_credentials' + client-authentication-method: client_secret_post provider: osi2: - token-uri: http://localhost:8080/realms/master/protocol/openid-connect/token + token-uri: 'https://idp.serviceportal-stage.schleswig-holstein.de/webidp2/connect/token' ozgcloud: osiv2-postfach: + enabled: true api: - url: 'replaceme' + resource: 'urn:dataport:osi:postfach:rz2:stage:sh' + url: 'https://api-gateway-stage.dataport.de:443/api/osi_postfach/1.0.0' + tenant: 'SH' + name-identifier: 'ozgkopfstelle' + http-proxy: + enabled: true + host: 127.0.0.1 + port: 3128 diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java index 4633ec32a3c03cf9491201380ffdf23ef4e73736..c1f97ce6707679c77aa262b661607912dd62c457 100644 --- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java +++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceITCase.java @@ -1,6 +1,8 @@ package de.ozgcloud.nachrichten.postfach.osiv2; +import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JwtFactory.*; import static org.assertj.core.api.Assertions.*; +import static org.mockserver.matchers.Times.*; import static org.mockserver.model.HttpRequest.*; import static org.mockserver.model.HttpResponse.*; @@ -10,17 +12,23 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.mockserver.client.MockServerClient; -import org.mockserver.matchers.Times; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; import de.ozgcloud.nachrichten.postfach.PostfachNachricht; -import de.ozgcloud.nachrichten.postfach.osiv2.extension.JwtParser; +import de.ozgcloud.nachrichten.postfach.osiv2.extension.Jwt; import de.ozgcloud.nachrichten.postfach.osiv2.extension.OsiMockServerExtension; +import lombok.SneakyThrows; @SpringBootTest(classes = TestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("itcase") +@TestPropertySource(properties = { + "ozgcloud.osiv2-postfach.http-proxy.enabled=false", +}) public class OsiPostfachRemoteServiceITCase { @RegisterExtension @@ -36,46 +44,53 @@ public class OsiPostfachRemoteServiceITCase { @DynamicPropertySource static void dynamicProperties(DynamicPropertyRegistry registry) { - registry.add("spring.security.oauth2.client.provider.osi2.token-uri", OSI_MOCK_SERVER_EXTENSION::getTokenUri); - registry.add("ozgcloud.osiv2-postfach.api.url", OSI_MOCK_SERVER_EXTENSION::getPostfachMockServerUrl); + registry.add("spring.security.oauth2.client.provider.osi2.token-uri", OSI_MOCK_SERVER_EXTENSION::getAccessTokenUrl); + registry.add("spring.security.oauth2.client.registration.osi2.scope", () -> CLIENT_SCOPES); + registry.add("spring.security.oauth2.client.registration.osi2.client-id", () -> CLIENT_ID); + registry.add("ozgcloud.osiv2-postfach.api.url", OSI_MOCK_SERVER_EXTENSION::getPostfachFacadeUrl); + registry.add("ozgcloud.osiv2-postfach.api.resource", () -> RESOURCE_URN); } - private MockServerClient mockServerClient; + private MockServerClient mockClient; @BeforeEach + @SneakyThrows public void setup() { - mockServerClient = OSI_MOCK_SERVER_EXTENSION.getMockServerClient(); - mockServerClient - .when( - request() - .withMethod("GET") - .withPath("/dummy"), - Times.exactly(1) - ) - .respond( - response() - .withStatusCode(200) - ); + mockClient = OSI_MOCK_SERVER_EXTENSION.getMockClient(); } @DisplayName("send message") @Nested class TestSendMessage { + @DisplayName("should send dummy request with jwt") @Test void shouldSendDummyRequestWithJwt() { + mockClient + .when( + request() + .withMethod("GET") + .withPath("/_metrics"), + exactly(1) + ) + .respond( + response() + .withStatusCode(200) + ); + osiPostfachRemoteService.sendMessage(postfachNachricht); - var requests = mockServerClient.retrieveRecordedRequests( + var requests = mockClient.retrieveRecordedRequests( request() .withMethod("GET") - .withPath("/dummy") + .withPath("/_metrics") ); assertThat(requests).hasSize(1); - String clientId = JwtParser.parseBody( + var jwt = Jwt.parseAuthHeaderValue( requests[0].getHeader("Authorization").getFirst() - ).read("$.client_id"); - assertThat(clientId).isEqualTo("OZG-Kopfstelle"); + ); + assertThat(jwt.body().read("$.client_id", String.class)).isEqualTo(CLIENT_ID); + assertThat(jwt.body().read("$.aud", String.class)).isEqualTo(RESOURCE_URN); } } diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceRemoteITCase.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceRemoteITCase.java new file mode 100644 index 0000000000000000000000000000000000000000..b85d5225eecb974c503e3e1a628fe25a2032fa33 --- /dev/null +++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/OsiPostfachRemoteServiceRemoteITCase.java @@ -0,0 +1,69 @@ +package de.ozgcloud.nachrichten.postfach.osiv2; + +import static org.assertj.core.api.Assertions.*; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import de.ozgcloud.nachrichten.postfach.PostfachNachricht; + +@SpringBootTest(classes = TestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("itcase") +@EnabledIfEnvironmentVariable(named = "SH_STAGE_CLIENT_SECRET", matches = ".+") +public class OsiPostfachRemoteServiceRemoteITCase { + + @Autowired + private OsiPostfachRemoteService osiPostfachRemoteService; + + private static final String MESSAGE_ID = "message-id"; + private final PostfachNachricht postfachNachricht = PostfachNachricht.builder() + .messageId(MESSAGE_ID) + .build(); + + @DynamicPropertySource + static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add( + "spring.security.oauth2.client.registration.osi2.client-secret", + () -> System.getenv("SH_STAGE_CLIENT_SECRET") + ); + registry.add( + "ozgcloud.osiv2-postfach.http-proxy.host", + () -> matchProxyRegex(System.getenv("HTTP_PROXY")).group(1) + ); + registry.add( + "ozgcloud.osiv2-postfach.http-proxy.port", + () -> matchProxyRegex(System.getenv("HTTP_PROXY")).group(2) + ); + } + + private static Matcher matchProxyRegex(String text) { + var matcher = Pattern.compile("([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+):([0-9]+)").matcher(text); + if (matcher.find()) { + return matcher; + } + throw new IllegalArgumentException("Proxy host and port not found in '%s'".formatted(text)); + } + + @DisplayName("send message") + @Nested + class TestSendMessage { + + @DisplayName("should not fail") + @Test + void shouldNotFail() { + assertThatCode(() -> osiPostfachRemoteService.sendMessage(postfachNachricht)) + .doesNotThrowAnyException(); + } + + } +} diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/Jwt.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/Jwt.java new file mode 100644 index 0000000000000000000000000000000000000000..327c91effe0823e3d7fb7c41e3a7a2c706a11eac --- /dev/null +++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/Jwt.java @@ -0,0 +1,89 @@ +package de.ozgcloud.nachrichten.postfach.osiv2.extension; + +import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JsonUtil.*; + +import java.io.Serializable; +import java.util.Map; + +import org.bouncycastle.util.encoders.Base64; + +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.ReadContext; + +import lombok.SneakyThrows; + +public record Jwt(String token) { + + public record JwtParts(ReadContext header, ReadContext body, byte[] signature) { + } + + @SneakyThrows + public static JwtParts parseAuthHeaderValue(String authorizationHeaderValue) { + return new Jwt(discardBearerPrefix(authorizationHeaderValue)).parse(); + + } + + public String scope() { + return parse().body().read("$.scope"); + } + + public Integer issuedAt() { + return parse().body().read("$.iat", Integer.class); + } + + public Integer expireTime() { + return parse().body().read("$.exp", Integer.class); + } + + public JwtParts parse() { + var jwtParts = splitIntoHeaderAndBodyAndSignature(this.token); + return new JwtParts( + parseJsonPath(base64UrlDecode(jwtParts[0])), + parseJsonPath(base64UrlDecode(jwtParts[1])), + base64UrlDecode(jwtParts[2]) + ); + + } + + private static ReadContext parseJsonPath(byte[] bytes) { + return JsonPath.parse(new String(bytes)); + } + + private static String discardBearerPrefix(String authorizationHeaderValue) { + return authorizationHeaderValue.substring("Bearer ".length()); + } + + private static String[] splitIntoHeaderAndBodyAndSignature(String jwt) { + var jwtParts = jwt.split("\\."); + if (jwtParts.length != 3) { + throw new IllegalArgumentException("Invalid JWT token"); + } + return jwtParts; + } + + private static byte[] base64UrlDecode(String input) { + // Replace URL-safe characters + String base64 = input.replace('-', '+').replace('_', '/'); + // Add padding if necessary + int padding = 4 - (base64.length() % 4); + if (padding < 4) { + base64 += "=".repeat(padding); + } + return Base64.decode(base64); + } + + @SneakyThrows + public static Jwt create(Map<String, Serializable> header, Map<String, Serializable> body, byte[] signature) { + var headerJson = toJson(header); + var bodyJson = toJson(body); + var token = base64UrlEncode(headerJson) + "." + base64UrlEncode(bodyJson) + "." + base64UrlEncode(new String(signature)); + return new Jwt(token); + } + + private static String base64UrlEncode(String input) { + var base64 = new String(Base64.encode(input.getBytes())); + return base64.replace('+', '-').replace('/', '_').replace("=", ""); + + } + +} diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtParser.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtParser.java deleted file mode 100644 index 85c18edb1889ecdebdb31dc6f32317df77594ffc..0000000000000000000000000000000000000000 --- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtParser.java +++ /dev/null @@ -1,47 +0,0 @@ -package de.ozgcloud.nachrichten.postfach.osiv2.extension; - -import org.eclipse.jgit.util.Base64; - -import com.jayway.jsonpath.JsonPath; -import com.jayway.jsonpath.ReadContext; - -import lombok.SneakyThrows; - -public class JwtParser { - - @SneakyThrows - public static ReadContext parseBody(String authorizationHeaderValue) { - var jwtParts = splitIntoSignatureAndHeaderAndBody( - discardBearerPrefix(authorizationHeaderValue) - ); - var bodyPart = jwtParts[1]; - return parseJsonPartFromUrlEncodedBase64(bodyPart); - } - - private static ReadContext parseJsonPartFromUrlEncodedBase64(String base64EncodedPayload) { - return JsonPath.parse(new String(base64UrlDecode(base64EncodedPayload))); - } - - private static String discardBearerPrefix(String authorizationHeaderValue) { - return authorizationHeaderValue.substring("Bearer ".length()); - } - - private static String[] splitIntoSignatureAndHeaderAndBody(String jwt) { - var jwtParts = jwt.split("\\."); - if (jwtParts.length != 3) { - throw new IllegalArgumentException("Invalid JWT token"); - } - return jwtParts; - } - - private static byte[] base64UrlDecode(String input) { - // Replace URL-safe characters - String base64 = input.replace('-', '+').replace('_', '/'); - // Add padding if necessary - int padding = 4 - (base64.length() % 4); - if (padding < 4) { - base64 += "=".repeat(padding); - } - return Base64.decode(base64); - } -} diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtParserTest.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtTest.java similarity index 66% rename from src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtParserTest.java rename to src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtTest.java index 9e5ab356ae8e7447395cecbd7839a42d0bd83820..0552893306658033825e6d21df1714a18e392a37 100644 --- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtParserTest.java +++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/JwtTest.java @@ -2,18 +2,35 @@ package de.ozgcloud.nachrichten.postfach.osiv2.extension; import static org.assertj.core.api.Assertions.*; +import java.util.Map; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -class JwtParserTest { +class JwtTest { @DisplayName("should parse") @Test void shouldParse() { var headerValue = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ1aV9EaHVXUzdocFhzV3dZTHRlOHFIRkR4bnNFYldlVmJBZ0pzaWpsWGw4In0.eyJleHAiOjE3MzEwNzA5NTEsImlhdCI6MTczMTA3MDg5MSwianRpIjoiZTFjNWE4YjEtZWEyYS00Mzg5LTkyNDQtZWE5Mjc4M2IyZDA1IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDozMjkyNy9yZWFsbXMvbWFzdGVyIiwic3ViIjoiNTg1MzdjMGQtMzU3MS00MDExLWIxM2ItZDY1MGZjOGUwZjQ0IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiT1pHLUtvcGZzdGVsbGUiLCJzY29wZSI6ImFjY2Vzc191cm46c29tZTpzY29wZTpmb3I6b3pna29wZnN0ZWxsZSBkZWZhdWx0IiwiY2xpZW50SG9zdCI6IjE3Mi4xNy4wLjEiLCJjbGllbnRBZGRyZXNzIjoiMTcyLjE3LjAuMSIsImNsaWVudF9pZCI6Ik9aRy1Lb3Bmc3RlbGxlIn0.MRGusCVssO-fHRp8-tEcdQWE7QVi3P0iHdmO4rGUwj_17KtHzQAT8ShZEVvE8oL-y-XKAPh7eT9will3oON1qhW6GHbZk5Xds4P5u8D0iHNl8nCSi_YS122v9Q1gwPrwPtVH26AKrdNM_YYv0AzT63gOVUoK4YY4jLhow3Uid2AVr2OMNAtcSPMysHXS1VeQRrhOm33JF_WVlguIHNjRpvRqCULkwywBRXDJm2mHOohkXFf10nM3ORAlmeElJCZa7Lg0zeg3q957Z9Mv5KbZA1X_QiHR5qpaDvimn0R_TTCZTGWM00GfyEHi2UU1s2ZfBeZTLOTNg2MUuDgA1cI7CQ"; - var context = JwtParser.parseBody(headerValue); + var context = Jwt.parseAuthHeaderValue(headerValue); - String value = context.read("$.client_id"); + String value = context.body().read("$.client_id"); assertThat(value).isEqualTo("OZG-Kopfstelle"); } + + @DisplayName("should create") + @Test + void shouldCreate() { + var jwt = Jwt.create(Map.of( + "alg", "RS256" + ), Map.of( + "sub", "58537c0d-3571-4011-b13b-d650fc8e0f44" + ), "signature".getBytes()); + + var jwtParts = jwt.parse(); + assertThat(jwtParts.header().read("$.alg", String.class)).isEqualTo("RS256"); + assertThat(jwtParts.body().read("$.sub", String.class)).isEqualTo("58537c0d-3571-4011-b13b-d650fc8e0f44"); + assertThat(jwtParts.signature()).isEqualTo("signature".getBytes()); + } } diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java index ad7c5601652fb2bd9ba5d6ab64b67e2636b30734..1c745f5ae52029bd7719b4d9f4a9860e4997d55f 100644 --- a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java +++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/extension/OsiMockServerExtension.java @@ -1,107 +1,111 @@ package de.ozgcloud.nachrichten.postfach.osiv2.extension; -import static org.assertj.core.api.Assertions.*; - -import java.util.List; -import java.util.function.Supplier; - -import jakarta.ws.rs.core.Response; +import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JwtFactory.*; +import static org.mockserver.matchers.Times.*; +import static org.mockserver.model.Header.*; +import static org.mockserver.model.HttpRequest.*; +import static org.mockserver.model.HttpResponse.*; +import static org.mockserver.model.Parameter.*; +import static org.mockserver.model.ParameterBody.*; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; -import org.keycloak.admin.client.resource.RealmResource; -import org.keycloak.representations.idm.ClientRepresentation; -import org.keycloak.representations.idm.ClientScopeRepresentation; import org.mockserver.client.MockServerClient; import org.testcontainers.containers.MockServerContainer; +import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.utility.DockerImageName; -import dasniko.testcontainers.keycloak.KeycloakContainer; +import de.ozgcloud.nachrichten.postfach.osiv2.factory.JwtFactory; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +@Log4j2 @Getter @RequiredArgsConstructor -public class OsiMockServerExtension implements BeforeAllCallback, AfterAllCallback, AfterEachCallback { - - private MockServerClient mockServerClient; - private MockServerContainer mockServerContainer; - private KeycloakContainer keycloakContainer; +public class OsiMockServerExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback { + + private MockServerClient mockClient; + private final MockServerContainer mockServerContainer = new MockServerContainer(DockerImageName.parse("mockserver/mockserver") + .withTag("mockserver-5.15.0")) + .withLogConsumer(outputFrame -> { + var line = outputFrame.getUtf8String().stripTrailing(); + if (outputFrame.getType() == OutputFrame.OutputType.STDERR) { + LOG.error(line); + } else { + LOG.info(line); + } + }); @Override public void beforeAll(ExtensionContext context) { - setupPostfachMockServer(); - setupKeycloak(); + setupMockServer(); } @Override public void afterEach(ExtensionContext context) { - mockServerClient.reset(); + mockClient.reset(); } @Override public void afterAll(ExtensionContext context) { mockServerContainer.stop(); - mockServerClient.stop(); - keycloakContainer.stop(); + mockClient.stop(); } - private void setupPostfachMockServer() { - mockServerContainer = new MockServerContainer(DockerImageName.parse("mockserver/mockserver") - .withTag("mockserver-5.15.0")); + private void setupMockServer() { mockServerContainer.start(); - mockServerClient = new MockServerClient(mockServerContainer.getHost(), mockServerContainer.getServerPort()); + mockClient = new MockServerClient( + mockServerContainer.getHost(), + mockServerContainer.getServerPort() + ); } - private void setupKeycloak() { - keycloakContainer = new KeycloakContainer("quay.io/keycloak/keycloak:24.0.5"); - keycloakContainer.start(); - try (var keycloak = keycloakContainer.getKeycloakAdminClient()) { - keycloak.tokenManager().getAccessToken(); - var masterRealm = keycloak.realm("master"); - var clientScopes = List.of("default", "access_urn:some:scope:for:ozgkopfstelle"); - clientScopes.forEach(scope -> createClientScope(masterRealm, scope)); - createPostfachClient(masterRealm, clientScopes); - } + public String getAccessTokenUrl() { + return getMockServerUrl() + "/access-token"; } - private void createPostfachClient(RealmResource realmResource, List<String> clientScopes) { - var clients = realmResource.clients(); - var postfach = new ClientRepresentation(); - postfach.setClientId("OZG-Kopfstelle"); - postfach.setSecret("changeme"); - postfach.setOptionalClientScopes(clientScopes); - postfach.setServiceAccountsEnabled(true); - postfach.setEnabled(true); - verifyResponseOk(() -> clients.create(postfach)); + public String getPostfachFacadeUrl() { + return getMockServerUrl(); } - private void createClientScope(RealmResource realmResource, String scopeName) { - var clientScopes = realmResource.clientScopes(); - var clientScopeRepresentation = new ClientScopeRepresentation(); - clientScopeRepresentation.setName(scopeName); - clientScopeRepresentation.setProtocol("openid-connect"); - verifyResponseOk(() -> clientScopes.create(clientScopeRepresentation)); + private String getMockServerUrl() { + return "http://" + mockClient.remoteAddress().getHostName() + ":" + mockClient.remoteAddress().getPort(); } - private void verifyResponseOk(Supplier<Response> responseSupplier) { - try (var response = responseSupplier.get()) { - assertThat(response.getStatus()).isEqualTo(201); - } - } - - public String getTokenUri() { - return getAuthProtocolUrl() + "/token"; - } - - public String getAuthProtocolUrl() { - return keycloakContainer.getAuthServerUrl() + "/realms/master/protocol/openid-connect"; - } - - public String getPostfachMockServerUrl() { - return "http://" + mockServerClient.remoteAddress().getHostName() + ":" + mockServerClient.remoteAddress().getPort(); + @Override + public void beforeEach(ExtensionContext context) { + mockClient + .when( + request() + .withMethod("POST") + .withPath("/access-token") + .withHeaders( + header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") + ) + .withBody( + params( + param("grant_type", "client_credentials"), + param("client_id", CLIENT_ID), + param("client_secret", "changeme"), + param("scope", String.join(" ", CLIENT_SCOPES)), + param("resource", RESOURCE_URN) + ) + ), + exactly(1) + ) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody( + JwtFactory.createTokenResponse( + JwtFactory.createAccessTokenExampleWithExpireIn(900) + ) + ) + ); } - } diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/JsonUtil.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/JsonUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..5353606ac693ef6d98d03dc58d77d900c4943279 --- /dev/null +++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/JsonUtil.java @@ -0,0 +1,14 @@ +package de.ozgcloud.nachrichten.postfach.osiv2.factory; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.SneakyThrows; + +public class JsonUtil { + private static final ObjectMapper jsonMapper = new ObjectMapper(); + + @SneakyThrows + public static String toJson(Object object) { + return jsonMapper.writeValueAsString(object); + } +} diff --git a/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/JwtFactory.java b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/JwtFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..c5e7fcd8adf6948ceffdf3420183a4395897850f --- /dev/null +++ b/src/test/java/de/ozgcloud/nachrichten/postfach/osiv2/factory/JwtFactory.java @@ -0,0 +1,51 @@ +package de.ozgcloud.nachrichten.postfach.osiv2.factory; + +import static de.ozgcloud.nachrichten.postfach.osiv2.factory.JsonUtil.*; + +import java.util.List; +import java.util.Map; + + +import de.ozgcloud.nachrichten.postfach.osiv2.extension.Jwt; +import lombok.SneakyThrows; + +public class JwtFactory { + + public static final String CLIENT_ID = "ITCase OZG Kopfstelle"; + public static final String RESOURCE_URN = "urn:dataport:osi:postfach:rz2:itcase:sh"; + public static final List<String> CLIENT_SCOPES = List.of("default", "access_urn:itcase:scope:ozgkopfstelle"); + + public static Jwt createAccessTokenExampleWithExpireIn(Integer expireInSeconds) { + var nowEpochSeconds = System.currentTimeMillis() / 1000; + return Jwt.create( + Map.of( + "alg", "RS256", + "kid", "...", + "x5t", "...", + "typ", "at+jwt" + ), + Map.of( + "iss", "https://idp.serviceportal-stage.schleswig-holstein.de/webidp2", + "nbf", nowEpochSeconds, + "iat", nowEpochSeconds, + "exp", nowEpochSeconds + expireInSeconds, + "aud", RESOURCE_URN, + "scope", String.join(" ", CLIENT_SCOPES), + "client_id", CLIENT_ID, + "client_tenant", "urn:osp:names:realm:stage:sh", + "jti", "..." + ), + "signature".getBytes()); + } + + @SneakyThrows + public static String createTokenResponse(Jwt accessToken) { + return toJson(Map.of( + "access_token", accessToken.token(), + "token_type", "Bearer", + "expires_in", accessToken.expireTime() - accessToken.issuedAt(), + "scope", accessToken.scope() + )); + } + +}