From 6dca01a783b1aec4140606581680a6d64db526a4 Mon Sep 17 00:00:00 2001 From: LudBjork Date: Wed, 10 Jun 2026 15:10:06 +0200 Subject: [PATCH 1/7] fix(junit-tenant): make port reservation robust for parallel test JVMs Seed the scan with a per-JVM SecureRandom, advance through the range, and skip occupied ports immediately. A port this instance picked itself is now tracked, so when another process steals it the reservation rescans (new rescan() method) instead of failing every subsequent start with "Preconfigured port is not free". Externally pinned ports still fail fast. reserved() also closes any socket a concurrent caller reserved while a thread waited, and start() is a no-op while a reservation is already held. --- .../entur/auth/junit/jwt/PortReservation.java | 84 +++++++++++----- .../auth/junit/jwt/PortReservationTest.java | 98 +++++++++++++++++++ 2 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java diff --git a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/jwt/PortReservation.java b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/jwt/PortReservation.java index 7df6b34..664c72e 100644 --- a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/jwt/PortReservation.java +++ b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/jwt/PortReservation.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.net.InetAddress; import java.net.ServerSocket; +import java.security.SecureRandom; import javax.net.ServerSocketFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,12 +17,17 @@ public class PortReservation { private static final int PORT_RANGE_START = 10000; private static final int PORT_RANGE_END = PORT_RANGE_MAX; + // seeded independently per JVM, so parallel test forks do not probe the same port sequence + private static final SecureRandom RANDOM = new SecureRandom(); + private final int portRangeStart; private final int portRangeEnd; private final String propertyName; private volatile int port = -1; private ServerSocket serverSocket; + // true when this instance picked the port itself; false when it was pinned via the property + private boolean portChosenByScan = false; public PortReservation(String portNames) { this(PORT_RANGE_START, PORT_RANGE_END, portNames); @@ -53,29 +59,62 @@ public synchronized boolean start() { throw new IllegalArgumentException( "Port range end must not be larger than " + PORT_RANGE_MAX + "."); } + if (serverSocket != null) { + return true; // already holding a reservation + } // check if the property already exists, if so it must be free String property = System.getProperty(propertyName); if (property != null) { - if (reserve(Integer.parseInt(property), true)) { + int preconfiguredPort = Integer.parseInt(property); + // distinguish a port this instance picked earlier from one pinned externally + boolean ownPort = portChosenByScan && preconfiguredPort == this.port; + if (reserve(preconfiguredPort, true)) { + portChosenByScan = ownPort; log.warn("Reserved previously configured port " + property); return true; - } else { + } + if (!ownPort) { throw new IllegalArgumentException("Preconfigured port " + property + " is not free"); } + // the port this reservation picked earlier has been taken by another process; + // abandon it and scan for a new one + System.clearProperty(propertyName); + this.port = -1; } - // systematically try ports in range - // starting at 'random' offset - int portRange = portRangeEnd - portRangeStart + 1; + scan(); + return true; + } - int offset = - (propertyName.hashCode() + (int) System.currentTimeMillis()) - % portRange; // more or less random per port name + /** + * Abandon the current reservation and reserve a fresh port from the range. Intended for recovery + * when another process binds the port between {@link #stop()} and the consumer's own bind + * attempt. + * + * @throws IllegalStateException if the port was preconfigured externally and therefore cannot be + * changed + */ + public synchronized void rescan() { + if (!portChosenByScan && System.getProperty(propertyName) != null) { + throw new IllegalStateException( + "Preconfigured port " + System.getProperty(propertyName) + " cannot be rescanned"); + } + stop(); + System.clearProperty(propertyName); + this.port = -1; + scan(); + } + + private void scan() { + // systematically try ports in range, starting at a random offset + int portRange = portRangeEnd - portRangeStart + 1; + int offset = RANDOM.nextInt(portRange); for (int i = 0; i < portRange; i++) { - int candidatePort = portRangeStart + (offset + portRange) % portRange; + int candidatePort = portRangeStart + (offset + i) % portRange; if (reserve(candidatePort, false)) { + portChosenByScan = true; log.warn("Reserved newly configured port " + candidatePort); - return true; + return; } } throw new IllegalArgumentException("Unable to reserve free port"); @@ -95,35 +134,34 @@ public synchronized void stop() { } private boolean reserve(int candidatePort, boolean retry) { - // Retry on failure, 10 times. - for (int i = 0; i < 10; i++) { + // While scanning, an occupied port is skipped immediately so the next candidate can be + // tried; only a specific (preconfigured or previously reserved) port is worth waiting for. + int attempts = retry ? 10 : 1; + for (int i = 0; i < attempts; i++) { if (i > 0) { log.debug("Waiting 1 second before try reserve port {}.", candidatePort); try { - wait(1000); // Wait 1 second + // Object.wait releases the monitor while sleeping, so other threads are not + // blocked for the full retry duration; a stray notify only shortens the wait + wait(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } - try { - ServerSocket result = capturePort(candidatePort); - if (result != null) { - reserved(candidatePort, result); - return true; - } - } catch (Exception e) { - if (!retry) { - return false; - } + ServerSocket result = capturePort(candidatePort); + if (result != null) { + reserved(candidatePort, result); + return true; } } return false; } private void reserved(int port, ServerSocket serverSocket) { + stop(); // release any socket a concurrent caller reserved while this thread waited this.port = port; this.serverSocket = serverSocket; diff --git a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java new file mode 100644 index 0000000..4afe647 --- /dev/null +++ b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java @@ -0,0 +1,98 @@ +package org.entur.auth.junit.jwt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import org.junit.jupiter.api.Test; + +class PortReservationTest { + + @Test + void scanSkipsOccupiedPortsAndAdvancesToNextCandidate() throws IOException { + String propertyName = "PortReservationTest.scan"; + int base = findConsecutiveFreePorts(3); + try (ServerSocket occupiedFirst = bind(base); + ServerSocket occupiedSecond = bind(base + 1)) { + PortReservation reservation = new PortReservation(base, base + 2, propertyName); + try { + assertTrue(reservation.start()); + assertEquals(base + 2, reservation.getPort()); + assertEquals(Integer.toString(base + 2), System.getProperty(propertyName)); + } finally { + reservation.stop(); + System.clearProperty(propertyName); + } + } + } + + @Test + void rescanReservesNewPortWhenPreviousPortIsStolen() throws IOException { + String propertyName = "PortReservationTest.rescan"; + PortReservation reservation = new PortReservation(propertyName); + try { + reservation.start(); + int stolenPort = reservation.getPort(); + + reservation.stop(); + try (ServerSocket thief = bind(stolenPort)) { + reservation.rescan(); + + assertTrue(reservation.getPort() > 0); + assertNotEquals(stolenPort, reservation.getPort()); + assertEquals(Integer.toString(reservation.getPort()), System.getProperty(propertyName)); + } + } finally { + reservation.stop(); + System.clearProperty(propertyName); + } + } + + @Test + void externallyPreconfiguredPortIsReusedAndCannotBeRescanned() throws IOException { + String propertyName = "PortReservationTest.preconfigured"; + int freePort; + try (ServerSocket probe = bind(0)) { + freePort = probe.getLocalPort(); + } + System.setProperty(propertyName, Integer.toString(freePort)); + PortReservation reservation = new PortReservation(propertyName); + try { + assertTrue(reservation.start()); + assertEquals(freePort, reservation.getPort()); + assertThrows(IllegalStateException.class, reservation::rescan); + } finally { + reservation.stop(); + System.clearProperty(propertyName); + } + } + + private static ServerSocket bind(int port) throws IOException { + return new ServerSocket(port, 1, InetAddress.getByName("localhost")); + } + + private static int findConsecutiveFreePorts(int count) throws IOException { + for (int base = 24000; base < 64000; base += count) { + ServerSocket[] sockets = new ServerSocket[count]; + try { + for (int i = 0; i < count; i++) { + sockets[i] = bind(base + i); + } + return base; + } catch (IOException e) { + // try the next block of ports + } finally { + for (ServerSocket socket : sockets) { + if (socket != null) { + socket.close(); + } + } + } + } + throw new IOException("Unable to find " + count + " consecutive free ports"); + } +} From 6e84e2be90c7fd821a8421ac742ab27381c1e115 Mon Sep 17 00:00:00 2001 From: LudBjork Date: Wed, 10 Jun 2026 15:18:11 +0200 Subject: [PATCH 2/7] fix(junit-tenant): retry WireMock startup on a fresh port when bind fails Rescan for port using SafeRandom from PortReservation, ensuring that parallell JVMs don't collide --- .../tenant/TenantAnnotationTokenFactory.java | 161 +++++++++++------- 1 file changed, 100 insertions(+), 61 deletions(-) diff --git a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java index 867beab..6ab98d3 100644 --- a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java +++ b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java @@ -1,6 +1,7 @@ package org.entur.auth.junit.tenant; import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.common.FatalStartupException; import java.lang.annotation.Annotation; import java.time.Instant; import java.time.ZonedDateTime; @@ -42,6 +43,8 @@ */ @Slf4j public class TenantAnnotationTokenFactory implements AutoCloseable { + private static final int MAX_BIND_ATTEMPTS = 5; + private final Provider provider; private final PortReservation portReservation; private JwtTokenFactory jwtTokenFactory; @@ -60,8 +63,44 @@ public TenantAnnotationTokenFactory( this.portReservation = portReservation; /* Ensure the WireMock server is running, reserving the port if needed. */ - portReservation.stop(); - setServer(new WireMockAuthenticationServer(portReservation.getPort())); + setServer(startServer(portReservation)); + } + + /** + * Start a WireMock server on the reserved port. The reservation socket is released immediately + * before binding; if another process grabs the port in that window, a fresh port is reserved and + * the bind is retried instead of failing the whole test run. + */ + private static WireMockAuthenticationServer startServer(final PortReservation portReservation) { + if (portReservation.getPort() < 0) { + portReservation.start(); + } + + FatalStartupException lastFailure = null; + for (int attempt = 1; ; attempt++) { + if (attempt > MAX_BIND_ATTEMPTS) { + throw lastFailure; + } + if (lastFailure != null) { + // the previous bind failed; pick a fresh port before retrying + try { + portReservation.rescan(); + } catch (IllegalStateException pinnedPort) { + pinnedPort.initCause(lastFailure); + throw pinnedPort; + } + } + portReservation.stop(); + try { + return new WireMockAuthenticationServer(portReservation.getPort()); + } catch (FatalStartupException e) { + lastFailure = e; + log.warn( + "Failed to bind mock server to reserved port {}, rescanning for a new port", + portReservation.getPort(), + e); + } + } } /** @@ -192,85 +231,85 @@ private static String createToken( return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(annotation.tenant()) - .subject(annotation.subject()) - .audience(annotation.audience()) - .expiresAt(Instant.now().plusNanos(annotation.expiresIn())) - .claims(claims) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(annotation.tenant()) + .subject(annotation.subject()) + .audience(annotation.audience()) + .expiresAt(Instant.now().plusNanos(annotation.expiresIn())) + .claims(claims) + .create(); } else if (tenant instanceof PartnerTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_PARTNER); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_PARTNER) - .subject(annotation.subject()) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), - EnturProvider.CLAIM_EMAIL, annotation.email(), - EnturProvider.CLAIM_EMAIL_VERIFIED, annotation.emailVerified(), - EnturProvider.CLAIM_PREFERRED_USERNAME, annotation.username(), - EnturProvider.CLAIM_PERMISSIONS, annotation.permissions())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_PARTNER) + .subject(annotation.subject()) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), + EnturProvider.CLAIM_EMAIL, annotation.email(), + EnturProvider.CLAIM_EMAIL_VERIFIED, annotation.emailVerified(), + EnturProvider.CLAIM_PREFERRED_USERNAME, annotation.username(), + EnturProvider.CLAIM_PERMISSIONS, annotation.permissions())) + .create(); } else if (tenant instanceof InternalTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_INTERNAL); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_INTERNAL) - .subject(annotation.clientId()) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_INTERNAL) + .subject(annotation.clientId()) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId())) + .create(); } else if (tenant instanceof TravellerTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_TRAVELLER); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_TRAVELLER) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), - EnturProvider.CLAIM_CUSTOMER_NUMBER, annotation.customerNumber())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_TRAVELLER) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), + EnturProvider.CLAIM_CUSTOMER_NUMBER, annotation.customerNumber())) + .create(); } else if (tenant instanceof PersonTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_PERSON); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_PERSON) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, - annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, - annotation.organisationId(), - EnturProvider.CLAIM_SOCIAL_SECURITY_NUMBER, - annotation.socialSecurityNumber())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_PERSON) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, + annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, + annotation.organisationId(), + EnturProvider.CLAIM_SOCIAL_SECURITY_NUMBER, + annotation.socialSecurityNumber())) + .create(); } throw new IllegalArgumentException("Unknown tenant " + tenant); } From ced5daf93bceb06ddc9c059b76b440b4debec326 Mon Sep 17 00:00:00 2001 From: LudBjork Date: Wed, 10 Jun 2026 15:20:27 +0200 Subject: [PATCH 3/7] test(junit-tenant): cover WireMock recovery when the reserved port is stolen Ensure collision of ports don't happen --- ...antAnnotationTokenFactoryPortRaceTest.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java diff --git a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java new file mode 100644 index 0000000..9bbe500 --- /dev/null +++ b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java @@ -0,0 +1,45 @@ +package org.entur.auth.junit.tenant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.net.ServerSocket; +import org.entur.auth.junit.jwt.EnturProvider; +import org.entur.auth.junit.jwt.PortReservation; +import org.junit.jupiter.api.Test; + +class TenantAnnotationTokenFactoryPortRaceTest { + + /** + * Simulates a parallel test fork stealing the reserved port in the window between the reservation + * socket being released and WireMock binding it. The factory must recover by reserving a fresh + * port instead of failing the whole run. + */ + @Test + void recoversWhenReservedPortIsStolenBeforeWireMockBinds() throws IOException { + String propertyName = "TenantAnnotationTokenFactoryPortRaceTest.port"; + PortReservation reservation = new PortReservation(propertyName); + try { + reservation.start(); + int stolenPort = reservation.getPort(); + + reservation.stop(); + // bind the wildcard address, like a WireMock server in a competing fork would + try (ServerSocket thief = new ServerSocket(stolenPort)) { + try (TenantAnnotationTokenFactory factory = + new TenantAnnotationTokenFactory(new EnturProvider(), reservation)) { + assertNotNull(factory.getServer()); + assertNotEquals(stolenPort, factory.getServer().getPort()); + assertEquals(reservation.getPort(), factory.getServer().getPort()); + assertEquals( + Integer.toString(factory.getServer().getPort()), System.getProperty(propertyName)); + } + } + } finally { + reservation.stop(); + System.clearProperty(propertyName); + } + } +} From 1b3df7abe6584afb6756c741c928ec4076347765 Mon Sep 17 00:00:00 2001 From: LudBjork Date: Wed, 10 Jun 2026 15:27:51 +0200 Subject: [PATCH 4/7] chore(formatting): format affected files --- .../tenant/TenantAnnotationTokenFactory.java | 118 +++++++++--------- .../auth/junit/jwt/PortReservationTest.java | 2 +- ...antAnnotationTokenFactoryPortRaceTest.java | 2 +- 3 files changed, 61 insertions(+), 61 deletions(-) diff --git a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java index 6ab98d3..adb29ac 100644 --- a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java +++ b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactory.java @@ -231,85 +231,85 @@ private static String createToken( return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(annotation.tenant()) - .subject(annotation.subject()) - .audience(annotation.audience()) - .expiresAt(Instant.now().plusNanos(annotation.expiresIn())) - .claims(claims) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(annotation.tenant()) + .subject(annotation.subject()) + .audience(annotation.audience()) + .expiresAt(Instant.now().plusNanos(annotation.expiresIn())) + .claims(claims) + .create(); } else if (tenant instanceof PartnerTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_PARTNER); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_PARTNER) - .subject(annotation.subject()) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), - EnturProvider.CLAIM_EMAIL, annotation.email(), - EnturProvider.CLAIM_EMAIL_VERIFIED, annotation.emailVerified(), - EnturProvider.CLAIM_PREFERRED_USERNAME, annotation.username(), - EnturProvider.CLAIM_PERMISSIONS, annotation.permissions())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_PARTNER) + .subject(annotation.subject()) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), + EnturProvider.CLAIM_EMAIL, annotation.email(), + EnturProvider.CLAIM_EMAIL_VERIFIED, annotation.emailVerified(), + EnturProvider.CLAIM_PREFERRED_USERNAME, annotation.username(), + EnturProvider.CLAIM_PERMISSIONS, annotation.permissions())) + .create(); } else if (tenant instanceof InternalTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_INTERNAL); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_INTERNAL) - .subject(annotation.clientId()) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_INTERNAL) + .subject(annotation.clientId()) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId())) + .create(); } else if (tenant instanceof TravellerTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_TRAVELLER); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_TRAVELLER) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), - EnturProvider.CLAIM_CUSTOMER_NUMBER, annotation.customerNumber())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_TRAVELLER) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, annotation.organisationId(), + EnturProvider.CLAIM_CUSTOMER_NUMBER, annotation.customerNumber())) + .create(); } else if (tenant instanceof PersonTenant annotation) { checkTenantExists(server, jwtTokenFactory, provider, EnturProvider.TENANT_PERSON); return "Bearer " + jwtTokenFactory - .jwtTokenBuilder() - .provider(provider) - .domain(EnturProvider.TENANT_PERSON) - .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) - .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) - .claims( - Map.of( - EnturProvider.CLAIM_AZP, - annotation.clientId(), - EnturProvider.CLAIM_ORGANISATION_ID, - annotation.organisationId(), - EnturProvider.CLAIM_SOCIAL_SECURITY_NUMBER, - annotation.socialSecurityNumber())) - .create(); + .jwtTokenBuilder() + .provider(provider) + .domain(EnturProvider.TENANT_PERSON) + .audience(annotation.audience() == null ? null : new String[] {annotation.audience()}) + .expiresAt(ZonedDateTime.now().plusMinutes(annotation.expiresInMinutes()).toInstant()) + .claims( + Map.of( + EnturProvider.CLAIM_AZP, + annotation.clientId(), + EnturProvider.CLAIM_ORGANISATION_ID, + annotation.organisationId(), + EnturProvider.CLAIM_SOCIAL_SECURITY_NUMBER, + annotation.socialSecurityNumber())) + .create(); } throw new IllegalArgumentException("Unknown tenant " + tenant); } diff --git a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java index 4afe647..343e05a 100644 --- a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java +++ b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java @@ -17,7 +17,7 @@ void scanSkipsOccupiedPortsAndAdvancesToNextCandidate() throws IOException { String propertyName = "PortReservationTest.scan"; int base = findConsecutiveFreePorts(3); try (ServerSocket occupiedFirst = bind(base); - ServerSocket occupiedSecond = bind(base + 1)) { + ServerSocket occupiedSecond = bind(base + 1)) { PortReservation reservation = new PortReservation(base, base + 2, propertyName); try { assertTrue(reservation.start()); diff --git a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java index 9bbe500..51305cb 100644 --- a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java +++ b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/tenant/TenantAnnotationTokenFactoryPortRaceTest.java @@ -29,7 +29,7 @@ void recoversWhenReservedPortIsStolenBeforeWireMockBinds() throws IOException { // bind the wildcard address, like a WireMock server in a competing fork would try (ServerSocket thief = new ServerSocket(stolenPort)) { try (TenantAnnotationTokenFactory factory = - new TenantAnnotationTokenFactory(new EnturProvider(), reservation)) { + new TenantAnnotationTokenFactory(new EnturProvider(), reservation)) { assertNotNull(factory.getServer()); assertNotEquals(stolenPort, factory.getServer().getPort()); assertEquals(reservation.getPort(), factory.getServer().getPort()); From 1eeee9186695e6f50070054b5a9d21c95e45e56a Mon Sep 17 00:00:00 2001 From: LudBjork Date: Fri, 12 Jun 2026 11:41:39 +0200 Subject: [PATCH 5/7] test(junit-tenant): cover start() recovery when own reserved port is stolen Regression test for the upstream oidc-lib bug where re-running start() after the previously scanned port was stolen throws IllegalArgumentException ("Preconfigured port ... is not free") and pins the dead port forever. The ownPort distinction added when making port reservation robust for parallel test JVMs makes start() abandon the stolen port and reserve a fresh one instead; this test fails on upstream and passes on this fork. --- .../auth/junit/jwt/PortReservationTest.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java index 343e05a..fdc1cee 100644 --- a/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java +++ b/oidc-rs-junit-tenant/src/test/java/org/entur/auth/junit/jwt/PortReservationTest.java @@ -52,6 +52,43 @@ void rescanReservesNewPortWhenPreviousPortIsStolen() throws IOException { } } + /** + * Simulates another process stealing the reserved port in the window after {@link + * PortReservation#stop()}. The stale port stays pinned until recovery is requested, but + * re-running {@link PortReservation#start()} (as {@code TenantAnnotationTokenFactory#close()} + * does) must abandon the stolen port and reserve a fresh one instead of failing. + * + *

Reproduces the upstream oidc-lib bug where start() instead throws {@code + * IllegalArgumentException: Preconfigured port ... is not free}, pinning the dead port forever. + * + *

Slow by design: start() retries the previously reserved port for ~10 seconds before giving + * it up, in case the thief is short-lived. + */ + @Test + void restartReservesNewPortWhenPreviousPortIsStolen() throws IOException { + String propertyName = "PortReservationTest.restart"; + PortReservation reservation = new PortReservation(propertyName); + try { + reservation.start(); + int stolenPort = reservation.getPort(); + + reservation.stop(); + try (ServerSocket thief = bind(stolenPort)) { + // the stale reservation still reports the stolen port... + assertEquals(stolenPort, reservation.getPort()); + + // ...but restarting recovers by abandoning it and scanning for a new one + assertTrue(reservation.start()); + assertTrue(reservation.getPort() > 0); + assertNotEquals(stolenPort, reservation.getPort()); + assertEquals(Integer.toString(reservation.getPort()), System.getProperty(propertyName)); + } + } finally { + reservation.stop(); + System.clearProperty(propertyName); + } + } + @Test void externallyPreconfiguredPortIsReusedAndCannotBeRescanned() throws IOException { String propertyName = "PortReservationTest.preconfigured"; From 138fdcc30b1bfa2993aec68c21dd4fcea7fcaffb Mon Sep 17 00:00:00 2001 From: LudBjork Date: Tue, 16 Jun 2026 12:00:46 +0200 Subject: [PATCH 6/7] fix(junit-tenant): publish the final mock server port before Spring loads the context The extension reserved the port in its constructor but only started WireMock in beforeAll, which runs after SpringExtension has already loaded the context and resolved the JWKS URLs. If the reserved port was stolen in the bind window, startServer() rescanned to a fresh port, leaving the Spring context wired to the old port while WireMock listened on the new one. Start the WireMock server during extension construction instead, so the final port is published to the MOCKAUTHSERVER_PORT system property before the context loads. This keeps reused contexts wired to the port the server actually listens on, even when the port has to be retried. --- .../entur/auth/junit/tenant/TenantJsonWebToken.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantJsonWebToken.java b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantJsonWebToken.java index 638ee52..661b4b5 100644 --- a/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantJsonWebToken.java +++ b/oidc-rs-junit-tenant/src/main/java/org/entur/auth/junit/tenant/TenantJsonWebToken.java @@ -65,9 +65,17 @@ public class TenantJsonWebToken implements ParameterResolver, BeforeAllCallback /** Manages reservation of the network port for the WireMock authentication server. */ private static PortReservation portReservation; - /** Construct a new TenantJsonWebToken and initializes the reserved port */ + /** + * Construct a new TenantJsonWebToken, reserving the port and initializing the token factory and + * WireMock server. + * + *

This happens during extension construction (before any lifecycle callback) so the final port + * is published to the {@link #MOCKAUTHSERVER_PORT_NAME} system property before {@code + * SpringExtension} loads the context. This keeps the Spring context wired to the same port the + * mock server listens on, even when the port has to be retried because the original was stolen. + */ public TenantJsonWebToken() { - setupPortReservation(); + setupTokenFactory(); } /** From 442a8a50c5619303d3f671c8bfd3f7b2a3fcb519 Mon Sep 17 00:00:00 2001 From: LudBjork Date: Tue, 16 Jun 2026 12:00:46 +0200 Subject: [PATCH 7/7] test: cover mock server port injection into Spring (including reused contexts) Add a Spring integration test asserting the published MOCKAUTHSERVER_PORT equals the live WireMock server port and that an authenticated request succeeds, plus a second class with identical configuration so a reused (cached) context is checked for port consistency. Add a DefaultAuthProviders unit test that locks the property->JWKS-URL resolution channel and confirms it tracks the port live. --- .../server/DefaultAuthProvidersTest.java | 72 +++++++++++++++++++ .../test/server/MockServerPortAssertions.java | 50 +++++++++++++ .../MockServerPortInjectionReuseTest.java | 47 ++++++++++++ .../server/MockServerPortInjectionTest.java | 47 ++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 oidc-rs-spring-boot-common/src/test/java/org/entur/auth/spring/common/server/DefaultAuthProvidersTest.java create mode 100644 oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortAssertions.java create mode 100644 oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionReuseTest.java create mode 100644 oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionTest.java diff --git a/oidc-rs-spring-boot-common/src/test/java/org/entur/auth/spring/common/server/DefaultAuthProvidersTest.java b/oidc-rs-spring-boot-common/src/test/java/org/entur/auth/spring/common/server/DefaultAuthProvidersTest.java new file mode 100644 index 0000000..23364a7 --- /dev/null +++ b/oidc-rs-spring-boot-common/src/test/java/org/entur/auth/spring/common/server/DefaultAuthProvidersTest.java @@ -0,0 +1,72 @@ +package org.entur.auth.spring.common.server; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Locks the channel used to communicate the mock server port to Spring: the {@code + * MOCKAUTHSERVER_PORT} system property published by the JUnit extension is resolved into the mock + * issuer certificate (JWKS) URLs that the resource server uses to validate tokens. + */ +class DefaultAuthProvidersTest { + + private static final String PORT_PROPERTY = "MOCKAUTHSERVER_PORT"; + private static final List MOCK_TENANTS = + List.of("internal", "traveller", "partner", "person"); + + @Test + void mockCertificateUrlsAreResolvedWithThePublishedPort() { + String previous = System.getProperty(PORT_PROPERTY); + try { + System.setProperty(PORT_PROPERTY, "54321"); + + List providers = new DefaultAuthProviders().get("mock", MOCK_TENANTS); + + assertFalse(providers.isEmpty(), "Expected mock providers to be resolved"); + for (IssuerProperties provider : providers) { + assertTrue( + provider.getCertificateUrl().contains("localhost:54321/"), + "Certificate URL must use the published port but was " + provider.getCertificateUrl()); + assertFalse( + provider.getCertificateUrl().contains("${MOCKAUTHSERVER_PORT}"), + "Placeholder must be resolved"); + } + } finally { + restore(previous); + } + } + + @Test + void resolvedPortFollowsThePropertyWhenItChanges() { + String previous = System.getProperty(PORT_PROPERTY); + try { + // Each resolution reads the property live, so a port retried onto a fresh value is picked + // up rather than a stale one being cached. + System.setProperty(PORT_PROPERTY, "11111"); + assertTrue( + firstCertificateUrl().contains("localhost:11111/"), "First resolution should use 11111"); + + System.setProperty(PORT_PROPERTY, "22222"); + assertTrue( + firstCertificateUrl().contains("localhost:22222/"), + "Resolution after the port changes should use 22222"); + } finally { + restore(previous); + } + } + + private static String firstCertificateUrl() { + return new DefaultAuthProviders().get("mock", MOCK_TENANTS).get(0).getCertificateUrl(); + } + + private static void restore(String previous) { + if (previous == null) { + System.clearProperty(PORT_PROPERTY); + } else { + System.setProperty(PORT_PROPERTY, previous); + } + } +} diff --git a/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortAssertions.java b/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortAssertions.java new file mode 100644 index 0000000..21dc176 --- /dev/null +++ b/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortAssertions.java @@ -0,0 +1,50 @@ +package org.entur.auth.spring.test.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.entur.auth.junit.tenant.TenantJsonWebToken; +import org.entur.auth.junit.tenant.WireMockAuthenticationServer; +import org.springframework.context.ApplicationContext; + +/** + * Shared assertions used by {@link MockServerPortInjectionTest} and {@link + * MockServerPortInjectionReuseTest}. The two tests declare an identical Spring configuration so + * they share a single cached application context. This verifies that the mock server port wired + * into the context matches the live WireMock server, and that it stays consistent whenever the + * context is reused. + */ +final class MockServerPortAssertions { + + // null until the first context has been seen; lets us detect and check context reuse. + private static Integer firstContextId; + private static Integer firstPort; + + private MockServerPortAssertions() {} + + /** + * Assert that the port the Spring context was wired with equals the port the live WireMock server + * actually listens on, and that this stays stable across reuses of the same cached context. + */ + static synchronized void assertContextWiredToLiveServer( + ApplicationContext context, WireMockAuthenticationServer server) { + int livePort = server.getPort(); + + // The system property is the channel the extension uses to communicate the port to Spring. + assertEquals( + Integer.toString(livePort), + System.getProperty(TenantJsonWebToken.MOCKAUTHSERVER_PORT_NAME), + "Published MOCKAUTHSERVER_PORT must match the running mock server port"); + + int contextId = System.identityHashCode(context); + if (firstContextId != null && firstContextId == contextId) { + // Same cached context handed to another test class: the port must not have drifted. + assertEquals( + firstPort, + livePort, + "A reused Spring context must keep the same mock server port it was built with"); + } + + firstContextId = contextId; + firstPort = livePort; + } +} diff --git a/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionReuseTest.java b/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionReuseTest.java new file mode 100644 index 0000000..1ceb1f8 --- /dev/null +++ b/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionReuseTest.java @@ -0,0 +1,47 @@ +package org.entur.auth.spring.test.server; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.entur.auth.junit.tenant.InternalTenant; +import org.entur.auth.junit.tenant.TenantJsonWebToken; +import org.entur.auth.junit.tenant.WireMockAuthenticationServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Second class with the same Spring configuration as {@link MockServerPortInjectionTest}. Spring + * caches and reuses the application context across both classes; this asserts the reused context + * keeps the same mock server port and still authenticates against the live server. + */ +@ExtendWith({SpringExtension.class, TenantJsonWebToken.class}) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class MockServerPortInjectionReuseTest { + @Autowired private MockMvc mockMvc; + @Autowired private ApplicationContext applicationContext; + + @Test + void reusedContextKeepsLiveMockServerPort( + @InternalTenant(clientId = "clientId") String authorization, + WireMockAuthenticationServer mockServer) + throws Exception { + MockServerPortAssertions.assertContextWiredToLiveServer(applicationContext, mockServer); + + // End-to-end proof: the reused context still resolves its JWKS URL to the live mock server + // port, so a token signed by that server validates against the keys Spring fetches from it. + var requestHeaders = new HttpHeaders(); + requestHeaders.add("Accept", MediaType.APPLICATION_JSON_VALUE); + requestHeaders.add("Authorization", authorization); + + mockMvc.perform(get("/internal").headers(requestHeaders)).andExpect(status().isOk()); + } +} diff --git a/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionTest.java b/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionTest.java new file mode 100644 index 0000000..ab73c28 --- /dev/null +++ b/oidc-rs-spring-boot-web-test/src/test/java/org/entur/auth/spring/test/server/MockServerPortInjectionTest.java @@ -0,0 +1,47 @@ +package org.entur.auth.spring.test.server; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.entur.auth.junit.tenant.InternalTenant; +import org.entur.auth.junit.tenant.TenantJsonWebToken; +import org.entur.auth.junit.tenant.WireMockAuthenticationServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Verifies that the Spring test context is wired with the same port as the WireMock mock server + * bootstrapped by {@link TenantJsonWebToken}. {@link MockServerPortInjectionReuseTest} shares the + * exact same Spring configuration so the two classes exercise a reused (cached) context. + */ +@ExtendWith({SpringExtension.class, TenantJsonWebToken.class}) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class MockServerPortInjectionTest { + @Autowired private MockMvc mockMvc; + @Autowired private ApplicationContext applicationContext; + + @Test + void contextIsWiredWithLiveMockServerPort( + @InternalTenant(clientId = "clientId") String authorization, + WireMockAuthenticationServer mockServer) + throws Exception { + MockServerPortAssertions.assertContextWiredToLiveServer(applicationContext, mockServer); + + // End-to-end proof: the context resolved its JWKS URL to the live mock server port, so a + // token signed by that server validates against the keys Spring fetches from it. + var requestHeaders = new HttpHeaders(); + requestHeaders.add("Accept", MediaType.APPLICATION_JSON_VALUE); + requestHeaders.add("Authorization", authorization); + + mockMvc.perform(get("/internal").headers(requestHeaders)).andExpect(status().isOk()); + } +}