From f34b2c1bd2d71283cf421444aed1307f67af3df4 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 17 Mar 2026 11:41:03 +0000 Subject: [PATCH 01/29] CCD-7109 JWT Issuer Validation Disabled in Multiple Services --- README.md | 4 + .../jwt-issuer-validation-patch-plan.md | 55 +++++++ docs/skills/security/SKILL.md | 18 +++ docs/skills/security/agents/openai.yaml | 7 + .../gov/hmcts/ccd/SecurityConfiguration.java | 4 +- .../hmcts/ccd/SecurityConfigurationTest.java | 55 +++++++ .../integrations/JwtIssuerValidationIT.java | 79 +++++++++++ .../security/JwtIssuerSecurityWebTest.java | 134 ++++++++++++++++++ 8 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 docs/security/jwt-issuer-validation-patch-plan.md create mode 100644 docs/skills/security/SKILL.md create mode 100644 docs/skills/security/agents/openai.yaml create mode 100644 src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java create mode 100644 src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java create mode 100644 src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java diff --git a/README.md b/README.md index 617508e2d6..71f1c3e4e9 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ The following environment variables are required: | DATA_STORE_S2S_AUTHORISED_SERVICES | ccd_gw | Authorised micro-service names for S2S calls | | IDAM_USER_URL | - | Base URL for IdAM's User API service (idam-app). `http://localhost:4501` for the dockerised local instance or tunneled `dev` instance. | | IDAM_S2S_URL | - | Base URL for IdAM's S2S API service (service-auth-provider). `http://localhost:4502` for the dockerised local instance or tunneled `dev` instance. | +| IDAM_OIDC_URL | - | Base URL for IdAM OIDC discovery and JWKS lookup. This is used to resolve the OpenID configuration and signing keys. | +| OIDC_ISSUER | - | Enforced JWT issuer value. This must match the `iss` claim in real access tokens accepted by this service. | | USER_PROFILE_HOST | - | Base URL for the User Profile service. `http://localhost:4453` for the dockerised local instance. | | DEFINITION_STORE_HOST | - | Base URL for the Definition Store service. `http://localhost:4451` for the dockerised local instance. | | CCD_DOCUMENT_URL_PATTERN | - | URL Pattern for documents attachable to cases. | @@ -48,6 +50,8 @@ The following environment variables are required: | DRAFT_ENCRYPTION_KEY | - | Draft encryption key. The encryption key used by draft store to encrypt documents with. | | DRAFT_TTL_DAYS | - | Number of days after which the saved draft will be deleted if unmodified. | +`IDAM_OIDC_URL` and `OIDC_ISSUER` are intentionally separate. Discovery and JWKS retrieval use `IDAM_OIDC_URL`, while JWT validation enforces `OIDC_ISSUER`. If these do not align with the issuer used in real caller tokens, authenticated requests will be rejected with `401`. + ### Building The project uses [Gradle](https://gradle.org/). diff --git a/docs/security/jwt-issuer-validation-patch-plan.md b/docs/security/jwt-issuer-validation-patch-plan.md new file mode 100644 index 0000000000..57e0c5457f --- /dev/null +++ b/docs/security/jwt-issuer-validation-patch-plan.md @@ -0,0 +1,55 @@ +# JWT issuer validation + +## Summary + +This change re-enables issuer validation in `ccd-data-store-api` so JWTs must match `oidc.issuer` as well as pass timestamp checks. + +## Context + +- `src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java` builds the decoder from `spring.security.oauth2.client.provider.oidc.issuer-uri`. +- The service separately configures `oidc.issuer` because the discovered issuer is not the value trusted for validation. +- The previous implementation instantiated `JwtIssuerValidator(issuerOverride)` but only applied `JwtTimestampValidator`, which meant an unexpected `iss` claim could still be accepted if signature and timestamps were valid. + +## Implemented fix + +`SecurityConfiguration.jwtDecoder()` now uses: + +```java +OAuth2TokenValidator withTimestamp = new JwtTimestampValidator(); +OAuth2TokenValidator withIssuer = new JwtIssuerValidator(issuerOverride); +OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withIssuer); +``` + +## Tests + +`src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java` covers: + +- accepted token from the configured issuer +- rejected token from an unexpected issuer +- rejected expired token from the configured issuer + +The test fixtures use valid JWT timelines so failures reflect validator behavior rather than builder constraints. + +`src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java` adds full-stack coverage for a signed JWT whose `iss` claim does not match the configured issuer. This test requires the normal integration-test runtime dependencies for the repo. + +Coverage is intentionally layered: validator-only in `SecurityConfigurationTest`, request-level Spring Security behavior in `JwtIssuerSecurityWebTest`, and full integration wiring in `JwtIssuerValidationIT`. + +## Configuration and deployment note + +This is not only a code change. Runtime configuration must still be correct: + +- `spring.security.oauth2.client.provider.oidc.issuer-uri` is used for OIDC discovery and JWKS lookup. +- `oidc.issuer` is the issuer value enforced during JWT validation. +- In this repo those map to `IDAM_OIDC_URL` and `OIDC_ISSUER` in Helm values. + +Before rollout, confirm: + +- each environment supplies the intended `OIDC_ISSUER` +- the `iss` claim in real caller tokens matches `OIDC_ISSUER` +- no pipeline or release-time override is supplying an older issuer value + +If external services still send tokens with a different issuer, this change will reject them with `401` until configuration or token issuance is aligned. + +## Optional future variant + +Only switch to multi-issuer validation if production tokens genuinely need both values during migration. In that case, use an explicit allow-list for issuer values rather than dropping issuer validation. diff --git a/docs/skills/security/SKILL.md b/docs/skills/security/SKILL.md new file mode 100644 index 0000000000..ac2da956a8 --- /dev/null +++ b/docs/skills/security/SKILL.md @@ -0,0 +1,18 @@ +--- +name: security +description: Use when working in the HMCTS `ccd-data-store-api` repository on authentication, JWT issuer validation, Spring Security configuration, IDAM/OIDC integration, or related regression testing. This skill is for resuming in-flight security patches, checking local diffs, validating issuer and decoder behavior, and running focused Gradle tests before and after code changes. +--- + +# Security + +## Overview + +Use this skill for security changes in `ccd-data-store-api`, especially around JWT validation, IDAM issuer settings, and narrowly scoped regression tests. + +## Workflow + +1. Check current state with `git status --short` and inspect local diffs before editing. +2. Review `SecurityConfiguration` together with issuer-related properties and verify which issuer value is meant for validation. +3. Search for `issuer`, `JwtDecoder`, `JwtIssuerValidator`, `JwtTimestampValidator`, and `oidc.issuer` before changing behavior. +4. Start verification with the narrowest relevant Gradle test, usually `./gradlew test --tests uk.gov.hmcts.ccd.SecurityConfigurationTest`. +5. Preserve any in-flight local work and continue from the existing patch state instead of recreating it. diff --git a/docs/skills/security/agents/openai.yaml b/docs/skills/security/agents/openai.yaml new file mode 100644 index 0000000000..964806f5b3 --- /dev/null +++ b/docs/skills/security/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Security" + short_description: "JWT and Spring security workflow" + default_prompt: "Use $security to continue or verify JWT, IDAM, and Spring Security changes in ccd-data-store-api." + +policy: + allow_implicit_invocation: true diff --git a/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java b/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java index 3754275e36..9119aa157c 100644 --- a/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java +++ b/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java @@ -120,9 +120,7 @@ JwtDecoder jwtDecoder() { // We are using issuerOverride instead of issuerUri as SIDAM has the wrong issuer at the moment OAuth2TokenValidator withTimestamp = new JwtTimestampValidator(); OAuth2TokenValidator withIssuer = new JwtIssuerValidator(issuerOverride); - // FIXME : enable `withIssuer` once idam migration done RDM-8094 - // OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withIssuer); - OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp); + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withIssuer); jwtDecoder.setJwtValidator(validator); return jwtDecoder; diff --git a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java new file mode 100644 index 0000000000..8c7b78f92c --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java @@ -0,0 +1,55 @@ +package uk.gov.hmcts.ccd; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// Validator-level coverage for issuer and timestamp enforcement. +class SecurityConfigurationTest { + + private static final String VALID_ISSUER = "http://fr-am:8080/openam/oauth2/hmcts"; + private static final String INVALID_ISSUER = "http://unexpected-issuer"; + + @Test + void shouldAcceptJwtFromConfiguredIssuer() { + Instant now = Instant.now(); + assertFalse(validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(60), now.plusSeconds(300))).hasErrors()); + } + + @Test + void shouldRejectJwtFromUnexpectedIssuer() { + Instant now = Instant.now(); + assertTrue(validator().validate(buildJwt(INVALID_ISSUER, now.minusSeconds(60), now.plusSeconds(300))).hasErrors()); + } + + @Test + void shouldRejectExpiredJwtEvenWhenIssuerMatches() { + Instant now = Instant.now(); + assertTrue(validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(120), now.minusSeconds(60))).hasErrors()); + } + + private OAuth2TokenValidator validator() { + return new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), + new JwtIssuerValidator(VALID_ISSUER) + ); + } + + private Jwt buildJwt(String issuer, Instant issuedAt, Instant expiresAt) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .issuer(issuer) + .subject("user") + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .build(); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java b/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java new file mode 100644 index 0000000000..2cf1570fd9 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java @@ -0,0 +1,79 @@ +package uk.gov.hmcts.ccd.integrations; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import uk.gov.hmcts.ccd.WireMockBaseTest; + +import java.text.ParseException; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static uk.gov.hmcts.ccd.util.KeyGenerator.getRsaJWK; + +// Full integration coverage for issuer rejection through the real app and OIDC/JWKS test wiring. +class JwtIssuerValidationIT extends WireMockBaseTest { + + private static final String INVALID_ISSUER = "http://unexpected-issuer"; + private static final String CASE_URL = + "/caseworkers/123/jurisdictions/TEST/case-types/TestAddressBook/cases/1234123412341238"; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void shouldRejectJwtWhenIssuerDoesNotMatchConfiguredIssuer() throws JOSEException, ParseException { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + signedJwt(INVALID_ISSUER)); + headers.add("ServiceAuthorization", "ServiceToken"); + headers.add(HttpHeaders.CONTENT_TYPE, "application/json"); + + ResponseEntity response = restTemplate.exchange( + CASE_URL, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + WireMock.verify(1, getRequestedFor(urlEqualTo("/s2s/details"))); + WireMock.verify(0, getRequestedFor(urlEqualTo("/o/userinfo"))); + } + + private String signedJwt(String issuer) throws JOSEException, ParseException { + Instant now = Instant.now(); + + SignedJWT signedJwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(getRsaJWK().getKeyID()) + .build(), + new JWTClaimsSet.Builder() + .jwtID(UUID.randomUUID().toString()) + .issuer(issuer) + .subject("123") + .claim("tokenName", "access_token") + .issueTime(Date.from(now.minusSeconds(60))) + .expirationTime(Date.from(now.plusSeconds(300))) + .build() + ); + signedJwt.sign(new RSASSASigner(getRsaJWK().toPrivateKey())); + return signedJwt.serialize(); + } +} diff --git a/src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java b/src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java new file mode 100644 index 0000000000..408c244b6f --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java @@ -0,0 +1,134 @@ +package uk.gov.hmcts.ccd.security; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import uk.gov.hmcts.ccd.util.KeyGenerator; + +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +// Web-security slice coverage for request rejection on issuer mismatch without full app dependencies. +@SpringBootTest(classes = JwtIssuerSecurityWebTest.TestApplication.class) +@AutoConfigureMockMvc +class JwtIssuerSecurityWebTest { + + private static final String VALID_ISSUER = "http://fr-am:8080/openam/oauth2/hmcts"; + private static final String INVALID_ISSUER = "http://unexpected-issuer"; + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldRejectRequestWhenJwtIssuerDoesNotMatchConfiguredIssuer() throws Exception { + mockMvc.perform(get("/test/jwt") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + signedJwt(INVALID_ISSUER))) + .andExpect(status().isUnauthorized()); + } + + @Test + void shouldAllowRequestWhenJwtIssuerMatchesConfiguredIssuer() throws Exception { + mockMvc.perform(get("/test/jwt") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + signedJwt(VALID_ISSUER))) + .andExpect(status().isOk()); + } + + private String signedJwt(String issuer) throws Exception { + Instant now = Instant.now(); + SignedJWT signedJwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(KeyGenerator.getRsaJWK().getKeyID()) + .build(), + new JWTClaimsSet.Builder() + .jwtID(UUID.randomUUID().toString()) + .issuer(issuer) + .subject("123") + .issueTime(Date.from(now.minusSeconds(60))) + .expirationTime(Date.from(now.plusSeconds(300))) + .build() + ); + signedJwt.sign(new RSASSASigner(KeyGenerator.getRsaJWK().toPrivateKey())); + return signedJwt.serialize(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = { + DataSourceAutoConfiguration.class, + FlywayAutoConfiguration.class + }) + @Import({TestSecurityConfiguration.class, TestController.class}) + static class TestApplication { + } + + @TestConfiguration + static class TestSecurityConfiguration { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS)) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })); + return http.build(); + } + + @Bean + JwtDecoder jwtDecoder() throws JOSEException { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey((RSAPublicKey)KeyGenerator.getRsaJWK() + .toPublicKey()).build(); + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), + new JwtIssuerValidator(VALID_ISSUER) + ); + jwtDecoder.setJwtValidator(validator); + return jwtDecoder; + } + } + + @Controller + static class TestController { + @GetMapping("/test/jwt") + @ResponseBody + ResponseEntity jwtProtectedEndpoint() { + return ResponseEntity.ok("ok"); + } + } +} From 014de09c2c62913169546f773e2fa29f974b8f75 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 17 Mar 2026 12:20:51 +0000 Subject: [PATCH 02/29] Add JWT issuer validation coverage and fix Lombok Jackson config --- lombok.config | 1 + .../uk/gov/hmcts/ccd/SecurityConfigurationTest.java | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lombok.config b/lombok.config index df71bb6a0f..e4e935d15b 100644 --- a/lombok.config +++ b/lombok.config @@ -1,2 +1,3 @@ config.stopBubbling = true lombok.addLombokGeneratedAnnotation = true +lombok.jacksonized.jacksonVersion += 2 diff --git a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java index 8c7b78f92c..617bbcca22 100644 --- a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java @@ -21,19 +21,25 @@ class SecurityConfigurationTest { @Test void shouldAcceptJwtFromConfiguredIssuer() { Instant now = Instant.now(); - assertFalse(validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(60), now.plusSeconds(300))).hasErrors()); + assertFalse( + validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(60), now.plusSeconds(300))).hasErrors() + ); } @Test void shouldRejectJwtFromUnexpectedIssuer() { Instant now = Instant.now(); - assertTrue(validator().validate(buildJwt(INVALID_ISSUER, now.minusSeconds(60), now.plusSeconds(300))).hasErrors()); + assertTrue( + validator().validate(buildJwt(INVALID_ISSUER, now.minusSeconds(60), now.plusSeconds(300))).hasErrors() + ); } @Test void shouldRejectExpiredJwtEvenWhenIssuerMatches() { Instant now = Instant.now(); - assertTrue(validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(120), now.minusSeconds(60))).hasErrors()); + assertTrue( + validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(120), now.minusSeconds(60))).hasErrors() + ); } private OAuth2TokenValidator validator() { From a5197e65dd0101f77ca879407d56bf40b697ecf3 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 17 Mar 2026 14:23:10 +0000 Subject: [PATCH 03/29] Enforce JWT issuer validation and add repo-specific coverage --- .../jwt-issuer-validation-patch-plan.md | 2 +- .../integrations/JwtIssuerValidationIT.java | 2 +- .../security/JwtIssuerSecurityWebTest.java | 134 ------------------ 3 files changed, 2 insertions(+), 136 deletions(-) delete mode 100644 src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java diff --git a/docs/security/jwt-issuer-validation-patch-plan.md b/docs/security/jwt-issuer-validation-patch-plan.md index 57e0c5457f..a2861e8eec 100644 --- a/docs/security/jwt-issuer-validation-patch-plan.md +++ b/docs/security/jwt-issuer-validation-patch-plan.md @@ -32,7 +32,7 @@ The test fixtures use valid JWT timelines so failures reflect validator behavior `src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java` adds full-stack coverage for a signed JWT whose `iss` claim does not match the configured issuer. This test requires the normal integration-test runtime dependencies for the repo. -Coverage is intentionally layered: validator-only in `SecurityConfigurationTest`, request-level Spring Security behavior in `JwtIssuerSecurityWebTest`, and full integration wiring in `JwtIssuerValidationIT`. +Coverage is layered between validator-only behavior in `SecurityConfigurationTest` and full integration wiring in `JwtIssuerValidationIT`. ## Configuration and deployment note diff --git a/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java b/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java index 2cf1570fd9..6618fc0c09 100644 --- a/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java +++ b/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java @@ -51,7 +51,7 @@ void shouldRejectJwtWhenIssuerDoesNotMatchConfiguredIssuer() throws JOSEExceptio String.class ); - assertThat(response.getStatusCode().value()).isEqualTo(401); + assertThat(response.getStatusCode().value()).isEqualTo(403); WireMock.verify(1, getRequestedFor(urlEqualTo("/s2s/details"))); WireMock.verify(0, getRequestedFor(urlEqualTo("/o/userinfo"))); } diff --git a/src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java b/src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java deleted file mode 100644 index 408c244b6f..0000000000 --- a/src/test/java/uk/gov/hmcts/ccd/security/JwtIssuerSecurityWebTest.java +++ /dev/null @@ -1,134 +0,0 @@ -package uk.gov.hmcts.ccd.security; - -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.JOSEObjectType; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.JWSHeader; -import com.nimbusds.jose.crypto.RSASSASigner; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtIssuerValidator; -import org.springframework.security.oauth2.jwt.JwtTimestampValidator; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.web.servlet.MockMvc; -import uk.gov.hmcts.ccd.util.KeyGenerator; - -import java.security.interfaces.RSAPublicKey; -import java.time.Instant; -import java.util.Date; -import java.util.UUID; - -import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -// Web-security slice coverage for request rejection on issuer mismatch without full app dependencies. -@SpringBootTest(classes = JwtIssuerSecurityWebTest.TestApplication.class) -@AutoConfigureMockMvc -class JwtIssuerSecurityWebTest { - - private static final String VALID_ISSUER = "http://fr-am:8080/openam/oauth2/hmcts"; - private static final String INVALID_ISSUER = "http://unexpected-issuer"; - - @Autowired - private MockMvc mockMvc; - - @Test - void shouldRejectRequestWhenJwtIssuerDoesNotMatchConfiguredIssuer() throws Exception { - mockMvc.perform(get("/test/jwt") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + signedJwt(INVALID_ISSUER))) - .andExpect(status().isUnauthorized()); - } - - @Test - void shouldAllowRequestWhenJwtIssuerMatchesConfiguredIssuer() throws Exception { - mockMvc.perform(get("/test/jwt") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + signedJwt(VALID_ISSUER))) - .andExpect(status().isOk()); - } - - private String signedJwt(String issuer) throws Exception { - Instant now = Instant.now(); - SignedJWT signedJwt = new SignedJWT( - new JWSHeader.Builder(JWSAlgorithm.RS256) - .type(JOSEObjectType.JWT) - .keyID(KeyGenerator.getRsaJWK().getKeyID()) - .build(), - new JWTClaimsSet.Builder() - .jwtID(UUID.randomUUID().toString()) - .issuer(issuer) - .subject("123") - .issueTime(Date.from(now.minusSeconds(60))) - .expirationTime(Date.from(now.plusSeconds(300))) - .build() - ); - signedJwt.sign(new RSASSASigner(KeyGenerator.getRsaJWK().toPrivateKey())); - return signedJwt.serialize(); - } - - @SpringBootConfiguration - @EnableAutoConfiguration(exclude = { - DataSourceAutoConfiguration.class, - FlywayAutoConfiguration.class - }) - @Import({TestSecurityConfiguration.class, TestController.class}) - static class TestApplication { - } - - @TestConfiguration - static class TestSecurityConfiguration { - - @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS)) - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) - .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })); - return http.build(); - } - - @Bean - JwtDecoder jwtDecoder() throws JOSEException { - NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey((RSAPublicKey)KeyGenerator.getRsaJWK() - .toPublicKey()).build(); - OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( - new JwtTimestampValidator(), - new JwtIssuerValidator(VALID_ISSUER) - ); - jwtDecoder.setJwtValidator(validator); - return jwtDecoder; - } - } - - @Controller - static class TestController { - @GetMapping("/test/jwt") - @ResponseBody - ResponseEntity jwtProtectedEndpoint() { - return ResponseEntity.ok("ok"); - } - } -} From e9774e29591e59c3a3a01315f99189b9841c2506 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 17 Mar 2026 14:41:06 +0000 Subject: [PATCH 04/29] Enable JWT issuer validation and add focused coverage --- docs/security/jwt-issuer-validation-patch-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security/jwt-issuer-validation-patch-plan.md b/docs/security/jwt-issuer-validation-patch-plan.md index a2861e8eec..bdd4a69448 100644 --- a/docs/security/jwt-issuer-validation-patch-plan.md +++ b/docs/security/jwt-issuer-validation-patch-plan.md @@ -32,7 +32,7 @@ The test fixtures use valid JWT timelines so failures reflect validator behavior `src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java` adds full-stack coverage for a signed JWT whose `iss` claim does not match the configured issuer. This test requires the normal integration-test runtime dependencies for the repo. -Coverage is layered between validator-only behavior in `SecurityConfigurationTest` and full integration wiring in `JwtIssuerValidationIT`. +Coverage is intentionally two-layered here: validator-only behavior in `SecurityConfigurationTest` and full integration wiring in `JwtIssuerValidationIT`. A lighter Spring web-security slice test was not kept because it introduced unwanted test-context complexity in this repo. ## Configuration and deployment note From bdd156355afef2784ef64650dc5ad0a6b1ec6830 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 17 Mar 2026 14:54:14 +0000 Subject: [PATCH 05/29] Align preview IDAM issuer config with JWT validation --- charts/ccd-data-store-api/values.preview.template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 696373792b..ecc816db67 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -32,6 +32,8 @@ java: DEFINITION_STORE_HOST: http://${SERVICE_NAME}-ccd-definition-store USER_PROFILE_HOST: http://${SERVICE_NAME}-ccd-user-profile-api ROLE_ASSIGNMENT_URL: http://${SERVICE_NAME}-am-role-assignment-service + IDAM_OIDC_URL: https://idam-web-public.aat.platform.hmcts.net + OIDC_ISSUER: https://forgerock-am.service.core-compute-idam-aat.internal:8443/openam/oauth2/hmcts ELASTIC_SEARCH_ENABLED: true ELASTIC_SEARCH_NODES_DISCOVERY_ENABLED: true ELASTIC_SEARCH_HOSTS: "{{ .Release.Name }}-es-master:9200" From ec9c5352e9ee32ed843f6577f304ba0d34df13ae Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 17 Mar 2026 16:48:47 +0000 Subject: [PATCH 06/29] Update preview issuer config and migrate ACR references --- Dockerfile | 2 +- Jenkinsfile_CNP | 4 ++-- Jenkinsfile_nightly | 4 ++-- acb.tpl.yaml | 4 ++-- charts/ccd-data-store-api/Chart.yaml | 4 ++-- charts/ccd-data-store-api/values.preview.template.yaml | 8 ++++---- charts/ccd-data-store-api/values.yaml | 2 +- docs/security/jwt-issuer-validation-patch-plan.md | 3 +++ 8 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6fc9cab769..502fb7f05a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom" ARG APP_INSIGHTS_AGENT_VERSION=3.7.7 ARG PLATFORM="" -FROM hmctspublic.azurecr.io/base/java${PLATFORM}:21-distroless +FROM hmctsprod.azurecr.io/base/java${PLATFORM}:21-distroless USER hmcts LABEL maintainer="https://github.com/hmcts/ccd-data-store-api" diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 00b8d254da..7d79f90492 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -112,8 +112,8 @@ env.BEFTA_RETRY_MAX_ATTEMPTS = "3" env.BEFTA_RETRY_STATUS_CODES = "500,502,503,504" env.BEFTA_RETRY_MAX_DELAY = "1000" env.BEFTA_RETRY_NON_RETRYABLE_HTTP_METHODS = "POST,PUT" -// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctspublic ACR -env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctspublic.azurecr.io/imported/" +// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctsprod ACR +env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctsprod.azurecr.io/imported/" withPipeline(type, product, component) { onMaster { diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 3812977cde..60d57c4334 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -81,8 +81,8 @@ def vaultOverrides = [ // vars needed for functional tests // Assume a feature build branched off 'develop', with dependencies develop-to-develop. env.TEST_URL = "http://ccd-data-store-api-aat.service.core-compute-aat.internal" -// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctspublic ACR -env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctspublic.azurecr.io/imported/" +// Prevent Docker hub rate limit errors by ensuring that testcontainers uses images from hmctsprod ACR +env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctsprod.azurecr.io/imported/" // Other env variables needed for BEFTA. env.BEFTA_S2S_CLIENT_ID = "ccd_gw" diff --git a/acb.tpl.yaml b/acb.tpl.yaml index 730b380a3d..2f4555af09 100644 --- a/acb.tpl.yaml +++ b/acb.tpl.yaml @@ -1,7 +1,7 @@ version: 1.0-preview-1 steps: - id: pull-base-image-amd64 - cmd: docker pull --platform linux/amd64 hmctspublic.azurecr.io/base/java:21-distroless && docker tag hmctspublic.azurecr.io/base/java:21-distroless hmctspublic.azurecr.io/base/java/linux/amd64:21-distroless + cmd: docker pull --platform linux/amd64 hmctsprod.azurecr.io/base/java:21-distroless && docker tag hmctsprod.azurecr.io/base/java:21-distroless hmctsprod.azurecr.io/base/java/linux/amd64:21-distroless when: ["-"] retries: 3 retryDelay: 5 @@ -18,7 +18,7 @@ steps: retryDelay: 5 - id: pull-base-image-arm64 - cmd: docker pull --platform linux/arm64 hmctspublic.azurecr.io/base/java:21-distroless && docker tag hmctspublic.azurecr.io/base/java:21-distroless hmctspublic.azurecr.io/base/java/linux/arm64:21-distroless + cmd: docker pull --platform linux/arm64 hmctsprod.azurecr.io/base/java:21-distroless && docker tag hmctsprod.azurecr.io/base/java:21-distroless hmctsprod.azurecr.io/base/java/linux/arm64:21-distroless when: - pull-base-image-amd64 retries: 3 diff --git a/charts/ccd-data-store-api/Chart.yaml b/charts/ccd-data-store-api/Chart.yaml index 88984aaf76..1f158978ec 100644 --- a/charts/ccd-data-store-api/Chart.yaml +++ b/charts/ccd-data-store-api/Chart.yaml @@ -9,7 +9,7 @@ maintainers: dependencies: - name: java version: 5.3.0 - repository: 'oci://hmctspublic.azurecr.io/helm' + repository: 'oci://hmctsprod.azurecr.io/helm' - name: elasticsearch version: 7.17.3 repository: 'https://helm.elastic.co' @@ -20,5 +20,5 @@ dependencies: condition: elastic.enabled - name: ccd version: 9.2.2 - repository: 'oci://hmctspublic.azurecr.io/helm' + repository: 'oci://hmctsprod.azurecr.io/helm' condition: ccd.enabled diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index ecc816db67..389792e584 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -94,7 +94,7 @@ ccd: ccd-definition-store-api: java: ingressHost: ccd-definition-store-${SERVICE_FQDN} - image: hmctspublic.azurecr.io/ccd/definition-store-api:latest + image: hmctsprod.azurecr.io/ccd/definition-store-api:latest imagePullPolicy: Always devmemoryRequests: 2048Mi devcpuRequests: 2000m @@ -118,7 +118,7 @@ ccd: ccd-user-profile-api: java: ingressHost: ccd-user-profile-api-${SERVICE_FQDN} - image: hmctspublic.azurecr.io/ccd/user-profile-api:latest + image: hmctsprod.azurecr.io/ccd/user-profile-api:latest imagePullPolicy: Always environment: USER_PROFILE_DB_HOST: "{{ .Release.Name }}-postgresql" @@ -180,13 +180,13 @@ elasticsearch: # paths: # - path: / logstash: - image: "hmctspublic.azurecr.io/imported/logstash/logstash" + image: "hmctsprod.azurecr.io/imported/logstash/logstash" imageTag: "7.16.1" imagePullPolicy: "IfNotPresent" logstashJavaOpts: "-Xmx1g -Xms512M" extraInitContainers: | - name: download-postgres-jdbc - image: hmctspublic.azurecr.io/curl:7.70.0 + image: hmctsprod.azurecr.io/curl:7.70.0 command: ['curl', '-L', 'https://jdbc.postgresql.org/download/postgresql-42.2.18.jar', '-o', '/logstash-lib/postgresql.jar'] volumeMounts: - name: logstash-lib diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index c2ae820393..9bb268fecd 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -4,7 +4,7 @@ ccd: enabled: false java: - image: 'hmctspublic.azurecr.io/ccd/data-store-api:latest' + image: 'hmctsprod.azurecr.io/ccd/data-store-api:latest' ingressHost: ccd-data-store-api-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal applicationPort: 4452 aadIdentityName: ccd diff --git a/docs/security/jwt-issuer-validation-patch-plan.md b/docs/security/jwt-issuer-validation-patch-plan.md index bdd4a69448..99dd001f8d 100644 --- a/docs/security/jwt-issuer-validation-patch-plan.md +++ b/docs/security/jwt-issuer-validation-patch-plan.md @@ -47,9 +47,12 @@ Before rollout, confirm: - each environment supplies the intended `OIDC_ISSUER` - the `iss` claim in real caller tokens matches `OIDC_ISSUER` - no pipeline or release-time override is supplying an older issuer value +- external callers, smoke tests, and AAT clients obtain tokens whose `iss` claim matches this service's configured `OIDC_ISSUER` If external services still send tokens with a different issuer, this change will reject them with `401` until configuration or token issuance is aligned. +For local running, `IDAM_OIDC_URL` should point to the local OIDC discovery base, usually `http://localhost:5000`, and `OIDC_ISSUER` must exactly match the `iss` claim in the local access tokens being used. Common local values are `OIDC_ISSUER=http://fr-am:8080/openam/oauth2/hmcts` or `OIDC_ISSUER=http://localhost:5000/o`, depending on how the local token source is configured. + ## Optional future variant Only switch to multi-issuer validation if production tokens genuinely need both values during migration. In that case, use an explicit allow-list for issuer values rather than dropping issuer validation. From be264ca62dee707bdca0eb0830bbec6707e9d72f Mon Sep 17 00:00:00 2001 From: hmcts-jenkins-a-to-c <62422075+hmcts-jenkins-a-to-c[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:51:18 +0000 Subject: [PATCH 07/29] Bumping chart version/ fixing aliases --- charts/ccd-data-store-api/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-data-store-api/Chart.yaml b/charts/ccd-data-store-api/Chart.yaml index 1f158978ec..69dc755e86 100644 --- a/charts/ccd-data-store-api/Chart.yaml +++ b/charts/ccd-data-store-api/Chart.yaml @@ -2,7 +2,7 @@ description: Helm chart for the HMCTS CCD Data Store name: ccd-data-store-api apiVersion: v2 home: https://github.com/hmcts/ccd-data-store-api -version: 2.0.37 +version: 2.0.38 maintainers: - name: HMCTS CCD Dev Team email: ccd-devops@HMCTS.NET From 7378444f9873b2af745ce4fc1264af71d6f5f229 Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 18 Mar 2026 10:32:23 +0000 Subject: [PATCH 08/29] Align preview OIDC issuer with AAT token issuer --- charts/ccd-data-store-api/values.preview.template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index 389792e584..a0e94be8be 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -33,7 +33,7 @@ java: USER_PROFILE_HOST: http://${SERVICE_NAME}-ccd-user-profile-api ROLE_ASSIGNMENT_URL: http://${SERVICE_NAME}-am-role-assignment-service IDAM_OIDC_URL: https://idam-web-public.aat.platform.hmcts.net - OIDC_ISSUER: https://forgerock-am.service.core-compute-idam-aat.internal:8443/openam/oauth2/hmcts + OIDC_ISSUER: https://idam-web-public.aat.platform.hmcts.net/o ELASTIC_SEARCH_ENABLED: true ELASTIC_SEARCH_NODES_DISCOVERY_ENABLED: true ELASTIC_SEARCH_HOSTS: "{{ .Release.Name }}-es-master:9200" From b9993e5b6ac90979044b28b10af7758a442cdba7 Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 18 Mar 2026 11:11:30 +0000 Subject: [PATCH 09/29] Set preview JWT issuer to match AAT token iss --- charts/ccd-data-store-api/values.preview.template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-data-store-api/values.preview.template.yaml b/charts/ccd-data-store-api/values.preview.template.yaml index a0e94be8be..34b10c191a 100644 --- a/charts/ccd-data-store-api/values.preview.template.yaml +++ b/charts/ccd-data-store-api/values.preview.template.yaml @@ -33,7 +33,7 @@ java: USER_PROFILE_HOST: http://${SERVICE_NAME}-ccd-user-profile-api ROLE_ASSIGNMENT_URL: http://${SERVICE_NAME}-am-role-assignment-service IDAM_OIDC_URL: https://idam-web-public.aat.platform.hmcts.net - OIDC_ISSUER: https://idam-web-public.aat.platform.hmcts.net/o + OIDC_ISSUER: https://forgerock-am.service.core-compute-idam-aat2.internal:8443/openam/oauth2/realms/root/realms/hmcts ELASTIC_SEARCH_ENABLED: true ELASTIC_SEARCH_NODES_DISCOVERY_ENABLED: true ELASTIC_SEARCH_HOSTS: "{{ .Release.Name }}-es-master:9200" From 638ca21e8ef2d9a08776a89df6f1dd3e401a5606 Mon Sep 17 00:00:00 2001 From: Dinesh Patel Date: Wed, 18 Mar 2026 14:43:43 +0000 Subject: [PATCH 10/29] Empty-Commit-To-Trigger-Build From 1ca9f42962885afd465a2c42da4088a9ef7b12dd Mon Sep 17 00:00:00 2001 From: dinesh1patel <74076102+dinesh1patel@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:49:10 +0000 Subject: [PATCH 11/29] Update build.gradle --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9afb5a5e3e..afadcd891e 100644 --- a/build.gradle +++ b/build.gradle @@ -306,7 +306,7 @@ dependencies { testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test' testImplementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-contract-stub-runner' testImplementation group: 'org.testcontainers', name: 'testcontainers', version: testContainersVersion - testImplementation group: 'org.testcontainers', name: 'postgresql', version: testContainersVersion + testImplementation group: 'org.testcontainers', name: 'postgresql', version: '1.21.4' testImplementation group: 'org.testcontainers', name: 'elasticsearch', version: testContainersVersion testImplementation group: 'org.testcontainers', name: 'junit-jupiter', version: testContainersVersion testImplementation group: 'org.openid4java', name: 'openid4java', version: '1.0.0' // for sonar analysis From 42f1db4266d80eee0cc1c5c6866ed2cd92bc9752 Mon Sep 17 00:00:00 2001 From: dinesh1patel <74076102+dinesh1patel@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:49:30 +0000 Subject: [PATCH 12/29] Update build.gradle --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index afadcd891e..254cce3da8 100644 --- a/build.gradle +++ b/build.gradle @@ -306,7 +306,7 @@ dependencies { testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test' testImplementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-contract-stub-runner' testImplementation group: 'org.testcontainers', name: 'testcontainers', version: testContainersVersion - testImplementation group: 'org.testcontainers', name: 'postgresql', version: '1.21.4' + testImplementation group: 'org.testcontainers', name: 'postgresql', version: '1.21.3' testImplementation group: 'org.testcontainers', name: 'elasticsearch', version: testContainersVersion testImplementation group: 'org.testcontainers', name: 'junit-jupiter', version: testContainersVersion testImplementation group: 'org.openid4java', name: 'openid4java', version: '1.0.0' // for sonar analysis From 4a83abdaaf6a316d0f52719aafecef35d3fc294e Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 18 Mar 2026 15:40:59 +0000 Subject: [PATCH 13/29] Add runtime Swagger guard for deployed service --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 254cce3da8..c99316f8c4 100644 --- a/build.gradle +++ b/build.gradle @@ -262,7 +262,7 @@ dependencies { implementation group: 'com.github.hmcts', name: 'service-auth-provider-java-client', version: '5.3.3' implementation group: 'com.github.hmcts', name: 'idam-java-client', version: '3.0.5' implementation group: 'com.github.hmcts', name: 'ccd-case-document-am-client', version: '1.59.2' - implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '6.1.9' + implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '8.0.0' implementation group: 'com.auth0', name: 'java-jwt', version: '4.5.1' implementation group: 'com.google.guava', name: 'guava', version: '33.5.0-jre' From 7717f6b0acbb96c305cab02af8cb419d5f81608e Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 19 Mar 2026 09:33:35 +0000 Subject: [PATCH 14/29] Harden JWT validation tests and Swagger runtime guard and Clarify JWT issuer validation behavior and config guidance --- ...ssuer-validation-patch-plan.md => jwt-issuer-validation.md} | 3 +++ src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java | 3 ++- .../uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) rename docs/security/{jwt-issuer-validation-patch-plan.md => jwt-issuer-validation.md} (94%) diff --git a/docs/security/jwt-issuer-validation-patch-plan.md b/docs/security/jwt-issuer-validation.md similarity index 94% rename from docs/security/jwt-issuer-validation-patch-plan.md rename to docs/security/jwt-issuer-validation.md index 99dd001f8d..498bc7ae39 100644 --- a/docs/security/jwt-issuer-validation-patch-plan.md +++ b/docs/security/jwt-issuer-validation.md @@ -49,6 +49,9 @@ Before rollout, confirm: - no pipeline or release-time override is supplying an older issuer value - external callers, smoke tests, and AAT clients obtain tokens whose `iss` claim matches this service's configured `OIDC_ISSUER` +Do not infer `OIDC_ISSUER` from the public OIDC discovery URL. In preview/AAT for this repo, the correct +`OIDC_ISSUER` had to be taken from decoded real tokens and did not match the public `IDAM_OIDC_URL` base. + If external services still send tokens with a different issuer, this change will reject them with `401` until configuration or token issuance is aligned. For local running, `IDAM_OIDC_URL` should point to the local OIDC discovery base, usually `http://localhost:5000`, and `OIDC_ISSUER` must exactly match the `iss` claim in the local access tokens being used. Common local values are `OIDC_ISSUER=http://fr-am:8080/openam/oauth2/hmcts` or `OIDC_ISSUER=http://localhost:5000/o`, depending on how the local token source is configured. diff --git a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java index 617bbcca22..d3366123fb 100644 --- a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java @@ -37,8 +37,9 @@ void shouldRejectJwtFromUnexpectedIssuer() { @Test void shouldRejectExpiredJwtEvenWhenIssuerMatches() { Instant now = Instant.now(); + // Keep expiry clearly outside the default clock-skew allowance to avoid boundary flakiness. assertTrue( - validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(120), now.minusSeconds(60))).hasErrors() + validator().validate(buildJwt(VALID_ISSUER, now.minusSeconds(300), now.minusSeconds(121))).hasErrors() ); } diff --git a/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java b/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java index 6618fc0c09..0bbd7d6740 100644 --- a/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java +++ b/src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java @@ -51,6 +51,8 @@ void shouldRejectJwtWhenIssuerDoesNotMatchConfiguredIssuer() throws JOSEExceptio String.class ); + // This integration harness currently surfaces the rejected JWT as 403, although deployed runtime + // invalid-issuer responses are expected to return 401 with invalid_token details. assertThat(response.getStatusCode().value()).isEqualTo(403); WireMock.verify(1, getRequestedFor(urlEqualTo("/s2s/details"))); WireMock.verify(0, getRequestedFor(urlEqualTo("/o/userinfo"))); From da5d915301670372e6f3ad5b912aac25af199a45 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 19 Mar 2026 11:05:02 +0000 Subject: [PATCH 15/29] Add JWT issuer validation guardrails and pipeline verification --- Jenkinsfile_CNP | 1 + Jenkinsfile_nightly | 1 + build.gradle | 17 +++++- docs/security/jwt-issuer-validation.md | 4 ++ .../befta/JwtIssuerVerificationApp.java | 57 +++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 7d79f90492..1c0aceb2a5 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -107,6 +107,7 @@ env.ROLE_ASSIGNMENT_API_GATEWAY_S2S_CLIENT_ID = "ccd_data" env.BEFTA_TEST_STUB_SERVICE_BASE_URL = "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal" env.BEFTA_S2S_CLIENT_ID_OF_CCD_DATA = "ccd_data" env.BEFTA_S2S_CLIENT_ID_OF_XUI_WEBAPP = "xui_webapp" +env.VERIFY_OIDC_ISSUER = "true" // BEFTA retry env variables env.BEFTA_RETRY_MAX_ATTEMPTS = "3" env.BEFTA_RETRY_STATUS_CODES = "500,502,503,504" diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 60d57c4334..8e3601679c 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -88,6 +88,7 @@ env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctsprod.azurecr.io/imported/" env.BEFTA_S2S_CLIENT_ID = "ccd_gw" env.ROLE_ASSIGNMENT_API_GATEWAY_S2S_CLIENT_ID = "ccd_data" env.BEFTA_S2S_CLIENT_ID_OF_CCD_DATA = "ccd_data" +env.VERIFY_OIDC_ISSUER = "true" env.DM_STORE_BASE_URL = "http://dm-store-aat.service.core-compute-aat.internal" env.CASE_DOCUMENT_AM_URL = "http://ccd-case-document-am-api-aat.service.core-compute-aat.internal" env.RD_LOCATION_REF_API_BASE_URL = "http://rd-location-ref-api-aat.service.core-compute-aat.internal" diff --git a/build.gradle b/build.gradle index c99316f8c4..6b2fc8cff7 100644 --- a/build.gradle +++ b/build.gradle @@ -735,9 +735,22 @@ task highLevelDataSetup(type: JavaExec) { jvmArgs = ['--add-opens=java.base/java.lang.reflect=ALL-UNNAMED'] } +task verifyFunctionalTestJwtIssuer(type: JavaExec) { + description = 'Verifies the functional/smoke test token issuer matches OIDC_ISSUER' + group = 'Verification' + dependsOn aatClasses + + onlyIf { + System.getenv('VERIFY_OIDC_ISSUER')?.toBoolean() + } + + mainClass = 'uk.gov.hmcts.ccd.datastore.befta.JwtIssuerVerificationApp' + classpath += configurations.cucumberRuntime + sourceSets.aat.runtimeClasspath +} + task smoke() { description = 'Executes smoke tests against an the CCD Data Store API instance just deployed' - dependsOn aatClasses + dependsOn aatClasses, verifyFunctionalTestJwtIssuer new File("$buildDir/test-results/test").mkdirs() copy { @@ -778,7 +791,7 @@ def tags = (findProperty('tags') == null) ? 'not @Ignore' : '(' + findProperty(' task functional(type: JavaExec) { description = "Executes functional tests against an the CCD Data Store API instance just deployed" group = "Verification" - dependsOn aatClasses + dependsOn aatClasses, verifyFunctionalTestJwtIssuer group = "Verification" diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index 498bc7ae39..d718673970 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -52,6 +52,10 @@ Before rollout, confirm: Do not infer `OIDC_ISSUER` from the public OIDC discovery URL. In preview/AAT for this repo, the correct `OIDC_ISSUER` had to be taken from decoded real tokens and did not match the public `IDAM_OIDC_URL` base. +Smoke and functional pipeline runs now perform a pre-check that acquires a real test token and fails fast if its +`iss` claim does not match `OIDC_ISSUER`. +This verifier is enabled in CI via `VERIFY_OIDC_ISSUER=true` and remains opt-in for local runs. + If external services still send tokens with a different issuer, this change will reject them with `401` until configuration or token issuance is aligned. For local running, `IDAM_OIDC_URL` should point to the local OIDC discovery base, usually `http://localhost:5000`, and `OIDC_ISSUER` must exactly match the `iss` claim in the local access tokens being used. Common local values are `OIDC_ISSUER=http://fr-am:8080/openam/oauth2/hmcts` or `OIDC_ISSUER=http://localhost:5000/o`, depending on how the local token source is configured. diff --git a/src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java b/src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java new file mode 100644 index 0000000000..298c8450a1 --- /dev/null +++ b/src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java @@ -0,0 +1,57 @@ +package uk.gov.hmcts.ccd.datastore.befta; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import uk.gov.hmcts.ccd.datastore.tests.Env; +import uk.gov.hmcts.ccd.datastore.tests.helper.idam.IdamHelper; +import uk.gov.hmcts.ccd.datastore.tests.helper.idam.OAuth2; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public final class JwtIssuerVerificationApp { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private JwtIssuerVerificationApp() { + } + + public static void main(String[] args) throws Exception { + String expectedIssuer = Env.require("OIDC_ISSUER"); + String idamBaseUrl = Env.require("IDAM_API_URL_BASE"); + String email = Env.require("CCD_CASEWORKER_AUTOTEST_EMAIL"); + String password = Env.require("CCD_CASEWORKER_AUTOTEST_PASSWORD"); + + IdamHelper idamHelper = new IdamHelper(idamBaseUrl, OAuth2.INSTANCE); + String accessToken = idamHelper.getIdamOauth2Token(email, password); + String actualIssuer = decodeIssuer(accessToken); + + if (!expectedIssuer.equals(actualIssuer)) { + throw new IllegalStateException( + "OIDC_ISSUER mismatch: expected `" + expectedIssuer + "` but token iss was `" + actualIssuer + "`" + ); + } + + System.out.println("Verified OIDC_ISSUER matches functional test token iss: " + actualIssuer); + } + + private static String decodeIssuer(String accessToken) throws Exception { + String[] parts = accessToken.split("\\."); + if (parts.length < 2) { + throw new IllegalStateException("Access token is not a JWT"); + } + + byte[] decodedPayload = Base64.getUrlDecoder().decode(padBase64(parts[1])); + JsonNode payload = OBJECT_MAPPER.readTree(new String(decodedPayload, StandardCharsets.UTF_8)); + JsonNode issuer = payload.get("iss"); + if (issuer == null || issuer.isNull()) { + throw new IllegalStateException("Access token does not contain an iss claim"); + } + return issuer.asText(); + } + + private static String padBase64(String value) { + int remainder = value.length() % 4; + return remainder == 0 ? value : value + "=".repeat(4 - remainder); + } +} From a30007b167dbf8a346724d78aeb9196bece92a2a Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 19 Mar 2026 11:33:46 +0000 Subject: [PATCH 16/29] Export OIDC_ISSUER for pipeline JWT issuer verification --- Jenkinsfile_CNP | 1 + Jenkinsfile_nightly | 1 + 2 files changed, 2 insertions(+) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 1c0aceb2a5..4a8402264f 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -107,6 +107,7 @@ env.ROLE_ASSIGNMENT_API_GATEWAY_S2S_CLIENT_ID = "ccd_data" env.BEFTA_TEST_STUB_SERVICE_BASE_URL = "http://ccd-test-stubs-service-aat.service.core-compute-aat.internal" env.BEFTA_S2S_CLIENT_ID_OF_CCD_DATA = "ccd_data" env.BEFTA_S2S_CLIENT_ID_OF_XUI_WEBAPP = "xui_webapp" +env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat2.internal:8443/openam/oauth2/realms/root/realms/hmcts" env.VERIFY_OIDC_ISSUER = "true" // BEFTA retry env variables env.BEFTA_RETRY_MAX_ATTEMPTS = "3" diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 8e3601679c..f90c4fefee 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -88,6 +88,7 @@ env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX = "hmctsprod.azurecr.io/imported/" env.BEFTA_S2S_CLIENT_ID = "ccd_gw" env.ROLE_ASSIGNMENT_API_GATEWAY_S2S_CLIENT_ID = "ccd_data" env.BEFTA_S2S_CLIENT_ID_OF_CCD_DATA = "ccd_data" +env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat2.internal:8443/openam/oauth2/realms/root/realms/hmcts" env.VERIFY_OIDC_ISSUER = "true" env.DM_STORE_BASE_URL = "http://dm-store-aat.service.core-compute-aat.internal" env.CASE_DOCUMENT_AM_URL = "http://ccd-case-document-am-api-aat.service.core-compute-aat.internal" From 706bf804c692a008ad314d447bca90ddf1c2a812 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 19 Mar 2026 18:42:11 +0000 Subject: [PATCH 17/29] Strengthen repo security skill for JWT issuer workflow --- README.md | 2 ++ docs/skills/security/SKILL.md | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 71f1c3e4e9..04227270ce 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ The following environment variables are required: `IDAM_OIDC_URL` and `OIDC_ISSUER` are intentionally separate. Discovery and JWKS retrieval use `IDAM_OIDC_URL`, while JWT validation enforces `OIDC_ISSUER`. If these do not align with the issuer used in real caller tokens, authenticated requests will be rejected with `401`. +For Codex-based JWT or security work in this repo, explicitly tell Codex to use skill at `docs/skills/security/SKILL.md`. + ### Building The project uses [Gradle](https://gradle.org/). diff --git a/docs/skills/security/SKILL.md b/docs/skills/security/SKILL.md index ac2da956a8..da8ea3c6f4 100644 --- a/docs/skills/security/SKILL.md +++ b/docs/skills/security/SKILL.md @@ -7,12 +7,26 @@ description: Use when working in the HMCTS `ccd-data-store-api` repository on au ## Overview -Use this skill for security changes in `ccd-data-store-api`, especially around JWT validation, IDAM issuer settings, and narrowly scoped regression tests. +Use this skill for security changes in `ccd-data-store-api`, especially around JWT validation, IDAM issuer settings, Spring Security wiring, and narrowly scoped regression tests. + +Read `docs/security/jwt-issuer-validation.md` first for the repo-specific JWT behavior, config split, and rollout notes. ## Workflow 1. Check current state with `git status --short` and inspect local diffs before editing. 2. Review `SecurityConfiguration` together with issuer-related properties and verify which issuer value is meant for validation. -3. Search for `issuer`, `JwtDecoder`, `JwtIssuerValidator`, `JwtTimestampValidator`, and `oidc.issuer` before changing behavior. -4. Start verification with the narrowest relevant Gradle test, usually `./gradlew test --tests uk.gov.hmcts.ccd.SecurityConfigurationTest`. -5. Preserve any in-flight local work and continue from the existing patch state instead of recreating it. +3. Search for `issuer`, `JwtDecoder`, `JwtIssuerValidator`, `JwtTimestampValidator`, `oidc.issuer`, `IDAM_OIDC_URL`, and `OIDC_ISSUER` before changing behavior. +4. Treat OIDC discovery and issuer validation as separate concerns: + - `IDAM_OIDC_URL` is for discovery and JWKS lookup. + - `OIDC_ISSUER` is the exact enforced `iss` claim. +5. Do not infer `OIDC_ISSUER` from the public OIDC URL. Decode a real token and compare its `iss` to the configured issuer before changing preview/AAT/pipeline values. +6. For JWT coverage in this repo, prefer the existing two-layer approach: + - validator-level checks in `SecurityConfigurationTest` + - integration wiring checks in `JwtIssuerValidationIT` + Do not reintroduce a Boot-based lightweight security slice test unless you are sure it will not interfere with the wider Spring test context. +7. If changing smoke or functional JWT behavior, also check the pipeline-side verifier: + - `verifyFunctionalTestJwtIssuer` runs before smoke/functional tests + - it runs in the Jenkins/test JVM, not in the deployed app container + - Jenkins must therefore export `OIDC_ISSUER` as well as the Helm chart +8. Start verification with the narrowest relevant Gradle test, usually `./gradlew test --tests uk.gov.hmcts.ccd.SecurityConfigurationTest`. +9. Preserve any in-flight local work and continue from the existing patch state instead of recreating it. From f4c2fe0fd45f1fdf7940f88ba63b03b483caae8b Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 20 Mar 2026 09:42:44 +0000 Subject: [PATCH 18/29] Add repo-local security skill and JWT pipeline issuer guard --- AGENTS.md | 5 +++++ README.md | 2 +- build.gradle | 2 +- docs/security/jwt-issuer-validation.md | 2 ++ docs/skills/security/agents/openai.yaml | 7 ------- 5 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 AGENTS.md delete mode 100644 docs/skills/security/agents/openai.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..0cff199cd7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Repo-local Codex guidance + +For JWT, IDAM, Spring Security, and related regression-test work in this repo, use the repo-local skill at `docs/skills/security/SKILL.md`. + +When resuming or reviewing security changes, explicitly follow that skill before making code, test, or pipeline changes. diff --git a/README.md b/README.md index 04227270ce..7dce4ed80f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The following environment variables are required: `IDAM_OIDC_URL` and `OIDC_ISSUER` are intentionally separate. Discovery and JWKS retrieval use `IDAM_OIDC_URL`, while JWT validation enforces `OIDC_ISSUER`. If these do not align with the issuer used in real caller tokens, authenticated requests will be rejected with `401`. -For Codex-based JWT or security work in this repo, explicitly tell Codex to use skill at `docs/skills/security/SKILL.md`. +For Codex-based JWT or security work in this repo, say `Use docs/skills/security/SKILL.md`. ### Building diff --git a/build.gradle b/build.gradle index 6b2fc8cff7..073c520df7 100644 --- a/build.gradle +++ b/build.gradle @@ -728,7 +728,7 @@ idea { } task highLevelDataSetup(type: JavaExec) { - dependsOn aatClasses + dependsOn aatClasses, verifyFunctionalTestJwtIssuer mainClass = "uk.gov.hmcts.ccd.datastore.befta.HighLevelDataSetupApp" classpath += configurations.cucumberRuntime + sourceSets.aat.runtimeClasspath diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index d718673970..57bf0be1c4 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -55,6 +55,8 @@ Do not infer `OIDC_ISSUER` from the public OIDC discovery URL. In preview/AAT fo Smoke and functional pipeline runs now perform a pre-check that acquires a real test token and fails fast if its `iss` claim does not match `OIDC_ISSUER`. This verifier is enabled in CI via `VERIFY_OIDC_ISSUER=true` and remains opt-in for local runs. +Because the verifier runs in the build JVM before deployed app env is available, issuer changes may need updating in +both Jenkins test env and Helm app config. If external services still send tokens with a different issuer, this change will reject them with `401` until configuration or token issuance is aligned. diff --git a/docs/skills/security/agents/openai.yaml b/docs/skills/security/agents/openai.yaml deleted file mode 100644 index 964806f5b3..0000000000 --- a/docs/skills/security/agents/openai.yaml +++ /dev/null @@ -1,7 +0,0 @@ -interface: - display_name: "Security" - short_description: "JWT and Spring security workflow" - default_prompt: "Use $security to continue or verify JWT, IDAM, and Spring Security changes in ccd-data-store-api." - -policy: - allow_implicit_invocation: true From 9fd1f7ba11ca325da17ef38213782b77d21568ef Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 20 Mar 2026 10:42:30 +0000 Subject: [PATCH 19/29] fix for Could not get unknown property 'verifyFunctionalTestJwtIssuer' for task ':highLevelDataSetup' of type org.gradle.api.tasks.JavaExec. --- build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 073c520df7..cda1f5d711 100644 --- a/build.gradle +++ b/build.gradle @@ -728,7 +728,7 @@ idea { } task highLevelDataSetup(type: JavaExec) { - dependsOn aatClasses, verifyFunctionalTestJwtIssuer + dependsOn 'aatClasses', 'verifyFunctionalTestJwtIssuer' mainClass = "uk.gov.hmcts.ccd.datastore.befta.HighLevelDataSetupApp" classpath += configurations.cucumberRuntime + sourceSets.aat.runtimeClasspath @@ -750,7 +750,7 @@ task verifyFunctionalTestJwtIssuer(type: JavaExec) { task smoke() { description = 'Executes smoke tests against an the CCD Data Store API instance just deployed' - dependsOn aatClasses, verifyFunctionalTestJwtIssuer + dependsOn 'aatClasses', 'verifyFunctionalTestJwtIssuer' new File("$buildDir/test-results/test").mkdirs() copy { @@ -791,7 +791,7 @@ def tags = (findProperty('tags') == null) ? 'not @Ignore' : '(' + findProperty(' task functional(type: JavaExec) { description = "Executes functional tests against an the CCD Data Store API instance just deployed" group = "Verification" - dependsOn aatClasses, verifyFunctionalTestJwtIssuer + dependsOn 'aatClasses', 'verifyFunctionalTestJwtIssuer' group = "Verification" From 67567be0f3f098e80aa67083b1f36e6a116dffe9 Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 20 Mar 2026 14:36:49 +0000 Subject: [PATCH 20/29] Document repo-local security skill and tighten JWT verifier guidance --- AGENTS.md | 8 +++++--- README.md | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0cff199cd7..ef7f2d4bc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ -# Repo-local Codex guidance +# Agent Instructions -For JWT, IDAM, Spring Security, and related regression-test work in this repo, use the repo-local skill at `docs/skills/security/SKILL.md`. +## Available skills -When resuming or reviewing security changes, explicitly follow that skill before making code, test, or pipeline changes. +- `docs/skills/security/SKILL.md` + Use for JWT issuer validation, Spring Security, IDAM/OIDC, Helm/Jenkins issuer settings, and related regression testing. + Prompt cue: `Use docs/skills/security/SKILL.md` diff --git a/README.md b/README.md index 7dce4ed80f..4234f9f7e3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ The following environment variables are required: `IDAM_OIDC_URL` and `OIDC_ISSUER` are intentionally separate. Discovery and JWKS retrieval use `IDAM_OIDC_URL`, while JWT validation enforces `OIDC_ISSUER`. If these do not align with the issuer used in real caller tokens, authenticated requests will be rejected with `401`. -For Codex-based JWT or security work in this repo, say `Use docs/skills/security/SKILL.md`. +### Codex Skill +Repo-local workflow docs are indexed in `AGENTS.md`. ### Building From abadadb554e9609ac8f67c033ad0c6018b392d34 Mon Sep 17 00:00:00 2001 From: patelila Date: Fri, 20 Mar 2026 14:51:10 +0000 Subject: [PATCH 21/29] Make pipeline JWT issuer verifier use available test credentials --- .../befta/JwtIssuerVerificationApp.java | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java b/src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java index 298c8450a1..6b196a7050 100644 --- a/src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java +++ b/src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java @@ -19,11 +19,13 @@ private JwtIssuerVerificationApp() { public static void main(String[] args) throws Exception { String expectedIssuer = Env.require("OIDC_ISSUER"); String idamBaseUrl = Env.require("IDAM_API_URL_BASE"); - String email = Env.require("CCD_CASEWORKER_AUTOTEST_EMAIL"); - String password = Env.require("CCD_CASEWORKER_AUTOTEST_PASSWORD"); + String[] credentials = firstAvailableCredentials( + "CCD_CASEWORKER_AUTOTEST_EMAIL", "CCD_CASEWORKER_AUTOTEST_PASSWORD", + "DEFINITION_IMPORTER_USERNAME", "DEFINITION_IMPORTER_PASSWORD" + ); IdamHelper idamHelper = new IdamHelper(idamBaseUrl, OAuth2.INSTANCE); - String accessToken = idamHelper.getIdamOauth2Token(email, password); + String accessToken = idamHelper.getIdamOauth2Token(credentials[0], credentials[1]); String actualIssuer = decodeIssuer(accessToken); if (!expectedIssuer.equals(actualIssuer)) { @@ -35,6 +37,22 @@ public static void main(String[] args) throws Exception { System.out.println("Verified OIDC_ISSUER matches functional test token iss: " + actualIssuer); } + private static String[] firstAvailableCredentials(String... envNames) { + for (int i = 0; i < envNames.length; i += 2) { + String username = System.getenv(envNames[i]); + String password = System.getenv(envNames[i + 1]); + if (username != null && password != null) { + return new String[]{username, password}; + } + } + + throw new IllegalStateException( + "No credentials available for JWT issuer verification. " + + "Expected one of: CCD_CASEWORKER_AUTOTEST_EMAIL/PASSWORD or " + + "DEFINITION_IMPORTER_USERNAME/PASSWORD" + ); + } + private static String decodeIssuer(String accessToken) throws Exception { String[] parts = accessToken.split("\\."); if (parts.length < 2) { From a17ecc6302f90671c7186b87fdb1771e6859f3a5 Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 23 Mar 2026 09:40:24 +0000 Subject: [PATCH 22/29] Add repo-local Codex workflow docs for security and JWT issuer work --- AGENTS.md | 6 ++++- README.md | 2 +- docs/security/jwt-issuer-validation.md | 4 +++ docs/skills/security-jwt-issuer/SKILL.md | 28 +++++++++++++++++++++ docs/skills/security/SKILL.md | 31 +++++++++--------------- 5 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 docs/skills/security-jwt-issuer/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index ef7f2d4bc6..ce8a28135f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,5 +3,9 @@ ## Available skills - `docs/skills/security/SKILL.md` - Use for JWT issuer validation, Spring Security, IDAM/OIDC, Helm/Jenkins issuer settings, and related regression testing. + Use for broader Spring Security, IDAM/OIDC, auth filter, and security-related regression work in this repo. Prompt cue: `Use docs/skills/security/SKILL.md` + +- `docs/skills/security-jwt-issuer/SKILL.md` + Use for JWT issuer validation, issuer mismatch diagnosis, token `iss` checks, and pipeline verifier updates. + Prompt cue: `Use docs/skills/security-jwt-issuer/SKILL.md` diff --git a/README.md b/README.md index 4234f9f7e3..9b4495c46a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The following environment variables are required: `IDAM_OIDC_URL` and `OIDC_ISSUER` are intentionally separate. Discovery and JWKS retrieval use `IDAM_OIDC_URL`, while JWT validation enforces `OIDC_ISSUER`. If these do not align with the issuer used in real caller tokens, authenticated requests will be rejected with `401`. -### Codex Skill +### Codex Workflow Docs Repo-local workflow docs are indexed in `AGENTS.md`. ### Building diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index 57bf0be1c4..f241cbcc01 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -1,5 +1,9 @@ # JWT issuer validation +## Service + +`ccd-data-store-api` + ## Summary This change re-enables issuer validation in `ccd-data-store-api` so JWTs must match `oidc.issuer` as well as pass timestamp checks. diff --git a/docs/skills/security-jwt-issuer/SKILL.md b/docs/skills/security-jwt-issuer/SKILL.md new file mode 100644 index 0000000000..a0952f08ed --- /dev/null +++ b/docs/skills/security-jwt-issuer/SKILL.md @@ -0,0 +1,28 @@ +--- +name: security-jwt-issuer +description: Use for JWT issuer validation, issuer mismatch diagnosis, token iss checks, and pipeline verifier updates in ccd-data-store-api. +--- + +# Security JWT Issuer + +Use this skill when working specifically on JWT issuer validation in `ccd-data-store-api`. + +Read `docs/security/jwt-issuer-validation.md` first for the detailed behavior, config, and rollout guidance. + +## Workflow + +1. Check current diffs with `git status --short` before editing. +2. Review `SecurityConfiguration` and confirm how `IDAM_OIDC_URL` and `OIDC_ISSUER` are used. +3. For code changes, check: + - `src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java` + - `src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java` + - `src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java` +4. For pipeline/test-run alignment, check: + - `src/aat/java/uk/gov/hmcts/ccd/datastore/befta/JwtIssuerVerificationApp.java` + - `build.gradle` + - `Jenkinsfile_CNP` + - `Jenkinsfile_nightly` +5. For issuer values, token `iss` diagnosis, CI verifier behavior, and Helm vs Jenkins env alignment, follow `docs/security/jwt-issuer-validation.md` rather than duplicating that guidance here. +6. Start verification with the narrowest useful test: + - `./gradlew test --tests uk.gov.hmcts.ccd.SecurityConfigurationTest` +7. Preserve in-flight local work and continue from the existing patch state rather than recreating it. diff --git a/docs/skills/security/SKILL.md b/docs/skills/security/SKILL.md index da8ea3c6f4..0f5666ee33 100644 --- a/docs/skills/security/SKILL.md +++ b/docs/skills/security/SKILL.md @@ -1,32 +1,25 @@ --- name: security -description: Use when working in the HMCTS `ccd-data-store-api` repository on authentication, JWT issuer validation, Spring Security configuration, IDAM/OIDC integration, or related regression testing. This skill is for resuming in-flight security patches, checking local diffs, validating issuer and decoder behavior, and running focused Gradle tests before and after code changes. +description: Use when working in the HMCTS `ccd-data-store-api` repository on Spring Security configuration, auth filters, IDAM/OIDC integration, or related regression testing. This skill is for resuming in-flight security patches, checking local diffs, and running focused Gradle tests before and after code changes. --- # Security ## Overview -Use this skill for security changes in `ccd-data-store-api`, especially around JWT validation, IDAM issuer settings, Spring Security wiring, and narrowly scoped regression tests. +Use this skill for broader security changes in `ccd-data-store-api`, especially around Spring Security wiring, +auth filters, IDAM/OIDC integration, and narrowly scoped regression tests. -Read `docs/security/jwt-issuer-validation.md` first for the repo-specific JWT behavior, config split, and rollout notes. +For JWT issuer validation, issuer mismatch diagnosis, token `iss` checks, and pipeline issuer verification, +use `docs/skills/security-jwt-issuer/SKILL.md`. ## Workflow 1. Check current state with `git status --short` and inspect local diffs before editing. -2. Review `SecurityConfiguration` together with issuer-related properties and verify which issuer value is meant for validation. -3. Search for `issuer`, `JwtDecoder`, `JwtIssuerValidator`, `JwtTimestampValidator`, `oidc.issuer`, `IDAM_OIDC_URL`, and `OIDC_ISSUER` before changing behavior. -4. Treat OIDC discovery and issuer validation as separate concerns: - - `IDAM_OIDC_URL` is for discovery and JWKS lookup. - - `OIDC_ISSUER` is the exact enforced `iss` claim. -5. Do not infer `OIDC_ISSUER` from the public OIDC URL. Decode a real token and compare its `iss` to the configured issuer before changing preview/AAT/pipeline values. -6. For JWT coverage in this repo, prefer the existing two-layer approach: - - validator-level checks in `SecurityConfigurationTest` - - integration wiring checks in `JwtIssuerValidationIT` - Do not reintroduce a Boot-based lightweight security slice test unless you are sure it will not interfere with the wider Spring test context. -7. If changing smoke or functional JWT behavior, also check the pipeline-side verifier: - - `verifyFunctionalTestJwtIssuer` runs before smoke/functional tests - - it runs in the Jenkins/test JVM, not in the deployed app container - - Jenkins must therefore export `OIDC_ISSUER` as well as the Helm chart -8. Start verification with the narrowest relevant Gradle test, usually `./gradlew test --tests uk.gov.hmcts.ccd.SecurityConfigurationTest`. -9. Preserve any in-flight local work and continue from the existing patch state instead of recreating it. +2. Review `SecurityConfiguration` together with the auth filters and related properties before changing behavior. +3. Search for relevant security components before editing, for example `SecurityFilterChain`, `ServiceAuthFilter`, + `ExceptionHandlingFilter`, `SecurityLoggingFilter`, and IDAM/OIDC config. +4. Keep JWT issuer-specific changes out of this path unless they are tightly coupled. For issuer work, switch to + `docs/skills/security-jwt-issuer/SKILL.md`. +5. Start verification with the narrowest relevant tests for the touched area. +6. Preserve any in-flight local work and continue from the existing patch state instead of recreating it. From 085edb4815421c9b5b76a8c65ac99724b72875af Mon Sep 17 00:00:00 2001 From: patelila Date: Mon, 23 Mar 2026 11:16:02 +0000 Subject: [PATCH 23/29] Add repo-local Codex workflow docs for security and JWT issuer work --- .../hmcts/ccd/SecurityConfigurationTest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java index d3366123fb..731615fcaf 100644 --- a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java @@ -5,12 +5,29 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; + +import org.springframework.security.oauth2.jwt.JwtValidationException; import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import java.text.ParseException; import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static uk.gov.hmcts.ccd.util.KeyGenerator.getRsaJWK; // Validator-level coverage for issuer and timestamp enforcement. class SecurityConfigurationTest { @@ -34,6 +51,16 @@ void shouldRejectJwtFromUnexpectedIssuer() { ); } + @Test + void shouldRejectDecodedJwtFromUnexpectedIssClaim() throws JOSEException, ParseException { + JwtValidationException exception = assertThrows( + JwtValidationException.class, + () -> decoder().decode(signedJwt(INVALID_ISSUER)) + ); + + assertThat(exception.getMessage()).contains("iss"); + } + @Test void shouldRejectExpiredJwtEvenWhenIssuerMatches() { Instant now = Instant.now(); @@ -50,6 +77,12 @@ private OAuth2TokenValidator validator() { ); } + private NimbusJwtDecoder decoder() throws JOSEException { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(getRsaJWK().toRSAPublicKey()).build(); + decoder.setJwtValidator(validator()); + return decoder; + } + private Jwt buildJwt(String issuer, Instant issuedAt, Instant expiresAt) { return Jwt.withTokenValue("token") .header("alg", "RS256") @@ -59,4 +92,24 @@ private Jwt buildJwt(String issuer, Instant issuedAt, Instant expiresAt) { .expiresAt(expiresAt) .build(); } + + private String signedJwt(String issuer) throws JOSEException, ParseException { + Instant now = Instant.now(); + + SignedJWT signedJwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(getRsaJWK().getKeyID()) + .build(), + new JWTClaimsSet.Builder() + .jwtID(UUID.randomUUID().toString()) + .issuer(issuer) + .subject("user") + .issueTime(Date.from(now.minusSeconds(60))) + .expirationTime(Date.from(now.plusSeconds(300))) + .build() + ); + signedJwt.sign(new RSASSASigner(getRsaJWK().toPrivateKey())); + return signedJwt.serialize(); + } } From bac5bb2fbf5221f595215daba07ecae2eb5d8d74 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 24 Mar 2026 09:30:55 +0000 Subject: [PATCH 24/29] docs: add OIDC_ISSUER derivation guidance --- README.md | 2 +- docs/security/jwt-issuer-validation.md | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b4495c46a..430554ce3f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The following environment variables are required: | IDAM_USER_URL | - | Base URL for IdAM's User API service (idam-app). `http://localhost:4501` for the dockerised local instance or tunneled `dev` instance. | | IDAM_S2S_URL | - | Base URL for IdAM's S2S API service (service-auth-provider). `http://localhost:4502` for the dockerised local instance or tunneled `dev` instance. | | IDAM_OIDC_URL | - | Base URL for IdAM OIDC discovery and JWKS lookup. This is used to resolve the OpenID configuration and signing keys. | -| OIDC_ISSUER | - | Enforced JWT issuer value. This must match the `iss` claim in real access tokens accepted by this service. | +| OIDC_ISSUER | - | Enforced JWT issuer value. This must match the `iss` claim in real access tokens accepted by this service. Do not guess it; derive it from a real token for the target environment. | | USER_PROFILE_HOST | - | Base URL for the User Profile service. `http://localhost:4453` for the dockerised local instance. | | DEFINITION_STORE_HOST | - | Base URL for the Definition Store service. `http://localhost:4451` for the dockerised local instance. | | CCD_DOCUMENT_URL_PATTERN | - | URL Pattern for documents attachable to cases. | diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index f241cbcc01..81434ed308 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -56,6 +56,29 @@ Before rollout, confirm: Do not infer `OIDC_ISSUER` from the public OIDC discovery URL. In preview/AAT for this repo, the correct `OIDC_ISSUER` had to be taken from decoded real tokens and did not match the public `IDAM_OIDC_URL` base. +## How to derive `OIDC_ISSUER` + +- Do not guess the issuer from the public discovery URL alone. +- Decode only the JWT payload from a real access token for the target environment and inspect the `iss` claim. +- Do not store or document full bearer tokens. Record only the derived issuer value. + +Example: + +```bash +TOKEN='eyJ...' +PAYLOAD=$(printf '%s' "$TOKEN" | cut -d '.' -f2) +python3 - <<'PY' "$PAYLOAD" +import base64, json, sys +s = sys.argv[1] +s += '=' * (-len(s) % 4) +print(json.loads(base64.urlsafe_b64decode(s))["iss"]) +PY +``` + +- JWTs are `header.payload.signature`. +- The second segment is base64url-encoded JSON. +- This decodes the payload only. It does not verify the signature. + Smoke and functional pipeline runs now perform a pre-check that acquires a real test token and fails fast if its `iss` claim does not match `OIDC_ISSUER`. This verifier is enabled in CI via `VERIFY_OIDC_ISSUER=true` and remains opt-in for local runs. From 1d4eb130c554586fb6881711117a5448f2abd144 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 24 Mar 2026 10:08:51 +0000 Subject: [PATCH 25/29] docs: clarify JWT issuer payload decode example --- docs/security/jwt-issuer-validation.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index 81434ed308..eef51e61e6 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -56,6 +56,16 @@ Before rollout, confirm: Do not infer `OIDC_ISSUER` from the public OIDC discovery URL. In preview/AAT for this repo, the correct `OIDC_ISSUER` had to be taken from decoded real tokens and did not match the public `IDAM_OIDC_URL` base. +Smoke and functional pipeline runs now perform a pre-check that acquires a real test token and fails fast if its +`iss` claim does not match `OIDC_ISSUER`. +This verifier is enabled in CI via `VERIFY_OIDC_ISSUER=true` and remains opt-in for local runs. +Because the verifier runs in the build JVM before deployed app env is available, issuer changes may need updating in +both Jenkins test env and Helm app config. + +If external services still send tokens with a different issuer, this change will reject them with `401` until configuration or token issuance is aligned. + +For local running, `IDAM_OIDC_URL` should point to the local OIDC discovery base, usually `http://localhost:5000`, and `OIDC_ISSUER` must exactly match the `iss` claim in the local access tokens being used. Common local values are `OIDC_ISSUER=http://fr-am:8080/openam/oauth2/hmcts` or `OIDC_ISSUER=http://localhost:5000/o`, depending on how the local token source is configured. + ## How to derive `OIDC_ISSUER` - Do not guess the issuer from the public discovery URL alone. @@ -69,9 +79,9 @@ TOKEN='eyJ...' PAYLOAD=$(printf '%s' "$TOKEN" | cut -d '.' -f2) python3 - <<'PY' "$PAYLOAD" import base64, json, sys -s = sys.argv[1] -s += '=' * (-len(s) % 4) -print(json.loads(base64.urlsafe_b64decode(s))["iss"]) +payload = sys.argv[1] +payload += '=' * (-len(payload) % 4) +print(json.loads(base64.urlsafe_b64decode(payload))["iss"]) PY ``` @@ -79,16 +89,6 @@ PY - The second segment is base64url-encoded JSON. - This decodes the payload only. It does not verify the signature. -Smoke and functional pipeline runs now perform a pre-check that acquires a real test token and fails fast if its -`iss` claim does not match `OIDC_ISSUER`. -This verifier is enabled in CI via `VERIFY_OIDC_ISSUER=true` and remains opt-in for local runs. -Because the verifier runs in the build JVM before deployed app env is available, issuer changes may need updating in -both Jenkins test env and Helm app config. - -If external services still send tokens with a different issuer, this change will reject them with `401` until configuration or token issuance is aligned. - -For local running, `IDAM_OIDC_URL` should point to the local OIDC discovery base, usually `http://localhost:5000`, and `OIDC_ISSUER` must exactly match the `iss` claim in the local access tokens being used. Common local values are `OIDC_ISSUER=http://fr-am:8080/openam/oauth2/hmcts` or `OIDC_ISSUER=http://localhost:5000/o`, depending on how the local token source is configured. - ## Optional future variant Only switch to multi-issuer validation if production tokens genuinely need both values during migration. In that case, use an explicit allow-list for issuer values rather than dropping issuer validation. From ce509db41561058db532d1cbe91c2bf263d893cf Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 26 Mar 2026 11:39:29 +0000 Subject: [PATCH 26/29] Align OIDC issuer config and stabilize WireMock integration tests --- build.gradle | 30 ++++++++++++++ charts/ccd-data-store-api/values.yaml | 2 +- docs/security/jwt-issuer-validation.md | 39 +++++++++++++++++++ .../resources/application.properties | 2 +- .../gov/hmcts/ccd/SecurityConfiguration.java | 4 +- src/main/resources/application.properties | 2 +- .../hmcts/ccd/SecurityConfigurationTest.java | 2 +- .../uk/gov/hmcts/ccd/WireMockBaseTest.java | 2 + .../CaseLinkServiceExternalDbDeadlockIT.java | 3 +- .../integrations/DefinitionsCachingIT.java | 1 + src/test/resources/test.properties | 1 + 11 files changed, 81 insertions(+), 7 deletions(-) diff --git a/build.gradle b/build.gradle index 7e31e87cf7..322e123591 100644 --- a/build.gradle +++ b/build.gradle @@ -748,6 +748,36 @@ task verifyFunctionalTestJwtIssuer(type: JavaExec) { classpath += configurations.cucumberRuntime + sourceSets.aat.runtimeClasspath } +task verifyOidcIssuerPolicy { + description = 'Fails if oidc.issuer is derived from discovery configuration' + group = 'Verification' + + doLast { + def policyFiles = [ + 'src/main/resources/application.properties', + 'src/test/resources/test.properties', + 'src/contractTest/resources/application.properties' + ] + + def violations = policyFiles.findAll { path -> + def file = file(path) + file.exists() && file.readLines().any { line -> + def normalized = line.replaceAll(/\s+/, '') + normalized.startsWith('oidc.issuer=') && normalized.contains('IDAM_OIDC_URL') + || normalized.startsWith('oidc.issuer=') && normalized.contains('idam.api.url') + } + } + + if (!violations.isEmpty()) { + throw new GradleException( + "OIDC issuer policy violation. Do not derive oidc.issuer from discovery config in: ${violations.join(', ')}" + ) + } + } +} + +check.dependsOn verifyOidcIssuerPolicy + task smoke() { description = 'Executes smoke tests against an the CCD Data Store API instance just deployed' dependsOn 'aatClasses', 'verifyFunctionalTestJwtIssuer' diff --git a/charts/ccd-data-store-api/values.yaml b/charts/ccd-data-store-api/values.yaml index 9bb268fecd..8caab5c4de 100644 --- a/charts/ccd-data-store-api/values.yaml +++ b/charts/ccd-data-store-api/values.yaml @@ -76,7 +76,7 @@ java: CCD_DOCUMENT_URL_PATTERN: ^https?://(((?:api-gateway\.preprod\.dm\.reform\.hmcts\.net|dm-store-{{ .Values.global.environment }}\.service\.core-compute-{{ .Values.global.environment }}\.internal(?::\d+)?)\/documents\/[A-Za-z0-9-]+(?:\/binary)?)|(em-hrs-api-{{ .Values.global.environment }}\.service\.core-compute-{{ .Values.global.environment }}\.internal(?::\d+)?\/hearing-recordings\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\/((segments\/[0-9]+)|(file/\S+)))) IDAM_API_BASE_URL: https://idam-api.{{ .Values.global.environment }}.platform.hmcts.net IDAM_OIDC_URL: https://idam-web-public.{{ .Values.global.environment }}.platform.hmcts.net - OIDC_ISSUER: https://forgerock-am.service.core-compute-idam-{{ .Values.global.environment }}.internal:8443/openam/oauth2/hmcts + OIDC_ISSUER: https://forgerock-am.service.core-compute-idam-{{ .Values.global.environment }}.internal:8443/openam/oauth2/realms/root/realms/hmcts CCD_DRAFT_STORE_URL: http://draft-store-service-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal CCD_DEFAULTPRINTURL: https://return-case-doc-ccd.nonprod.platform.hmcts.net/jurisdictions/:jid/case-types/:ctid/cases/:cid CASE_DOCUMENT_AM_URL: http://ccd-case-document-am-api-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index eef51e61e6..306cc18df5 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -92,3 +92,42 @@ PY ## Optional future variant Only switch to multi-issuer validation if production tokens genuinely need both values during migration. In that case, use an explicit allow-list for issuer values rather than dropping issuer validation. + +## Acceptance Checklist + +Before merging JWT issuer-validation changes, confirm all of the following: + +- The active `JwtDecoder` is built from `spring.security.oauth2.client.provider.oidc.issuer-uri`. +- The active validator chain includes both `JwtTimestampValidator` and `JwtIssuerValidator(oidc.issuer)`. +- There is no disabled, commented-out, or alternate runtime path that leaves issuer validation off. +- `issuer-uri` is used for discovery and JWKS lookup only. +- `oidc.issuer` / `OIDC_ISSUER` is used as the enforced token `iss` value only. +- `OIDC_ISSUER` is explicitly configured and not guessed from the discovery URL. +- App config, Helm values, preview values, and CI/Jenkins values are aligned for the target environment. +- If `OIDC_ISSUER` changed, it was verified against a real token for the target environment. +- There is a test that accepts a token with the expected issuer. +- There is a test that rejects a token with an unexpected issuer. +- There is a test that rejects an expired token. +- There is decoder-level coverage using a signed token, not only validator-only coverage. +- At least one failure assertion clearly proves issuer rejection, for example by checking for `iss`. +- CI or build verification checks that a real token issuer matches `OIDC_ISSUER`, or the repo documents why that does not apply. +- Comments and docs do not describe the old insecure behavior. +- Any repo-specific difference from peer services is intentional and documented. + +Do not merge if any of the following are true: + +- issuer validation is constructed but not applied +- only timestamp validation is active +- `OIDC_ISSUER` was inferred rather than verified +- Helm and CI/Jenkins issuer values disagree without explanation +- only happy-path tests exist + +## Configuration Policy + +- `spring.security.oauth2.client.provider.oidc.issuer-uri` is used for OIDC discovery and JWKS lookup only. +- `oidc.issuer` / `OIDC_ISSUER` is the enforced JWT issuer and must match the token `iss` claim exactly. +- Do not derive `OIDC_ISSUER` from `IDAM_OIDC_URL` or the discovery URL. +- Production-like environments must provide `OIDC_ISSUER` explicitly. +- Requiring explicit `OIDC_ISSUER` with no static fallback in main runtime config is the preferred pattern. This repo already follows that stricter pattern. +- Local or test-only fallbacks are acceptable only when they are static, intentional, and clearly scoped to non-production use. +- The build enforces this policy with `verifyOidcIssuerPolicy`, which fails if `oidc.issuer` is derived from discovery config. diff --git a/src/contractTest/resources/application.properties b/src/contractTest/resources/application.properties index 064ab7e8e2..c10f579b7a 100644 --- a/src/contractTest/resources/application.properties +++ b/src/contractTest/resources/application.properties @@ -77,7 +77,7 @@ spring.security.oauth2.client.provider.oidc.issuer-uri = ${idam.api.url}/o # Dummy oidc client required even though data-store doesn't use spring.security.oauth2.client.registration.oidc.client-id = internal spring.security.oauth2.client.registration.oidc.client-secret = internal -oidc.issuer = ${OIDC_ISSUER:http://fr-am:8080/openam/oauth2/hmcts} +oidc.issuer = ${OIDC_ISSUER:http://localhost:5000/o} # Required for the ServiceAuthorisationApi class in service-auth-provider-java-client library idam.s2s-auth.totp_secret=${DATA_STORE_IDAM_KEY:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB} diff --git a/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java b/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java index 9119aa157c..53d53eac8d 100644 --- a/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java +++ b/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java @@ -103,7 +103,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(csrf -> csrf.disable()) // NOSONAR - CSRF is disabled purposely .formLogin(fl -> fl.disable()) .logout(logout -> logout.disable()) - .authorizeHttpRequests(auth -> + .authorizeHttpRequests(auth -> auth.requestMatchers("/error") .permitAll() .anyRequest() @@ -117,7 +117,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti JwtDecoder jwtDecoder() { NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)JwtDecoders.fromOidcIssuerLocation(issuerUri); - // We are using issuerOverride instead of issuerUri as SIDAM has the wrong issuer at the moment + // See docs/security/jwt-issuer-validation.md for issuer-uri discovery and oidc.issuer enforcement. OAuth2TokenValidator withTimestamp = new JwtTimestampValidator(); OAuth2TokenValidator withIssuer = new JwtIssuerValidator(issuerOverride); OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withIssuer); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d597782b5a..d9cddb1225 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -94,7 +94,7 @@ spring.security.oauth2.client.provider.oidc.issuer-uri = ${IDAM_OIDC_URL:http:// # Dummy oidc client required even though data-store doesn't use spring.security.oauth2.client.registration.oidc.client-id = internal spring.security.oauth2.client.registration.oidc.client-secret = internal -oidc.issuer = ${OIDC_ISSUER:http://fr-am:8080/openam/oauth2/hmcts} +oidc.issuer = ${OIDC_ISSUER} # Required for the ServiceAuthorisationApi class in service-auth-provider-java-client library idam.s2s-auth.totp_secret=${DATA_STORE_IDAM_KEY:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB} diff --git a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java index 731615fcaf..9bc06abdbf 100644 --- a/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java @@ -32,7 +32,7 @@ // Validator-level coverage for issuer and timestamp enforcement. class SecurityConfigurationTest { - private static final String VALID_ISSUER = "http://fr-am:8080/openam/oauth2/hmcts"; + private static final String VALID_ISSUER = "http://localhost:5000/o"; private static final String INVALID_ISSUER = "http://unexpected-issuer"; @Test diff --git a/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java b/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java index 34f28c3ef4..55de5b5135 100644 --- a/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java +++ b/src/test/java/uk/gov/hmcts/ccd/WireMockBaseTest.java @@ -24,6 +24,7 @@ import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.util.ReflectionTestUtils; import uk.gov.hmcts.ccd.feign.FeignClientConfig; @@ -45,6 +46,7 @@ @AutoConfigureWireMock(port = 0) @Import({FeignClientConfig.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) public abstract class WireMockBaseTest extends AbstractBaseIntegrationTest { private static final Logger LOG = LoggerFactory.getLogger(WireMockBaseTest.class); diff --git a/src/test/java/uk/gov/hmcts/ccd/domain/service/caselinking/CaseLinkServiceExternalDbDeadlockIT.java b/src/test/java/uk/gov/hmcts/ccd/domain/service/caselinking/CaseLinkServiceExternalDbDeadlockIT.java index f78ebe6fb0..a7a79e72a7 100644 --- a/src/test/java/uk/gov/hmcts/ccd/domain/service/caselinking/CaseLinkServiceExternalDbDeadlockIT.java +++ b/src/test/java/uk/gov/hmcts/ccd/domain/service/caselinking/CaseLinkServiceExternalDbDeadlockIT.java @@ -58,7 +58,8 @@ "spring.datasource.hikari.minimum-idle=2", "spring.jpa.hibernate.ddl-auto=none", "spring.flyway.enabled=false", - "spring.security.oauth2.client.provider.oidc.issuer-uri=${IDAM_OIDC_URL:http://localhost:5000/o}" + "spring.security.oauth2.client.provider.oidc.issuer-uri=${IDAM_OIDC_URL:http://localhost:5000/o}", + "oidc.issuer=${OIDC_ISSUER:http://localhost:5000/o}" }) class CaseLinkServiceExternalDbDeadlockIT { diff --git a/src/test/java/uk/gov/hmcts/ccd/integrations/DefinitionsCachingIT.java b/src/test/java/uk/gov/hmcts/ccd/integrations/DefinitionsCachingIT.java index e4e684f0d6..b7ee5cb4f6 100644 --- a/src/test/java/uk/gov/hmcts/ccd/integrations/DefinitionsCachingIT.java +++ b/src/test/java/uk/gov/hmcts/ccd/integrations/DefinitionsCachingIT.java @@ -50,6 +50,7 @@ @ExtendWith(SpringExtension.class) @SpringBootTest @AutoConfigureWireMock(port = 0) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) @TestPropertySource(locations = "classpath:test.properties") public class DefinitionsCachingIT { diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties index 9e0d588bc6..928796d1be 100644 --- a/src/test/resources/test.properties +++ b/src/test/resources/test.properties @@ -13,6 +13,7 @@ idam.api.url=http://localhost:${wiremock.server.port:5000} idam.s2s-auth.url=http://localhost:${wiremock.server.port:4502}/s2s spring.security.oauth2.client.provider.oidc.issuer-uri=${idam.api.url}/o +oidc.issuer=${OIDC_ISSUER:http://localhost:${wiremock.server.port:5000}/o} spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.url=jdbc:tc:postgresql:15:///localhost?stringtype=unspecified From fca9179ada135b1f599902e40b16c419c2abbd55 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 31 Mar 2026 13:06:57 +0100 Subject: [PATCH 27/29] ISSUE-094 enforce PRD org boundary checks and tighten JWT docs --- docs/security/jwt-issuer-validation.md | 117 ++++++++++++++++--------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index 306cc18df5..b89ba08a8e 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -4,9 +4,25 @@ `ccd-data-store-api` +## Reference + +HMCTS guidance: [JWT iss Claim Validation guidance](https://tools.hmcts.net/confluence/spaces/SISM/pages/1958056812/JWT+iss+Claim+Validation+for+OIDC+and+OAuth+2+Tokens#JWTissClaimValidationforOIDCandOAuth2Tokens-Configurationrecommendation) + ## Summary This change re-enables issuer validation in `ccd-data-store-api` so JWTs must match `oidc.issuer` as well as pass timestamp checks. +For this repo, the current implementation follows the single configured issuer approach rather than an allow-list model. +Service-level issuer decisions should be checked against the reference above and the externally agreed service issuer policy before changing this repo's JWT issuer configuration. + +## At a glance + +| Area | Current approach in this repo | +|---|---| +| JWT validation | Signature, timestamp, and issuer are all enforced | +| Discovery / JWKS source | `spring.security.oauth2.client.provider.oidc.issuer-uri` | +| Enforced issuer | `oidc.issuer` / `OIDC_ISSUER` | +| Issuer model | Single configured issuer, not allow-list | +| Main runtime config | `OIDC_ISSUER` must be supplied explicitly | ## Context @@ -24,27 +40,27 @@ OAuth2TokenValidator withIssuer = new JwtIssuerValidator(issuerOverride); OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withIssuer); ``` -## Tests +## Runtime model -`src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java` covers: +| Setting | Purpose | Notes | +|---|---|---| +| `spring.security.oauth2.client.provider.oidc.issuer-uri` | OIDC discovery and JWKS lookup | Built from `IDAM_OIDC_URL` | +| `oidc.issuer` | Enforced token `iss` value | Supplied from `OIDC_ISSUER` | +| `IDAM_OIDC_URL` | Discovery base URL | Not the source of truth for token `iss` | +| `OIDC_ISSUER` | Expected JWT issuer | Must match real caller token `iss` exactly | -- accepted token from the configured issuer -- rejected token from an unexpected issuer -- rejected expired token from the configured issuer +## Tests -The test fixtures use valid JWT timelines so failures reflect validator behavior rather than builder constraints. +| Test | Coverage | +|---|---| +| `src/test/java/uk/gov/hmcts/ccd/SecurityConfigurationTest.java` | Accept expected issuer, reject unexpected issuer, reject expired token | +| `src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java` | Full-stack rejection of a signed JWT whose `iss` does not match configured issuer | -`src/test/java/uk/gov/hmcts/ccd/integrations/JwtIssuerValidationIT.java` adds full-stack coverage for a signed JWT whose `iss` claim does not match the configured issuer. This test requires the normal integration-test runtime dependencies for the repo. +The test fixtures use valid JWT timelines so failures reflect validator behavior rather than builder constraints. Coverage is intentionally two-layered here: validator-only behavior in `SecurityConfigurationTest` and full integration wiring in `JwtIssuerValidationIT`. A lighter Spring web-security slice test was not kept because it introduced unwanted test-context complexity in this repo. -## Configuration and deployment note - -This is not only a code change. Runtime configuration must still be correct: - -- `spring.security.oauth2.client.provider.oidc.issuer-uri` is used for OIDC discovery and JWKS lookup. -- `oidc.issuer` is the issuer value enforced during JWT validation. -- In this repo those map to `IDAM_OIDC_URL` and `OIDC_ISSUER` in Helm values. +## Configuration and deployment notes Before rollout, confirm: @@ -53,6 +69,14 @@ Before rollout, confirm: - no pipeline or release-time override is supplying an older issuer value - external callers, smoke tests, and AAT clients obtain tokens whose `iss` claim matches this service's configured `OIDC_ISSUER` +### Guidance alignment + +| Item | Current repo state | +|---|---| +| Service issuer model | Single configured issuer | +| Issuer pattern used for this service | Canonical FORGEROCK issuer pattern, consistent with the HMCTS guidance in the Reference section and the external service issuer policy for `ccd-data-store-api` | +| Repo wiring status | Helm values, preview values, and Jenkins wiring are already aligned to that FORGEROCK issuer pattern | + Do not infer `OIDC_ISSUER` from the public OIDC discovery URL. In preview/AAT for this repo, the correct `OIDC_ISSUER` had to be taken from decoded real tokens and did not match the public `IDAM_OIDC_URL` base. @@ -93,28 +117,33 @@ PY Only switch to multi-issuer validation if production tokens genuinely need both values during migration. In that case, use an explicit allow-list for issuer values rather than dropping issuer validation. -## Acceptance Checklist - -Before merging JWT issuer-validation changes, confirm all of the following: - -- The active `JwtDecoder` is built from `spring.security.oauth2.client.provider.oidc.issuer-uri`. -- The active validator chain includes both `JwtTimestampValidator` and `JwtIssuerValidator(oidc.issuer)`. -- There is no disabled, commented-out, or alternate runtime path that leaves issuer validation off. -- `issuer-uri` is used for discovery and JWKS lookup only. -- `oidc.issuer` / `OIDC_ISSUER` is used as the enforced token `iss` value only. -- `OIDC_ISSUER` is explicitly configured and not guessed from the discovery URL. -- App config, Helm values, preview values, and CI/Jenkins values are aligned for the target environment. -- If `OIDC_ISSUER` changed, it was verified against a real token for the target environment. -- There is a test that accepts a token with the expected issuer. -- There is a test that rejects a token with an unexpected issuer. -- There is a test that rejects an expired token. -- There is decoder-level coverage using a signed token, not only validator-only coverage. -- At least one failure assertion clearly proves issuer rejection, for example by checking for `iss`. -- CI or build verification checks that a real token issuer matches `OIDC_ISSUER`, or the repo documents why that does not apply. -- Comments and docs do not describe the old insecure behavior. -- Any repo-specific difference from peer services is intentional and documented. - -Do not merge if any of the following are true: +## Current repo status + +| Area | Current status | +|---|---| +| Decoder / validator chain | `SecurityConfiguration` enforces both timestamp and issuer validation | +| Additional action needed | No further JWT issuer config change is required in this repo unless the external service issuer policy changes | + +## Merge checklist + +Before merging JWT issuer-validation changes, confirm: + +- the active `JwtDecoder` is built from `spring.security.oauth2.client.provider.oidc.issuer-uri` +- the active validator chain includes both `JwtTimestampValidator` and `JwtIssuerValidator(oidc.issuer)` +- there is no disabled, commented-out, or alternate runtime path that leaves issuer validation off +- `issuer-uri` is used for discovery and JWKS lookup only +- `oidc.issuer` / `OIDC_ISSUER` is used as the enforced token `iss` value only +- `OIDC_ISSUER` is explicitly configured and not guessed from the discovery URL +- app config, Helm values, preview values, and CI/Jenkins values are aligned for the target environment +- if `OIDC_ISSUER` changed, it was verified against a real token for the target environment +- there is a test that accepts a token with the expected issuer +- there is a test that rejects a token with an unexpected issuer +- there is a test that rejects an expired token +- there is decoder-level coverage using a signed token, not only validator-only coverage +- CI or build verification checks that a real token issuer matches `OIDC_ISSUER`, or the repo documents why that does not apply +- comments and docs do not describe the old insecure behavior + +Do not merge if: - issuer validation is constructed but not applied - only timestamp validation is active @@ -124,10 +153,12 @@ Do not merge if any of the following are true: ## Configuration Policy -- `spring.security.oauth2.client.provider.oidc.issuer-uri` is used for OIDC discovery and JWKS lookup only. -- `oidc.issuer` / `OIDC_ISSUER` is the enforced JWT issuer and must match the token `iss` claim exactly. -- Do not derive `OIDC_ISSUER` from `IDAM_OIDC_URL` or the discovery URL. -- Production-like environments must provide `OIDC_ISSUER` explicitly. -- Requiring explicit `OIDC_ISSUER` with no static fallback in main runtime config is the preferred pattern. This repo already follows that stricter pattern. -- Local or test-only fallbacks are acceptable only when they are static, intentional, and clearly scoped to non-production use. -- The build enforces this policy with `verifyOidcIssuerPolicy`, which fails if `oidc.issuer` is derived from discovery config. +| Policy | Requirement | +|---|---| +| Discovery | `spring.security.oauth2.client.provider.oidc.issuer-uri` is used for OIDC discovery and JWKS lookup only | +| Enforcement | `oidc.issuer` / `OIDC_ISSUER` is the enforced JWT issuer and must match the token `iss` claim exactly | +| Derivation | Do not derive `OIDC_ISSUER` from `IDAM_OIDC_URL` or the discovery URL | +| Production-like environments | Must provide `OIDC_ISSUER` explicitly | +| Main runtime config | Explicit `OIDC_ISSUER` with no static fallback is the preferred pattern; this repo follows that pattern | +| Local / test-only fallbacks | Acceptable only when static, intentional, and clearly scoped to non-production use | +| Build guard | `verifyOidcIssuerPolicy` fails if `oidc.issuer` is derived from discovery config | From 22ea319454d651160c8a4bdf70b542d6d61f1763 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 31 Mar 2026 13:44:53 +0100 Subject: [PATCH 28/29] Document JWT issuer validation guidance and repo alignment --- docs/security/jwt-issuer-validation.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index b89ba08a8e..04839ad453 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -4,24 +4,25 @@ `ccd-data-store-api` -## Reference +## Summary -HMCTS guidance: [JWT iss Claim Validation guidance](https://tools.hmcts.net/confluence/spaces/SISM/pages/1958056812/JWT+iss+Claim+Validation+for+OIDC+and+OAuth+2+Tokens#JWTissClaimValidationforOIDCandOAuth2Tokens-Configurationrecommendation) +- re-enables issuer validation in `ccd-data-store-api`, so JWTs must match `oidc.issuer` as well as pass timestamp checks +- follows the single configured issuer approach rather than an allow-list model +- any change to this repo's JWT issuer configuration should remain consistent with the HMCTS guidance in the [HMCTS Guidance](#hmcts-guidance) section and the externally agreed service issuer policy -## Summary +## HMCTS Guidance -This change re-enables issuer validation in `ccd-data-store-api` so JWTs must match `oidc.issuer` as well as pass timestamp checks. -For this repo, the current implementation follows the single configured issuer approach rather than an allow-list model. -Service-level issuer decisions should be checked against the reference above and the externally agreed service issuer policy before changing this repo's JWT issuer configuration. +- [JWT iss Claim Validation guidance](https://tools.hmcts.net/confluence/spaces/SISM/pages/1958056812/JWT+iss+Claim+Validation+for+OIDC+and+OAuth+2+Tokens#JWTissClaimValidationforOIDCandOAuth2Tokens-Configurationrecommendation) +- Use that guidance as the reference point for service-level issuer decisions and configuration recommendations. -## At a glance +## Current approach | Area | Current approach in this repo | |---|---| | JWT validation | Signature, timestamp, and issuer are all enforced | | Discovery / JWKS source | `spring.security.oauth2.client.provider.oidc.issuer-uri` | | Enforced issuer | `oidc.issuer` / `OIDC_ISSUER` | -| Issuer model | Single configured issuer, not allow-list | +| Issuer model | Single configured issuer, not allow-list; this is permitted by the HMCTS guidance in the [HMCTS Guidance](#hmcts-guidance) section and matches the one externally agreed issuer for this repo | | Main runtime config | `OIDC_ISSUER` must be supplied explicitly | ## Context @@ -74,7 +75,7 @@ Before rollout, confirm: | Item | Current repo state | |---|---| | Service issuer model | Single configured issuer | -| Issuer pattern used for this service | Canonical FORGEROCK issuer pattern, consistent with the HMCTS guidance in the Reference section and the external service issuer policy for `ccd-data-store-api` | +| Issuer pattern used for this service | Canonical FORGEROCK issuer pattern, consistent with the HMCTS guidance in the [HMCTS Guidance](#hmcts-guidance) section and the external service issuer policy for `ccd-data-store-api` | | Repo wiring status | Helm values, preview values, and Jenkins wiring are already aligned to that FORGEROCK issuer pattern | Do not infer `OIDC_ISSUER` from the public OIDC discovery URL. In preview/AAT for this repo, the correct @@ -117,7 +118,7 @@ PY Only switch to multi-issuer validation if production tokens genuinely need both values during migration. In that case, use an explicit allow-list for issuer values rather than dropping issuer validation. -## Current repo status +## Current implementation status | Area | Current status | |---|---| @@ -162,3 +163,7 @@ Do not merge if: | Main runtime config | Explicit `OIDC_ISSUER` with no static fallback is the preferred pattern; this repo follows that pattern | | Local / test-only fallbacks | Acceptable only when static, intentional, and clearly scoped to non-production use | | Build guard | `verifyOidcIssuerPolicy` fails if `oidc.issuer` is derived from discovery config | + +## References + +See [HMCTS Guidance](#hmcts-guidance). From ce090b5ccc42a40bbf78e634eb3b7664f3a10f57 Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 1 Apr 2026 13:43:10 +0100 Subject: [PATCH 29/29] Clarify OIDC issuer configuration in JWT docs --- docs/security/jwt-issuer-validation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index 04839ad453..a6eb2a5c97 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -23,7 +23,6 @@ | Discovery / JWKS source | `spring.security.oauth2.client.provider.oidc.issuer-uri` | | Enforced issuer | `oidc.issuer` / `OIDC_ISSUER` | | Issuer model | Single configured issuer, not allow-list; this is permitted by the HMCTS guidance in the [HMCTS Guidance](#hmcts-guidance) section and matches the one externally agreed issuer for this repo | -| Main runtime config | `OIDC_ISSUER` must be supplied explicitly | ## Context @@ -50,6 +49,8 @@ OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withT | `IDAM_OIDC_URL` | Discovery base URL | Not the source of truth for token `iss` | | `OIDC_ISSUER` | Expected JWT issuer | Must match real caller token `iss` exactly | +For this repo, the FORGEROCK issuer used in deployed environments is an explicit `OIDC_ISSUER` value supplied by Helm/Jenkins configuration. It is not a runtime fallback that appears automatically when issuer settings are absent. + ## Tests | Test | Coverage | @@ -160,7 +161,6 @@ Do not merge if: | Enforcement | `oidc.issuer` / `OIDC_ISSUER` is the enforced JWT issuer and must match the token `iss` claim exactly | | Derivation | Do not derive `OIDC_ISSUER` from `IDAM_OIDC_URL` or the discovery URL | | Production-like environments | Must provide `OIDC_ISSUER` explicitly | -| Main runtime config | Explicit `OIDC_ISSUER` with no static fallback is the preferred pattern; this repo follows that pattern | | Local / test-only fallbacks | Acceptable only when static, intentional, and clearly scoped to non-production use | | Build guard | `verifyOidcIssuerPolicy` fails if `oidc.issuer` is derived from discovery config |