diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..abf14b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ + +* +!target/*.jar +!src/test/resources/jwt +!src/test/java/httpServer.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..da6dca2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ + +# Declare files that will always have LF line endings on checkout +* text eol=lf + +# Windows CMD does not like LF in batch files +*.bat text eol=crlf +*.cmd text eol=crlf + +# Files that are truly binary and should not be modified +*.png binary +*.jpg binary +*.jpeg binary +*.jar binary +*.p12 binary diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c5e2beb..f28225d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,7 +16,7 @@ variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: '' DOCKER_DRIVER: overlay2 - MAVEN_OPTS: -Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository + MAVEN_OPTS: -D maven.repo.local=$CI_PROJECT_DIR/.m2/repository build: stage: build diff --git a/.mvn/parent.xml b/.mvn/spring.xml similarity index 90% rename from .mvn/parent.xml rename to .mvn/spring.xml index 70b41c0..95cfd3b 100644 --- a/.mvn/parent.xml +++ b/.mvn/spring.xml @@ -14,20 +14,28 @@ org.springframework.boot spring-boot-starter-parent - 3.4.3 + 3.4.5 com.github.jaguililla - parent + spring 0.1.0 pom - Parent - Common Maven settings (excluding DB and messaging) + Spring base POM + + Common Spring settings (excluding DB and messaging): + + * Default configurations (i.e.: encoding) + * Set fix stable versions (Java, Maven, etc.) + * OpenAPI code generation (server and client) + * Profiles for Gatling load tests, mutation tests, Docker image generation, etc. + 3.9.9 + false ${project.groupId}.${project.artifactId}.http ${openapi.package}.controllers @@ -41,8 +49,14 @@ 2.8.4 7.11.0 - 0.8.12 - 1.4.0 + 0.2.6 + 1.17.3 + 1.2.1 + 3.6.0 + 3.5.0 + + 0.8.13 + 1.4.1 3.13.4 4.14.0 @@ -104,6 +118,12 @@ kafka test + + com.auth0 + java-jwt + 4.4.0 + test + io.gatling.highcharts gatling-charts-highcharts @@ -238,8 +258,7 @@ org.openapitools jackson-databind-nullable - - 0.2.6 + ${jackson-databind-nullable.version} @@ -256,14 +275,12 @@ org.pitest pitest-maven - - 1.17.3 + ${pitest-maven.version} org.pitest pitest-junit5-plugin - - 1.2.1 + ${pitest-junit5-plugin.version} @@ -301,8 +318,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - - 3.6.0 + ${maven-checkstyle-plugin.version} true @@ -324,8 +340,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - - 3.6.0 + ${maven-checkstyle-plugin.version} @@ -348,8 +363,7 @@ org.codehaus.mojo exec-maven-plugin - - 3.5.0 + ${exec-maven-plugin.version} docker-up diff --git a/.sdkmanrc b/.sdkmanrc index 774c30d..deb1692 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,3 +1,3 @@ # Enable auto-env through the sdkman_auto_env config # Add key=value pairs of SDKs to use below -java=21.0.6-librca +java=21.0.7-librca diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67f60bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ + +FROM docker.io/bellsoft/liberica-runtime-container:jre-21-slim-musl + +ARG PROJECT="appointments" +ARG OTEL_BASE="github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download" +ARG OTEL_VERSION="v1.32.1" + +WORKDIR /opt/$PROJECT + +COPY target/$PROJECT-*.jar application.jar +ADD https://$OTEL_BASE/$OTEL_VERSION/opentelemetry-javaagent.jar opentelemetry.jar + +USER 1000 + +ENV OTEL_SERVICE_NAME "$PROJECT" +ENV PERFORMANCE_OPTIONS "-XX:+AlwaysPreTouch -XX:+UseParallelGC -XX:+UseNUMA" +ENV TELEMETRY_OPTIONS "-javaagent:./opentelemetry.jar" +ENV JAVA_TOOL_OPTIONS "$PERFORMANCE_OPTIONS $TELEMETRY_OPTIONS" + +HEALTHCHECK --interval=10s --start-period=10s CMD \ + wget -O/dev/stdout --tries=1 \ + http://localhost:8080/actuator/health 2>/dev/null | grep UP || exit 1 + +ENTRYPOINT [ "java", "-jar", "application.jar" ] diff --git a/README.md b/README.md index 97fef08..e98f744 100644 --- a/README.md +++ b/README.md @@ -137,3 +137,8 @@ To run the Gatling test, execute `./mvnw -P gatling` at the shell. [Gatling settings]: https://docs.gatling.io/reference/script/core/configuration [gatlingDefaults]: https://github.com/gatling/gatling/blob/main/gatling-core/src/main/resources/gatling-defaults.conf + +## Release +* Merges to `main` create and publish a release +* A release is a tag, a Maven package for the application, and another for the client +* Publishing is uploading the release to GitHub packages diff --git a/docker-compose.yml b/docker-compose.yml index ff8645a..5f2ea27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,13 @@ name: appointments services: postgres: - image: docker.io/postgres:16-alpine + image: docker.io/postgres:17-alpine + platform: linux/amd64 environment: POSTGRES_USER: root POSTGRES_PASSWORD: root POSTGRES_DB: local + PGDATA: /var/lib/postgresql/data/pgdata volumes: - data:/var/lib/postgresql/data - ./src/test/resources/db:/docker-entrypoint-initdb.d @@ -17,6 +19,7 @@ services: kafka: image: docker.io/apache/kafka-native:3.8.0 + platform: linux/amd64 environment: CLUSTER_ID: 4L6g3nShT-eMCtK--X86sw KAFKA_NODE_ID: 1 @@ -35,22 +38,14 @@ services: ports: - "127.0.0.1:9092:9092" -# openobserve: -# image: docker.io/openobserve/openobserve:v0.14.0 -# environment: -# ZO_ROOT_USER_EMAIL: "root@example.com" -# ZO_ROOT_USER_PASSWORD: "Complex-pass#123" -# volumes: -# - data:/data -# ports: -# - "5080:5080" - appointments: - profiles: [ local ] depends_on: - postgres - kafka image: docker.io/com.github.jaguililla/appointments + platform: linux/amd64 + build: + context: . environment: GLOBAL_LOG_LEVEL: warn APPLICATION_LOG_LEVEL: info @@ -61,6 +56,14 @@ services: ports: - "127.0.0.1:18080:8080" + openid.mock: + image: docker.io/bellsoft/liberica-runtime-container:jdk-21-slim-musl + platform: linux/amd64 + security_opt: + - no-new-privileges:true + ports: + - "127.0.0.1:12345:12345" + volumes: data: driver: local diff --git a/pom.xml b/pom.xml index ae62d3b..b224af0 100644 --- a/pom.xml +++ b/pom.xml @@ -10,13 +10,13 @@ com.github.jaguililla - parent + spring 0.1.0 - .mvn/parent.xml + .mvn/spring.xml appointments - 0.3.10 + 0.3.11 Appointments Application to create appointments (REST API) @@ -45,11 +45,25 @@ org.springframework.boot spring-boot-starter-jdbc - - - - - + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + io.micrometer + micrometer-registry-otlp + runtime + + + com.google.protobuf + protobuf-java + + + + + io.micrometer + micrometer-tracing-bridge-brave + org.flywaydb flyway-database-postgresql diff --git a/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java b/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java index c45f9f7..d577c0c 100644 --- a/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java +++ b/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java @@ -25,9 +25,13 @@ class ApplicationConfiguration { private String deleteMessage; @Bean - AppointmentsNotifier appointmentsNotifier(final KafkaTemplate kafkaTemplate) { + AppointmentsNotifier appointmentsNotifier( + final KafkaTemplate kafkaTemplate, + final ConsumerFactory consumerFactory + ) { final var type = KafkaTemplateAppointmentsNotifier.class.getSimpleName(); LOGGER.info("Creating Appointments Notifier: {}", type); + kafkaTemplate.setConsumerFactory(consumerFactory); return new KafkaTemplateAppointmentsNotifier( kafkaTemplate, notifierTopic, createMessage, deleteMessage ); diff --git a/src/main/java/com/github/jaguililla/appointments/controllers/SecurityConfiguration.java b/src/main/java/com/github/jaguililla/appointments/controllers/SecurityConfiguration.java new file mode 100644 index 0000000..3c560bf --- /dev/null +++ b/src/main/java/com/github/jaguililla/appointments/controllers/SecurityConfiguration.java @@ -0,0 +1,52 @@ +package com.github.jaguililla.appointments.controllers; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.oauth2.jwt.JwtDecoders.fromIssuerLocation; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) +public class SecurityConfiguration { + + private static final String[] PERMITTED_ENDPOINTS = { + "/actuator/info", + "/actuator/info/**", + "/actuator/health", + "/actuator/health/**", + "/actuator/metrics", + "/actuator/metrics/**", + "/v*/api-docs/**", + "/v*/api-docs*", + "/swagger-ui.html", + "/swagger-ui/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + // TODO There should be a way to inject the JwtDecoder (this is a Spring property) + @Value("${spring.security.oauth2.resourceserver.jwk.issuer-uri}") String issuer + ) throws Exception { + + var jwtDecoder = fromIssuerLocation(issuer); + + return http + .csrf(AbstractHttpConfigurer::disable) + .headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin)) + .authorizeHttpRequests(requests -> requests + .requestMatchers(PERMITTED_ENDPOINTS).permitAll() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(jwtDecoder))) + .build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ae9ee53..0961b99 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,3 +30,6 @@ spring: value-deserializer: org.apache.kafka.common.serialization.StringDeserializer group-id: tests auto-offset-reset: earliest + + security.oauth2.resourceserver.jwk: + issuer-uri: ${OPENID:http://localhost:9876/realms/appointments} diff --git a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java index df77bad..1d3f35a 100644 --- a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java +++ b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java @@ -8,6 +8,8 @@ import com.github.jaguililla.appointments.http.controllers.messages.AppointmentResponse; import java.time.Duration; import java.util.List; + +import com.github.jaguililla.appointments.it.OpenIdMock; import org.apache.kafka.common.TopicPartition; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -34,6 +36,8 @@ @TestMethodOrder(OrderAnnotation.class) class ApplicationIT { + static final OpenIdMock OPENID_MOCK = new OpenIdMock(); + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.8.0"); @@ -49,11 +53,13 @@ class ApplicationIT { static void beforeAll() { postgres.start(); kafka.start(); + OPENID_MOCK.start(); } @AfterAll static void afterAll() { postgres.stop(); + OPENID_MOCK.stop(); } @DynamicPropertySource diff --git a/src/test/java/com/github/jaguililla/appointments/TestTemplate.java b/src/test/java/com/github/jaguililla/appointments/TestTemplate.java index 4c4c1e3..f621dd9 100644 --- a/src/test/java/com/github/jaguililla/appointments/TestTemplate.java +++ b/src/test/java/com/github/jaguililla/appointments/TestTemplate.java @@ -1,18 +1,25 @@ package com.github.jaguililla.appointments; import static org.slf4j.LoggerFactory.getLogger; +import static org.springframework.http.HttpMethod.DELETE; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.jaguililla.appointments.it.JwtTokenManager; import org.slf4j.Logger; import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatusCode; +import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; -import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestTemplate; +import java.net.URI; + /** * Simplification of RestTemplate for testing. It holds the last received response to ease testing * flows. @@ -20,26 +27,18 @@ public final class TestTemplate { private static final Logger LOGGER = getLogger(TestTemplate.class); - - @SuppressWarnings("NullableProblems") - private static final ResponseErrorHandler ERROR_HANDLER = new ResponseErrorHandler() { - @Override - public boolean hasError(ClientHttpResponse response) { - return false; - } - - @Override - public void handleError(ClientHttpResponse response) { - // Let RestTemplate return HTTP error codes without throwing exceptions - } - }; + private static final ResponseErrorHandler ERROR_HANDLER = ignore -> false; private final ObjectMapper mapper; private final RestTemplate client; + private final String rootUri; + private final JwtTokenManager tokenManager; private ResponseEntity lastResponse; public TestTemplate(final String rootUri) { + this.rootUri = rootUri; + this.tokenManager = new JwtTokenManager(); final var restTemplateBuilder = new RestTemplateBuilder(); mapper = new ObjectMapper(); @@ -53,7 +52,7 @@ public TestTemplate(final String rootUri) { @SuppressWarnings("UnusedReturnValue") // This is just a testing helper public ResponseEntity get(final String path) { LOGGER.debug("-> GET {}", path); - lastResponse = client.getForEntity(path, String.class); + lastResponse = client.exchange(createRequest(GET, path), String.class); LOGGER.debug("<- GET {}\n{}", path, lastResponse.getBody()); return lastResponse; } @@ -61,7 +60,7 @@ public ResponseEntity get(final String path) { @SuppressWarnings("UnusedReturnValue") // This is just a testing helper public ResponseEntity delete(final String path) { LOGGER.debug("-> DELETE {}", path); - lastResponse = client.exchange(path, HttpMethod.DELETE, null, String.class); + lastResponse = client.exchange(createRequest(DELETE, path), String.class); LOGGER.debug("<- DELETE {}\n{}", path, lastResponse.getBody()); return lastResponse; } @@ -69,7 +68,7 @@ public ResponseEntity delete(final String path) { @SuppressWarnings("UnusedReturnValue") // This is just a testing helper public ResponseEntity post(final String path, final Object body) { LOGGER.debug("-> POST {}", path); - lastResponse = client.postForEntity(path, body, String.class); + lastResponse = client.exchange(createRequest(POST, path, body), String.class); LOGGER.debug("<- POST {}\n{}", path, lastResponse.getBody()); return lastResponse; } @@ -90,8 +89,23 @@ public T getResponseBody(final Class type) { return mapper.readValue(body, type); } catch (final JsonProcessingException e) { - final var message = "Error mapping response body to '" + type.getName() + "':\n" + body; + final var message = "Error mapping response body to '%s':\n%s" + .formatted(type.getName(), body); throw new RuntimeException(message, e); } } + + private RequestEntity createRequest(HttpMethod method, String path) { + return createRequest(method, path, null); + } + + private RequestEntity createRequest(HttpMethod method, String path, Object body) { + final var headers = new HttpHeaders(); + final var uri = URI.create(rootUri + path); + final var request = new RequestEntity<>(body, headers, method, uri); + final var token = tokenManager.createToken("http://localhost:9876/realms/appointments"); + + headers.setBearerAuth(token); + return request; + } } diff --git a/src/test/java/com/github/jaguililla/appointments/it/JwtTokenManager.java b/src/test/java/com/github/jaguililla/appointments/it/JwtTokenManager.java new file mode 100644 index 0000000..6589018 --- /dev/null +++ b/src/test/java/com/github/jaguililla/appointments/it/JwtTokenManager.java @@ -0,0 +1,101 @@ +package com.github.jaguililla.appointments.it; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static io.undertow.util.Headers.REALM; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class JwtTokenManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenManager.class); + private static final Base64.Decoder BASE64_DECODER = Base64.getMimeDecoder(); + + private final Algorithm ALGORITHM; + + public JwtTokenManager() { + try { + RSAPrivateKey privateKey = readRsaPrivateKey("jwt/sign.key.pem"); + RSAPublicKey publicKey = readRsaPublicKey("jwt/sign.pub.pem"); + ALGORITHM = Algorithm.RSA256(publicKey, privateKey); + } + catch (IOException | InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + public String createToken(String issuer) { + var token = JWT + .create() + .withExpiresAt(Instant.now().plusSeconds(3600 * 24 * 365)) + .withIssuedAt(Instant.now()) + .withJWTId(UUID.randomUUID().toString()) + .withIssuer(issuer) + .withAudience("account") + .withSubject("subject") + .withClaim("typ", "Bearer") + .withClaim("allowed-origins", List.of("/*")) + .sign(ALGORITHM); + + LOGGER.info("ISSUER: {}\n{}", issuer, token); + return token; + } + + private String createToken(String server, int port) { + return createToken("%s:%d%s".formatted(server, port, REALM)); + } + + private static RSAPrivateKey readRsaPrivateKey(String resource) + throws IOException, InvalidKeySpecException, NoSuchAlgorithmException { + + var keyFactory = KeyFactory.getInstance("RSA"); + var keySpec = new PKCS8EncodedKeySpec(readPem(resource)); + return (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + } + + private static RSAPublicKey readRsaPublicKey(String resource) + throws IOException, InvalidKeySpecException, NoSuchAlgorithmException { + + var keyFactory = KeyFactory.getInstance("RSA"); + var keySpec = new X509EncodedKeySpec(readPem(resource)); + return (RSAPublicKey) keyFactory.generatePublic(keySpec); + } + + private static byte[] readPem(String resource) throws IOException { + var content = new ClassPathResource(resource).getContentAsString(UTF_8); + var privateKeyPEM = content + .replaceAll("-----(BEGIN|END) (PRIVATE|PUBLIC) KEY-----", "") + .replaceAll(System.lineSeparator(), ""); + + return BASE64_DECODER.decode(privateKeyPEM); + } + + /** + * Create test tokens. + */ + public static void main(String... args) { + var jwtTokenManager = new JwtTokenManager(); + Map.of( + "http://openid.mock", 9876, + "http://localhost", 9876 + ) + .forEach((endpoint, port) -> jwtTokenManager.createToken(endpoint, port)); + } +} diff --git a/src/test/java/com/github/jaguililla/appointments/it/OpenIdMock.java b/src/test/java/com/github/jaguililla/appointments/it/OpenIdMock.java new file mode 100644 index 0000000..11c052e --- /dev/null +++ b/src/test/java/com/github/jaguililla/appointments/it/OpenIdMock.java @@ -0,0 +1,78 @@ +package com.github.jaguililla.appointments.it; + +import com.sun.net.httpserver.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; + +import java.net.InetSocketAddress; +import java.util.Map; + +import static java.net.HttpURLConnection.HTTP_OK; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class OpenIdMock { + + private static final Logger LOGGER = LoggerFactory.getLogger(OpenIdMock.class); + private static final String JSON = "application/json; charset=" + UTF_8.name(); + + static final String CONTEXT = "/realms/appointments"; + static final int PORT = 9876; + + private HttpServer httpServer; + + final JwtTokenManager jwtTokenManager; + + public OpenIdMock() { + try { + jwtTokenManager = new JwtTokenManager(); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void start() { + try { + if (httpServer != null) + return; + + httpServer = HttpServer.create(new InetSocketAddress(PORT), 0); + + var resources = Map.of( + ".well-known/openid-configuration", "/jwt/openid-configuration.json", + "protocol/openid-connect/certs", "/jwt/certs.json" + ); + + httpServer.createContext(CONTEXT, exchange -> { + var uriPath = exchange.getRequestURI().getPath(); + var path = uriPath.replaceFirst("^" + CONTEXT + "/?", ""); + var host = exchange.getRequestHeaders().getFirst("Host"); + var binding = "http://" + host + CONTEXT; + + var responsePath = resources.get(path); + var responseTemplate = new ClassPathResource(responsePath).getContentAsByteArray(); + var responseText = new String(responseTemplate).replaceAll("", binding); + var response = responseText.getBytes(); + + exchange.getResponseHeaders().set("Content-Type", JSON); + exchange.sendResponseHeaders(HTTP_OK, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + + httpServer.start(); + LOGGER.info("OpenID configuration server started"); + } + catch (Exception e) { + LOGGER.error("Error starting OpenID configuration server mock", e); + } + } + + public void stop() { + if (httpServer != null) { + httpServer.stop(0); + httpServer = null; + } + } +} diff --git a/src/test/java/httpServer.java b/src/test/java/httpServer.java new file mode 100755 index 0000000..0683e60 --- /dev/null +++ b/src/test/java/httpServer.java @@ -0,0 +1,44 @@ +import com.sun.net.httpserver.HttpServer; + +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; + +import static java.net.HttpURLConnection.HTTP_OK; +import static java.nio.charset.StandardCharsets.UTF_8; + +class httpServer { + private static final String JSON = "application/json; charset=" + UTF_8.name(); + + public static void main(String... args) throws Exception { + var mockPort = System.getenv("OPENID_MOCK_PORT"); + var mockContext = System.getenv("OPENID_MOCK_CONTEXT"); + var mockRoot = System.getenv("OPENID_MOCK_ROOT"); + + var port = Integer.parseInt(mockPort == null ? "12345" : mockPort); + var context = mockContext == null ? "/context" : mockContext; + var root = mockRoot == null ? "src/test/resources" : mockRoot; + + var rootPath = Path.of(root); + var httpServer = HttpServer.create(new InetSocketAddress(port), 0); + + httpServer.createContext(context, exchange -> { + var uriPath = exchange.getRequestURI().getPath(); + var path = uriPath.replaceFirst("^" + context + "/?", ""); + var host = exchange.getRequestHeaders().getFirst("Host"); + + var responsePath = rootPath.resolve(path); + var responseTemplate = Files.readAllBytes(responsePath); + var responseText = new String(responseTemplate).replaceAll("", host); + var response = responseText.getBytes(); + + exchange.getResponseHeaders().set("Content-Type", JSON); + exchange.sendResponseHeaders(HTTP_OK, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + + httpServer.start(); + System.err.println("Running on: http://localhost:" + port); + } +} diff --git a/src/test/resources/http/http-server.dockerfile b/src/test/resources/http/http-server.dockerfile new file mode 100644 index 0000000..a50bcbd --- /dev/null +++ b/src/test/resources/http/http-server.dockerfile @@ -0,0 +1,13 @@ +FROM docker.io/bellsoft/liberica-runtime-container:jdk-21-slim-musl + +USER 1000 + +# TODO Mount these as volumes +#COPY src/test/resources/jwt/openid-configuration.json /configuration.json +#COPY src/test/resources/jwt/certs.json /certs.json +COPY src/test/java/httpServer.java /httpServer.java + +HEALTHCHECK --interval=10s CMD \ + wget -O/dev/stdout --tries=1 http://localhost:9876 2>/dev/null | grep index || exit 1 + +ENTRYPOINT [ "java", "/httpServer.java" ] diff --git a/src/test/resources/http/requests.sh b/src/test/resources/http/requests.sh new file mode 100755 index 0000000..695991d --- /dev/null +++ b/src/test/resources/http/requests.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# +# You can test other ports running: PORT=9090 ./requests.sh +# + +LOCAL_HOST=${HOST:-"localhost"} +LOCAL_PORT=${PORT:-8080} + +ENDPOINT="$LOCAL_HOST:$LOCAL_PORT" + +echo +curl "http://$ENDPOINT/appointments" +echo diff --git a/src/test/resources/jwt/appointments.p12 b/src/test/resources/jwt/appointments.p12 new file mode 100644 index 0000000..04c071f Binary files /dev/null and b/src/test/resources/jwt/appointments.p12 differ diff --git a/src/test/resources/jwt/certs.json b/src/test/resources/jwt/certs.json new file mode 100644 index 0000000..858eaba --- /dev/null +++ b/src/test/resources/jwt/certs.json @@ -0,0 +1,9 @@ +{ + "keys": [ + { + "kty": "RSA", + "n": "1EkXLkgVatkp4gRZi7Vzc6fxCRV68WgI3gE8qZcU2BQLLuuhHl9mI08VR6xy_MxCsmxcMrT7KlkQ5TGQ6n49HshaUFhHu9jn3Cx7ykNsqEZc3oSkLt4AV5vH_TT4bYh2JO9rOeF6ZRiUwPHMkgdf7iNVRUQLW_jXbHeY5CiQcGiVpiCY92tg_Xb66mRlN8kI9TJkUXbVNP4wHrpIsJlaNdl4lSh0GVMjk0LmjKMkUPR9689KNZ21kNewfq8bjX_muXQ2zoS-h2hefYmt7No5QQIlsJLfezsFDCCytNQmxg1zG54Sn_URwd8OsFJ5ZWgKSnWzy5bZng9VMQGLNG1Obw", + "e": "AQAB" + } + ] +} diff --git a/src/test/resources/jwt/openid-configuration.json b/src/test/resources/jwt/openid-configuration.json new file mode 100644 index 0000000..c7fa7b2 --- /dev/null +++ b/src/test/resources/jwt/openid-configuration.json @@ -0,0 +1,4 @@ +{ + "issuer": "", + "jwks_uri": "/protocol/openid-connect/certs" +} diff --git a/src/test/resources/jwt/sign.key.pem b/src/test/resources/jwt/sign.key.pem new file mode 100644 index 0000000..20ad2b1 --- /dev/null +++ b/src/test/resources/jwt/sign.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUSRcuSBVq2Sni +BFmLtXNzp/EJFXrxaAjeATyplxTYFAsu66EeX2YjTxVHrHL8zEKybFwytPsqWRDl +MZDqfj0eyFpQWEe72OfcLHvKQ2yoRlzehKQu3gBXm8f9NPhtiHYk72s54XplGJTA +8cySB1/uI1VFRAtb+Ndsd5jkKJBwaJWmIJj3a2D9dvrqZGU3yQj1MmRRdtU0/jAe +ukiwmVo12XiVKHQZUyOTQuaMoyRQ9H3rz0o1nbWQ17B+rxuNf+a5dDbOhL6HaF59 +ia3s2jlBAiWwkt97OwUMILK01CbGDXMbnhKf9RHB3w6wUnllaApKdbPLltmeD1Ux +AYs0bU5vAgMBAAECggEAW4fmRFDVVzwqeGb6uyfyDzimz41g9KywQhTTdKYNWTuP +NNxpHIDyt5+2I7DB5akmyuq4+C4bq786bzAndUwYC2lEs6bUyzRziHXvrB7VP1sT +WOhlKEYVbLDhEpaf1Q6FLljC6XKEhQmLvgOj+oTOgo3eit6TbUUGkaChniK1YJmW +S8WCiJX1ZtO79t5+FnDoJK02lvQ1Fu6KWrvsqMQwopHYqktBsaouzLFKljKgL3wk +I8HjFnRlw02Sm4f7dK6RFWcOELFBv+glqGF3WOuJOdq3p9TcQspjt/idoiv1MxsB +YrPtMFSTVaJj3rWXLT32UDxJZf+cynatMNWTfJCeRQKBgQDz8LWILTTKv8UHAhUK +MxS3wjZu8qGtBErUbRpqszm/LLhvwI6ouoFMRClolkFiY7p/+skjVqAEHeP4RO9X +N2nVB+HNINqbVM1EKOS9DVj2uKhCIsYwsFInmpZz0eubv8B19DdGQM+jolQRs4dp +92hAYHWJv0eb/cZtfRgbRzocXQKBgQDex8LSlhI0MQdWGAW/RZkQuL2XFhYb5lM7 +3Xz+T/0hGK4OxtuvQBKd+g+1sTp8R8q0E1BsYb9bZSkN0I2sHTXPtEeLiBnPehrO +OcgZUy/wq+2vp+eLIgUxsakR3ey/c5x+2TJfKmYvr/7KsnJJb1a26z/8zVmjchOu +9CaRbpeJOwKBgHKhwdytSaqhNWhWrSZu3KSBqmy2rg0NAFEUuB49/Lv+uukg9qkJ +g6sgOMlCGpWuwbxUUGK/2VA176QCWOqGSsBmBNENSE1IK3GFOgAxHoZKPh9eEf5d +TS35MgxZMDuvfzSuv67O1ARUSudKky7TqXTfHzzM20zNk4puB38CGKetAoGBANb5 +uwVNLYnRmfu5OKqHNZpOOx01geYwTzdglwItG925HBVETa+CS7TIHiq9N+u/t1on +nR7JAEfoiUI8csmYclnY5IU/s3BjqsRAO9g8TvGWZslvY9792DdI8hY8qf1hSa2a +V6I/ntX7pgnhQqmcV/gxcNC5M/ul1FqqXSFPr8kLAoGBALkywzal8KTyEmtFWCR+ +3Omp+W8f3F/ODpkycoknNtPYmpl7RJxHhd+wZqeJWTZLL+w6TdM5wnh845Xazkf8 +CMvlk4oivdPugBhx83nROlMPx89ZSEf47C/KTxbe8ZMWTysgZTbPcYWus2y2ZPOX +5ds44LQO91EGP5TkKWKGmp7G +-----END PRIVATE KEY----- diff --git a/src/test/resources/jwt/sign.pub.pem b/src/test/resources/jwt/sign.pub.pem new file mode 100644 index 0000000..81b5446 --- /dev/null +++ b/src/test/resources/jwt/sign.pub.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1EkXLkgVatkp4gRZi7Vz +c6fxCRV68WgI3gE8qZcU2BQLLuuhHl9mI08VR6xy/MxCsmxcMrT7KlkQ5TGQ6n49 +HshaUFhHu9jn3Cx7ykNsqEZc3oSkLt4AV5vH/TT4bYh2JO9rOeF6ZRiUwPHMkgdf +7iNVRUQLW/jXbHeY5CiQcGiVpiCY92tg/Xb66mRlN8kI9TJkUXbVNP4wHrpIsJla +Ndl4lSh0GVMjk0LmjKMkUPR9689KNZ21kNewfq8bjX/muXQ2zoS+h2hefYmt7No5 +QQIlsJLfezsFDCCytNQmxg1zG54Sn/URwd8OsFJ5ZWgKSnWzy5bZng9VMQGLNG1O +bwIDAQAB +-----END PUBLIC KEY----- diff --git a/src/test/resources/testcontainers.properties b/src/test/resources/testcontainers.properties new file mode 100644 index 0000000..007738a --- /dev/null +++ b/src/test/resources/testcontainers.properties @@ -0,0 +1,2 @@ + +#ryuk.container.image=private.docker.registry.com/testcontainers/ryuk:0.11.0