diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 10e7345..29962f2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,6 +39,8 @@ jobs: needs: [build] permissions: packages: write + outputs: + image-tag: ${{ steps.primary-tag.outputs.tag }} steps: - uses: actions/checkout@v3 - name: Set up JDK @@ -114,6 +116,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) @@ -132,3 +138,138 @@ 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 + 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 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/ diff --git a/build.gradle b/build.gradle index f70f222..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,4 +52,10 @@ tasks.named('test') { bootBuildImage { imageName = 'retro-api' + buildCache { + volume { name = 'retro-api-build-cache' } + } + launchCache { + volume { name = 'retro-api-launch-cache' } + } } 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' 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 new file mode 100644 index 0000000..8ce2d8d --- /dev/null +++ b/src/main/java/io/nickreuter/retroapi/configuration/LiquibaseNativeHints.java @@ -0,0 +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/*"); + + 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/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java b/src/main/java/io/nickreuter/retroapi/configuration/jwt/AllTypeJwtDecoderFactory.java index 2d05171..c06d9c4 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,77 @@ 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 override : overridesConfig.issuerOverrides()) { + if (issuerUri.startsWith(override.from())) { + return issuerUri.replace(override.from(), override.to()); + } + } + return issuerUri; + } + private JwtDecoder createNewDecoder(String issuerUri) { - try { - // Create decoder with JWK Set resolution + 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(issuerUri) .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); } } - /** - * 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..4e5e6e9 --- /dev/null +++ b/src/main/java/io/nickreuter/retroapi/configuration/jwt/JwtIssuerOverridesConfig.java @@ -0,0 +1,16 @@ +package io.nickreuter.retroapi.configuration.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties("jwt") +public record JwtIssuerOverridesConfig(List issuerOverrides) { + public JwtIssuerOverridesConfig { + if (issuerOverrides == null) { + issuerOverrides = List.of(); + } + } + + public record IssuerOverride(String from, String to) {} +} diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index bc97093..6b2e0b1 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,8 @@ 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: + - from: "http://localhost:8010" + to: "http://auth-server:8080" 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 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 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..1fcf94e --- /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.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AllTypeJwtDecoderFactoryTest { + + @Test + void resolveIssuerUrl_WithMatchingOverride_RewritesBaseUrl() { + var overrides = new JwtIssuerOverridesConfig(List.of( + new JwtIssuerOverridesConfig.IssuerOverride("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(List.of( + new JwtIssuerOverridesConfig.IssuerOverride("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(List.of()); + var factory = new AllTypeJwtDecoderFactory(overrides); + + var resolved = factory.resolveIssuerUrl("http://localhost:8010/realms/myrealm"); + + assertThat(resolved).isEqualTo("http://localhost:8010/realms/myrealm"); + } +}