From 8ffa35755b85a64af8dafaf7d678e30e107ec2a3 Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Thu, 14 May 2026 23:58:53 -0400 Subject: [PATCH 01/12] Upgrade Keycloak to 26.6 and Postgres to 16 Keycloak 22 is EOL; 26.6 brings hostname v2 with backchannel-dynamic support. Postgres 13 is EOL and unsupported by Keycloak 26. docker-compose.full.yml also gets hostname v2 config and parameterized ports so the browser-facing issuer URL stays in sync. --- docker-compose.full.yml | 16 +++++++++------- docker-compose.yml | 12 ++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 330201d..1e92f6e 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -1,6 +1,6 @@ services: db: - image: postgres:13 + image: postgres:16 environment: POSTGRES_USER: root POSTGRES_PASSWORD: retroapi @@ -11,12 +11,14 @@ services: - postgres_data:/var/lib/postgresql/data auth-server: - image: quay.io/keycloak/keycloak:22.0.0 + image: quay.io/keycloak/keycloak:26.6.0 environment: - KEYCLOAK_ADMIN: 'admin' - KEYCLOAK_ADMIN_PASSWORD: 'admin' + KC_BOOTSTRAP_ADMIN_USERNAME: 'admin' + KC_BOOTSTRAP_ADMIN_PASSWORD: 'admin' + KC_HOSTNAME: "http://localhost:${KEYCLOAK_PORT:-8010}" + KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" ports: - - '8010:8080' + - "${KEYCLOAK_PORT:-8010}:8080" volumes: - ./keycloak-realm-data:/opt/keycloak/data/import command: start-dev --import-realm @@ -34,9 +36,9 @@ services: - '25672:25672' api: - image: ghcr.io/lowbudgetman/retro-api:latest + image: ${RETRO_API_IMAGE:-ghcr.io/lowbudgetman/retro-api:latest} ports: - - "8080:8080" + - "${API_PORT:-8080}:8080" environment: SPRING_PROFILES_ACTIVE: docker depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 9abc7da..4a8fa04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: db: - image: postgres:13 + image: postgres:16 environment: POSTGRES_USER: root POSTGRES_PASSWORD: retroapi @@ -8,17 +8,17 @@ services: ports: - "5432:5432" auth-server: - image: quay.io/keycloak/keycloak:22.0.0 + image: quay.io/keycloak/keycloak:26.6.0 environment: - KEYCLOAK_ADMIN: 'admin' - KEYCLOAK_ADMIN_PASSWORD: 'admin' + KC_BOOTSTRAP_ADMIN_USERNAME: 'admin' + KC_BOOTSTRAP_ADMIN_PASSWORD: 'admin' ports: - '8010:8080' volumes: - ./keycloak-realm-data:/opt/keycloak/data/import command: start-dev --import-realm rabbitmq: - build: + build: context: ./ dockerfile: RabbitMqDockerfile ports: @@ -27,4 +27,4 @@ services: - '61613:61613' - '1883:1883' - '15692:15692' - - '25672:25672' \ No newline at end of file + - '25672:25672' From 5df0e787b1dfc977307c2c93224f7f23a24058b2 Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Fri, 15 May 2026 00:53:46 -0400 Subject: [PATCH 02/12] Add JWT issuer URL rewrite for Docker networking UniversalJwtDecoder extracts the issuer from tokens and fetches JWKS from that URL. In Docker, the browser-facing issuer (localhost:8010) is unreachable from the API container. The new issuer-overrides config maps external base URLs to internal Docker hostnames for JWKS fetching while preserving the original issuer for token validation. --- .../jwt/AllTypeJwtDecoderFactory.java | 85 +++++++------------ .../jwt/JwtIssuerOverridesConfig.java | 14 +++ src/main/resources/application-docker.yml | 10 ++- .../jwt/AllTypeJwtDecoderFactoryTest.java | 44 ++++++++++ 4 files changed, 95 insertions(+), 58 deletions(-) create mode 100644 src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java create mode 100644 src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java diff --git a/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java b/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java index 2d05171..0ea817e 100644 --- a/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java +++ b/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java @@ -12,93 +12,66 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -/** - * Factory for creating JWT decoders that can handle multiple OAuth providers - * with different JWT type configurations. - * - * This implementation supports common JWT types from various providers: - * - Standard JWT tokens ("JWT") - * - Auth0 access tokens ("at+jwt") - * - Microsoft tokens ("at+jwt", "jwt") - * - Generic OAuth2 tokens - * - And tokens with missing/empty typ headers - */ @Component public class AllTypeJwtDecoderFactory { - - // Cache decoders by issuer URI for performance + private final Map decoderCache = new ConcurrentHashMap<>(); - - // Most common JWT types across different OAuth providers + private final JwtIssuerOverridesConfig overridesConfig; + private static final Set ALLOWED_JWT_TYPES = new HashSet<>(Arrays.asList( JOSEObjectType.JWT, - new JOSEObjectType("at+jwt"), // Auth0, Microsoft - new JOSEObjectType("id+token"), // Microsoft Identity - new JOSEObjectType("access_token"), // Some OAuth2 implementations - new JOSEObjectType("RefreshToken"), // Some OAuth2 implementations - new JOSEObjectType("OpenID Connect"), // OIDC tokens - new JOSEObjectType("application/jwt"), // Some JSON Web Token implementations - new JOSEObjectType("application/at+jwt"), // Microsoft Identity - new JOSEObjectType("application/oauth-jwt") // General OAuth JWT + new JOSEObjectType("at+jwt"), + new JOSEObjectType("id+token"), + new JOSEObjectType("access_token"), + new JOSEObjectType("RefreshToken"), + new JOSEObjectType("OpenID Connect"), + new JOSEObjectType("application/jwt"), + new JOSEObjectType("application/at+jwt"), + new JOSEObjectType("application/oauth-jwt") )); - /** - * Creates a JWT decoder for the specified issuer URI with enhanced type handling. - * - * @param issuerUri The issuer URI for the JWT tokens - * @return A configured JWT decoder that accepts multiple JWT types - */ + public AllTypeJwtDecoderFactory(JwtIssuerOverridesConfig overridesConfig) { + this.overridesConfig = overridesConfig; + } + public JwtDecoder createDecoder(String issuerUri) { return decoderCache.computeIfAbsent(issuerUri, this::createNewDecoder); } - /** - * Creates a new JWT decoder with custom type verifier for multi-provider compatibility. - * - * @param issuerUri The issuer URI - * @return A JWT decoder configured for multiple JWT types - */ + public String resolveIssuerUrl(String issuerUri) { + for (var entry : overridesConfig.issuerOverrides().entrySet()) { + if (issuerUri.startsWith(entry.getKey())) { + return issuerUri.replace(entry.getKey(), entry.getValue()); + } + } + return issuerUri; + } + private JwtDecoder createNewDecoder(String issuerUri) { + String fetchUrl = resolveIssuerUrl(issuerUri); try { - // Create decoder with JWK Set resolution - - return NimbusJwtDecoder.withIssuerLocation(issuerUri) + return NimbusJwtDecoder.withIssuerLocation(fetchUrl) .jwtProcessorCustomizer(customizer -> { - // Configure type verifier to accept multiple JWT types customizer.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(ALLOWED_JWT_TYPES)); }) .build(); - } catch (Exception e) { - // Fallback to a more permissive decoder if JWK Set configuration fails - return createFallbackDecoder(issuerUri); + return createFallbackDecoder(fetchUrl); } } - /** - * Creates a fallback decoder that is very permissive with JWT types. - * This is used when the primary decoder configuration fails. - * - * @param issuerUri The issuer URI - * @return A permissive JWT decoder - */ private JwtDecoder createFallbackDecoder(String issuerUri) { return NimbusJwtDecoder.withIssuerLocation(issuerUri) .jwtProcessorCustomizer(customizer -> { - // Allow any JWT type by creating a very permissive set Set permissiveTypes = new HashSet<>(); - // Add empty type to allow tokens without 'typ' header permissiveTypes.add(new JOSEObjectType("")); - permissiveTypes.add(JOSEObjectType.JWT); // Standard JWT - permissiveTypes.add(new JOSEObjectType("at+jwt")); // Auth0/Microsoft + permissiveTypes.add(JOSEObjectType.JWT); + permissiveTypes.add(new JOSEObjectType("at+jwt")); customizer.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(permissiveTypes)); }) .build(); } - /** - * Clears the decoder cache. Useful for testing or when configuration changes. - */ public void clearCache() { decoderCache.clear(); } diff --git a/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java b/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java new file mode 100644 index 0000000..ad65eed --- /dev/null +++ b/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java @@ -0,0 +1,14 @@ +package io.nickreuter.retroapi.configuration.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +@ConfigurationProperties("jwt") +public record JwtIssuerOverridesConfig(Map issuerOverrides) { + public JwtIssuerOverridesConfig { + if (issuerOverrides == null) { + issuerOverrides = Map.of(); + } + } +} diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index bc97093..137856d 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -31,8 +31,11 @@ spring: issuer-uri: http://auth-server:8080/realms/myrealm web: authentication: - authority: http://auth-server:8080/realms/myrealm + authority: http://localhost:${KEYCLOAK_PORT:8010}/realms/myrealm clientId: retroquest-web + cors: + allowed-origins: + - http://localhost:${UI_PORT:3000} broker: relay: relay-host: rabbitmq @@ -40,4 +43,7 @@ broker: relay-username: guest relay-password: guest websocket: - base-url: ws://localhost:8080 \ No newline at end of file + base-url: ws://localhost:${API_PORT:8080} +jwt: + issuer-overrides: + "http://localhost:${KEYCLOAK_PORT:8010}": "http://auth-server:8080" diff --git a/src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java b/src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java new file mode 100644 index 0000000..96a4d16 --- /dev/null +++ b/src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java @@ -0,0 +1,44 @@ +package io.nickreuter.retroapi.configuration.jwt; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AllTypeJwtDecoderFactoryTest { + + @Test + void resolveIssuerUrl_WithMatchingOverride_RewritesBaseUrl() { + var overrides = new JwtIssuerOverridesConfig(Map.of( + "http://localhost:8010", "http://auth-server:8080" + )); + var factory = new AllTypeJwtDecoderFactory(overrides); + + var resolved = factory.resolveIssuerUrl("http://localhost:8010/realms/myrealm"); + + assertThat(resolved).isEqualTo("http://auth-server:8080/realms/myrealm"); + } + + @Test + void resolveIssuerUrl_WithNoMatchingOverride_ReturnsOriginal() { + var overrides = new JwtIssuerOverridesConfig(Map.of( + "http://localhost:8010", "http://auth-server:8080" + )); + var factory = new AllTypeJwtDecoderFactory(overrides); + + var resolved = factory.resolveIssuerUrl("http://some-other-provider.com/realms/myrealm"); + + assertThat(resolved).isEqualTo("http://some-other-provider.com/realms/myrealm"); + } + + @Test + void resolveIssuerUrl_WithEmptyOverrides_ReturnsOriginal() { + var overrides = new JwtIssuerOverridesConfig(Map.of()); + var factory = new AllTypeJwtDecoderFactory(overrides); + + var resolved = factory.resolveIssuerUrl("http://localhost:8010/realms/myrealm"); + + assertThat(resolved).isEqualTo("http://localhost:8010/realms/myrealm"); + } +} From d01e864cea3fde175e6b79078a0a0572dfaa490f Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Fri, 15 May 2026 09:36:14 -0400 Subject: [PATCH 03/12] Add native image support to bootBuildImage Pass -PbuildNative to produce a GraalVM native image via buildpacks. Add build and launch cache volumes for faster repeat builds. --- build.gradle | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f70f222..9db63a8 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.7' id 'io.spring.dependency-management' version '1.1.7' -// id 'org.graalvm.buildtools.native' version '0.11.3' + id 'org.graalvm.buildtools.native' version '0.11.3' } group = 'io.nickreuter' @@ -48,4 +48,13 @@ tasks.named('test') { bootBuildImage { imageName = 'retro-api' + if (project.hasProperty('buildNative')) { + environment = ["BP_NATIVE_IMAGE": "true"] + } + buildCache { + volume { name = 'retro-api-build-cache' } + } + launchCache { + volume { name = 'retro-api-launch-cache' } + } } From 8555bc385308396d16047e2e35ac4816369394a7 Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Fri, 15 May 2026 09:57:35 -0400 Subject: [PATCH 04/12] Add native image build job to CI Builds both JVM and native Docker images on every push and PR. Native images get -native suffix tags (e.g. beta-abc1234-native). --- .github/workflows/main.yml | 94 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 10e7345..84aef00 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -132,3 +132,97 @@ jobs: run: | rm -f ~/.gradle/caches/modules-2/modules-2.lock rm -f ~/.gradle/caches/modules-2/gc.properties + build-native-image: + runs-on: ubuntu-latest + needs: [build] + permissions: + packages: write + steps: + - uses: actions/checkout@v3 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: cache gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Set lowercase owner name + run: echo "OWNER_LC=${OWNER,,}" >> "${GITHUB_ENV}" + env: + OWNER: ${{ github.repository_owner }} + - name: Compute CalVer tag + if: github.event_name == 'push' + id: calver + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TODAY=$(date -u +%Y.%m.%d) + + TAGS=$(gh api /users/${{ env.OWNER_LC }}/packages/container/retro-api/versions \ + --paginate --jq '.[].metadata.container.tags[]' 2>/dev/null || echo "") + + TODAYS_TAGS=$(echo "$TAGS" | grep "^${TODAY}" || true) + + if [ -z "$TODAYS_TAGS" ]; then + echo "version=${TODAY}" >> "$GITHUB_OUTPUT" + else + MAX_SEQ=0 + for tag in $TODAYS_TAGS; do + if [ "$tag" = "$TODAY" ]; then + MAX_SEQ=$((MAX_SEQ > 0 ? MAX_SEQ : 0)) + else + SEQ=$(echo "$tag" | sed "s/^${TODAY}\.//") + if [ "$SEQ" -gt "$MAX_SEQ" ] 2>/dev/null; then + MAX_SEQ=$SEQ + fi + fi + done + NEXT_SEQ=$((MAX_SEQ + 1)) + echo "version=${TODAY}.${NEXT_SEQ}" >> "$GITHUB_OUTPUT" + fi + + - name: Compute short SHA + id: sha + run: echo "short=$(echo '${{ github.event.pull_request.head.sha || github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ env.OWNER_LC }}/retro-api + tags: | + type=raw,value=${{ steps.calver.outputs.version || 'unused' }}-native,enable=${{ github.event_name == 'push' }} + type=raw,value=native,enable=${{ github.event_name == 'push' }} + type=raw,value=beta-${{ steps.sha.outputs.short }}-native,enable=${{ github.event_name == 'pull_request' }} + + - name: Build native application image + run: | + PRIMARY_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1) + ./gradlew bootBuildImage -PbuildNative --imageName "$PRIMARY_TAG" + + - name: Tag and push image + run: | + PRIMARY_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1) + echo "${{ steps.meta.outputs.tags }}" | while read -r TAG; do + if [ "$TAG" != "$PRIMARY_TAG" ]; then + docker tag "$PRIMARY_TAG" "$TAG" + fi + docker push "$TAG" + done + - name: cleanup gradle cache + run: | + rm -f ~/.gradle/caches/modules-2/modules-2.lock + rm -f ~/.gradle/caches/modules-2/gc.properties From cd2aa56cfc56ece614c6e60e39a877306dd4ed83 Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Fri, 15 May 2026 10:06:41 -0400 Subject: [PATCH 05/12] Add resources for GraalVM --- .../io.nickreuter/retro-api/resource-config.json | 15 +++++++++++++++ src/main/resources/application-native.yml | 3 +++ 2 files changed, 18 insertions(+) create mode 100644 src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json create mode 100644 src/main/resources/application-native.yml diff --git a/src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json b/src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json new file mode 100644 index 0000000..d21a663 --- /dev/null +++ b/src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json @@ -0,0 +1,15 @@ +{ + "resources": { + "includes": [ + { + "pattern": "db/.*" + }, + { + "pattern": "www\\.liquibase\\.org/.*" + }, + { + "pattern": "liquibase/.*" + } + ] + } +} diff --git a/src/main/resources/application-native.yml b/src/main/resources/application-native.yml new file mode 100644 index 0000000..95219f7 --- /dev/null +++ b/src/main/resources/application-native.yml @@ -0,0 +1,3 @@ +spring: + liquibase: + clear-checksums: true From aca76c69cd2c5645425dd92c924e323fc850288a Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Sat, 16 May 2026 17:57:10 -0400 Subject: [PATCH 06/12] Fix JWT issuer resolution and native image configuration - Use withJwkSetUri when issuer override is active to avoid issuer mismatch between Docker hostnames (token says localhost, API resolves via internal hostname) - Change JwtIssuerOverridesConfig from Map to List to fix Spring Boot property binding with URL keys - Replace manual resource-config.json with RuntimeHintsRegistrar for Liquibase native hints (avoids conflict with AOT-generated file) - Make GraalVM plugin conditional (apply false + hasProperty check) so JVM builds use JRE instead of NIK --- build.gradle | 11 +++++----- .../configuration/LiquibaseNativeHints.java | 20 ++++++++++++++++++ .../jwt/AllTypeJwtDecoderFactory.java | 21 ++++++++++++++----- .../jwt/JwtIssuerOverridesConfig.java | 8 ++++--- .../retro-api/resource-config.json | 15 ------------- src/main/resources/application-docker.yml | 3 ++- .../jwt/AllTypeJwtDecoderFactoryTest.java | 12 +++++------ 7 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java delete mode 100644 src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json diff --git a/build.gradle b/build.gradle index 9db63a8..ef65cdc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,12 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.7' + id 'org.springframework.boot' version '3.5.8' id 'io.spring.dependency-management' version '1.1.7' - id 'org.graalvm.buildtools.native' version '0.11.3' + id 'org.graalvm.buildtools.native' version '0.11.3' apply false +} + +if (project.hasProperty('buildNative')) { + apply plugin: 'org.graalvm.buildtools.native' } group = 'io.nickreuter' @@ -48,9 +52,6 @@ tasks.named('test') { bootBuildImage { imageName = 'retro-api' - if (project.hasProperty('buildNative')) { - environment = ["BP_NATIVE_IMAGE": "true"] - } buildCache { volume { name = 'retro-api-build-cache' } } diff --git a/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java b/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java new file mode 100644 index 0000000..123f8fe --- /dev/null +++ b/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java @@ -0,0 +1,20 @@ +package io.nickreuter.retroapi.configuration; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportRuntimeHints; + +@Configuration +@ImportRuntimeHints(LiquibaseNativeHints.Registrar.class) +class LiquibaseNativeHints { + + static class Registrar implements RuntimeHintsRegistrar { + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + hints.resources().registerPattern("db/.*"); + hints.resources().registerPattern("www.liquibase.org/.*"); + hints.resources().registerPattern("liquibase/.*"); + } + } +} diff --git a/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java b/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java index 0ea817e..c06d9c4 100644 --- a/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java +++ b/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java @@ -39,9 +39,9 @@ public JwtDecoder createDecoder(String issuerUri) { } public String resolveIssuerUrl(String issuerUri) { - for (var entry : overridesConfig.issuerOverrides().entrySet()) { - if (issuerUri.startsWith(entry.getKey())) { - return issuerUri.replace(entry.getKey(), entry.getValue()); + for (var override : overridesConfig.issuerOverrides()) { + if (issuerUri.startsWith(override.from())) { + return issuerUri.replace(override.from(), override.to()); } } return issuerUri; @@ -49,14 +49,25 @@ public String resolveIssuerUrl(String issuerUri) { private JwtDecoder createNewDecoder(String issuerUri) { String fetchUrl = resolveIssuerUrl(issuerUri); + boolean overridden = !fetchUrl.equals(issuerUri); + + if (overridden) { + String jwksUri = fetchUrl + "/protocol/openid-connect/certs"; + return NimbusJwtDecoder.withJwkSetUri(jwksUri) + .jwtProcessorCustomizer(customizer -> { + customizer.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(ALLOWED_JWT_TYPES)); + }) + .build(); + } + try { - return NimbusJwtDecoder.withIssuerLocation(fetchUrl) + return NimbusJwtDecoder.withIssuerLocation(issuerUri) .jwtProcessorCustomizer(customizer -> { customizer.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(ALLOWED_JWT_TYPES)); }) .build(); } catch (Exception e) { - return createFallbackDecoder(fetchUrl); + return createFallbackDecoder(issuerUri); } } diff --git a/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java b/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java index ad65eed..4e5e6e9 100644 --- a/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java +++ b/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java @@ -2,13 +2,15 @@ import org.springframework.boot.context.properties.ConfigurationProperties; -import java.util.Map; +import java.util.List; @ConfigurationProperties("jwt") -public record JwtIssuerOverridesConfig(Map issuerOverrides) { +public record JwtIssuerOverridesConfig(List issuerOverrides) { public JwtIssuerOverridesConfig { if (issuerOverrides == null) { - issuerOverrides = Map.of(); + issuerOverrides = List.of(); } } + + public record IssuerOverride(String from, String to) {} } diff --git a/src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json b/src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json deleted file mode 100644 index d21a663..0000000 --- a/src/main/resources/META-INF/native-image/io.nickreuter/retro-api/resource-config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "resources": { - "includes": [ - { - "pattern": "db/.*" - }, - { - "pattern": "www\\.liquibase\\.org/.*" - }, - { - "pattern": "liquibase/.*" - } - ] - } -} diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 137856d..6b2e0b1 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -46,4 +46,5 @@ websocket: base-url: ws://localhost:${API_PORT:8080} jwt: issuer-overrides: - "http://localhost:${KEYCLOAK_PORT:8010}": "http://auth-server:8080" + - from: "http://localhost:8010" + to: "http://auth-server:8080" diff --git a/src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java b/src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java index 96a4d16..1fcf94e 100644 --- a/src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java +++ b/src/test/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactoryTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.Test; -import java.util.Map; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -10,8 +10,8 @@ class AllTypeJwtDecoderFactoryTest { @Test void resolveIssuerUrl_WithMatchingOverride_RewritesBaseUrl() { - var overrides = new JwtIssuerOverridesConfig(Map.of( - "http://localhost:8010", "http://auth-server:8080" + var overrides = new JwtIssuerOverridesConfig(List.of( + new JwtIssuerOverridesConfig.IssuerOverride("http://localhost:8010", "http://auth-server:8080") )); var factory = new AllTypeJwtDecoderFactory(overrides); @@ -22,8 +22,8 @@ void resolveIssuerUrl_WithMatchingOverride_RewritesBaseUrl() { @Test void resolveIssuerUrl_WithNoMatchingOverride_ReturnsOriginal() { - var overrides = new JwtIssuerOverridesConfig(Map.of( - "http://localhost:8010", "http://auth-server:8080" + var overrides = new JwtIssuerOverridesConfig(List.of( + new JwtIssuerOverridesConfig.IssuerOverride("http://localhost:8010", "http://auth-server:8080") )); var factory = new AllTypeJwtDecoderFactory(overrides); @@ -34,7 +34,7 @@ void resolveIssuerUrl_WithNoMatchingOverride_ReturnsOriginal() { @Test void resolveIssuerUrl_WithEmptyOverrides_ReturnsOriginal() { - var overrides = new JwtIssuerOverridesConfig(Map.of()); + var overrides = new JwtIssuerOverridesConfig(List.of()); var factory = new AllTypeJwtDecoderFactory(overrides); var resolved = factory.resolveIssuerUrl("http://localhost:8010/realms/myrealm"); From 613503a2bc8f7ff07b8178dd875af78ef0a92896 Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Sun, 17 May 2026 15:36:28 -0400 Subject: [PATCH 07/12] Automatically provide Jackson native hints --- .../configuration/JacksonNativeHints.java | 48 +++++++++++++++++++ .../configuration/LiquibaseNativeHints.java | 29 +++++++++-- src/main/resources/db/changelog.xml | 6 +-- 3 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 src/main/java/io/nickreuter/retroapi/configuration/JacksonNativeHints.java diff --git a/src/main/java/io/nickreuter/retroapi/configuration/JacksonNativeHints.java b/src/main/java/io/nickreuter/retroapi/configuration/JacksonNativeHints.java new file mode 100644 index 0000000..130d24e --- /dev/null +++ b/src/main/java/io/nickreuter/retroapi/configuration/JacksonNativeHints.java @@ -0,0 +1,48 @@ +package io.nickreuter.retroapi.configuration; + +import io.nickreuter.retroapi.notification.EventType; +import io.nickreuter.retroapi.notification.event.BaseEvent; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportRuntimeHints; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.core.type.filter.AssignableTypeFilter; + +import jakarta.persistence.Entity; + +@Configuration +@ImportRuntimeHints(JacksonNativeHints.Registrar.class) +class JacksonNativeHints { + + private static final String BASE_PACKAGE = "io.nickreuter.retroapi"; + private static final MemberCategory[] JACKSON_CATEGORIES = { + MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.DECLARED_FIELDS + }; + + static class Registrar implements RuntimeHintsRegistrar { + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + var scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AssignableTypeFilter(BaseEvent.class)); + scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); + + for (BeanDefinition bd : scanner.findCandidateComponents(BASE_PACKAGE)) { + try { + hints.reflection().registerType( + Class.forName(bd.getBeanClassName()), + JACKSON_CATEGORIES + ); + } catch (ClassNotFoundException ignored) { + } + } + + hints.reflection().registerType(EventType.class, JACKSON_CATEGORIES); + } + } +} diff --git a/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java b/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java index 123f8fe..8ce2d8d 100644 --- a/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java +++ b/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java @@ -1,20 +1,43 @@ package io.nickreuter.retroapi.configuration; +import liquibase.datatype.LiquibaseDataType; +import liquibase.snapshot.jvm.JdbcSnapshotGenerator; +import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportRuntimeHints; +import org.springframework.core.type.filter.AssignableTypeFilter; @Configuration @ImportRuntimeHints(LiquibaseNativeHints.Registrar.class) class LiquibaseNativeHints { + private static final MemberCategory[] CATEGORIES = { + MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.DECLARED_FIELDS + }; + static class Registrar implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, ClassLoader classLoader) { - hints.resources().registerPattern("db/.*"); - hints.resources().registerPattern("www.liquibase.org/.*"); - hints.resources().registerPattern("liquibase/.*"); + hints.resources().registerPattern("db/*"); + hints.resources().registerPattern("www.liquibase.org/*"); + hints.resources().registerPattern("liquibase/*"); + + var scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AssignableTypeFilter(JdbcSnapshotGenerator.class)); + scanner.addIncludeFilter(new AssignableTypeFilter(LiquibaseDataType.class)); + + for (BeanDefinition bd : scanner.findCandidateComponents("liquibase")) { + try { + hints.reflection().registerType(Class.forName(bd.getBeanClassName()), CATEGORIES); + } catch (ClassNotFoundException ignored) { + } + } } } } diff --git a/src/main/resources/db/changelog.xml b/src/main/resources/db/changelog.xml index 5cb661f..f05e50b 100644 --- a/src/main/resources/db/changelog.xml +++ b/src/main/resources/db/changelog.xml @@ -162,11 +162,7 @@ - - - - - + ANY From 19ac8bc03eaa000caedf436071db48fb5132e762 Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Sun, 17 May 2026 23:24:26 -0400 Subject: [PATCH 08/12] Add KeycloakDockerfile for publishing pre-configured Keycloak image --- KeycloakDockerfile | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 KeycloakDockerfile diff --git a/KeycloakDockerfile b/KeycloakDockerfile new file mode 100644 index 0000000..e0360dd --- /dev/null +++ b/KeycloakDockerfile @@ -0,0 +1,2 @@ +FROM quay.io/keycloak/keycloak:26.6.0 +COPY keycloak-realm-data/realm-export.json /opt/keycloak/data/import/ From 7fdc581dd225d2722b8e9059bba1beab6c10e84c Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Sun, 17 May 2026 23:25:47 -0400 Subject: [PATCH 09/12] Publish infra images to GHCR and expose build-image tag as output --- .github/workflows/main.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 84aef00..d623814 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,11 +34,44 @@ jobs: run: | rm -f ~/.gradle/caches/modules-2/modules-2.lock rm -f ~/.gradle/caches/modules-2/gc.properties + build-infra-images: + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set lowercase owner name + run: echo "OWNER_LC=${OWNER,,}" >> "${GITHUB_ENV}" + env: + OWNER: ${{ github.repository_owner }} + - name: Build and push RabbitMQ image + uses: docker/build-push-action@v6 + with: + context: . + file: RabbitMqDockerfile + push: true + tags: ghcr.io/${{ env.OWNER_LC }}/retro-rabbitmq:latest + - name: Build and push Keycloak image + uses: docker/build-push-action@v6 + with: + context: . + file: KeycloakDockerfile + push: true + tags: ghcr.io/${{ env.OWNER_LC }}/retro-keycloak:latest build-image: runs-on: ubuntu-latest needs: [build] permissions: packages: write + outputs: + image-tag: ${{ steps.primary-tag.outputs.tag }} steps: - uses: actions/checkout@v3 - name: Set up JDK @@ -114,6 +147,10 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'push' }} type=raw,value=beta-${{ steps.sha.outputs.short }},enable=${{ github.event_name == 'pull_request' }} + - name: Set primary tag output + id: primary-tag + run: echo "tag=$(echo "${{ steps.meta.outputs.tags }}" | head -1)" >> "$GITHUB_OUTPUT" + - name: Build application image run: | PRIMARY_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1) From 7161802af77b1f3024735f1113a9f5dc76bebccc Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Sun, 17 May 2026 23:51:42 -0400 Subject: [PATCH 10/12] Add E2E test job to CI pipeline --- .github/workflows/main.yml | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d623814..15d7e3d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -263,3 +263,44 @@ jobs: run: | rm -f ~/.gradle/caches/modules-2/modules-2.lock rm -f ~/.gradle/caches/modules-2/gc.properties + e2e: + runs-on: ubuntu-latest + needs: [build-image] + steps: + - name: Checkout E2E repo + uses: actions/checkout@v4 + with: + repository: LowBudgetMan/retro-tests + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Start E2E stack + run: | + RETRO_API_IMAGE=${{ needs.build-image.outputs.image-tag }} \ + docker compose up -d --wait --wait-timeout 120 + + - name: Run E2E tests + run: | + docker run --network host \ + -v ${{ github.workspace }}/playwright-report:/tests/playwright-report \ + ghcr.io/lowbudgetman/retro-e2e:latest + + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-report + path: playwright-report/ + + - name: Dump logs on failure + if: failure() + run: docker compose logs + + - name: Tear down + if: always() + run: docker compose down -v From 4ba84099c9bc91485dc4669a82733d297a9a86ec Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Sun, 17 May 2026 23:53:33 -0400 Subject: [PATCH 11/12] Gate infra image builds behind application build and fix checkout version --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 15d7e3d..a94f9f4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -37,10 +37,11 @@ jobs: build-infra-images: if: github.event_name == 'push' runs-on: ubuntu-latest + needs: [build] permissions: packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 - name: Login to GHCR uses: docker/login-action@v3 with: From b128f41a5fd00d993bae365158fd7094347c5684 Mon Sep 17 00:00:00 2001 From: Nick Reuter Date: Mon, 18 May 2026 10:14:23 -0400 Subject: [PATCH 12/12] Remove build-infra-images CI job, images pushed manually --- .github/workflows/main.yml | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a94f9f4..29962f2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,38 +34,6 @@ jobs: run: | rm -f ~/.gradle/caches/modules-2/modules-2.lock rm -f ~/.gradle/caches/modules-2/gc.properties - build-infra-images: - if: github.event_name == 'push' - runs-on: ubuntu-latest - needs: [build] - permissions: - packages: write - steps: - - uses: actions/checkout@v3 - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Set lowercase owner name - run: echo "OWNER_LC=${OWNER,,}" >> "${GITHUB_ENV}" - env: - OWNER: ${{ github.repository_owner }} - - name: Build and push RabbitMQ image - uses: docker/build-push-action@v6 - with: - context: . - file: RabbitMqDockerfile - push: true - tags: ghcr.io/${{ env.OWNER_LC }}/retro-rabbitmq:latest - - name: Build and push Keycloak image - uses: docker/build-push-action@v6 - with: - context: . - file: KeycloakDockerfile - push: true - tags: ghcr.io/${{ env.OWNER_LC }}/retro-keycloak:latest build-image: runs-on: ubuntu-latest needs: [build]