From ebe53598d316fb1fb60c9293522a5e11678c6b6a Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 24 Mar 2026 14:12:05 +0000 Subject: [PATCH 1/8] Standardise JWT issuer validation and update registry/auth build wiring --- AGENTS.md | 11 ++ Dockerfile | 4 +- Jenkinsfile_CNP | 6 +- Jenkinsfile_nightly | 6 +- README.md | 40 +++--- acb.tpl.yaml | 6 +- build.gradle | 17 ++- charts/ccd-case-document-am-api/Chart.yaml | 2 +- charts/ccd-case-document-am-api/values.yaml | 2 +- docs/security/jwt-issuer-validation.md | 82 ++++++++++++ docs/skills/security-jwt-issuer/SKILL.md | 36 +++++ docs/skills/security/SKILL.md | 23 ++++ .../endpoints/CaseDocumentAmProviderTest.java | 11 +- .../controller/endpoints/ContractConfig.java | 8 +- .../befta/JwtIssuerVerificationApp.java | 124 ++++++++++++++++++ .../hmcts/reform/ccd/documentam/BaseTest.java | 28 +++- .../SecurityConfigurationIT.java | 42 ++++++ .../CaseDocumentAmControllerIT.java | 7 + .../resources/application-itest.yaml | 3 + .../configuration/SecurityConfiguration.java | 3 +- .../SecurityConfigurationTest.java | 59 +++++++++ 21 files changed, 478 insertions(+), 42 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/security/jwt-issuer-validation.md create mode 100644 docs/skills/security-jwt-issuer/SKILL.md create mode 100644 docs/skills/security/SKILL.md create mode 100644 src/functionalTest/java/uk/gov/hmcts/ccd/documentam/befta/JwtIssuerVerificationApp.java create mode 100644 src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java create mode 100644 src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5e27e3de0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Agent Instructions + +## Available skills + +- `docs/skills/security/SKILL.md` + Use for general Spring Security, IDAM/OIDC, and related regression-test work in this repo. For JWT issuer-validation work, use `docs/skills/security-jwt-issuer/SKILL.md`. + Prompt cue: `Use docs/skills/security/SKILL.md` + +- `docs/skills/security-jwt-issuer/SKILL.md` + Use for JWT issuer validation, OIDC discovery versus enforced issuer configuration, Helm or Jenkins `OIDC_ISSUER` settings, and related regression-test work in this repo. + Prompt cue: `Use docs/skills/security-jwt-issuer/SKILL.md` diff --git a/Dockerfile b/Dockerfile index 6a4c7bee9..532de9e21 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.3 ARG PLATFORM="" -FROM hmctspublic.azurecr.io/base/java${PLATFORM}:21-distroless +FROM hmctsprod.azurecr.io/base/java${PLATFORM}:21-distroless # Change to non-root privilege USER hmcts @@ -15,4 +15,4 @@ COPY lib/applicationinsights.json /opt/app EXPOSE 4455 -CMD ["ccd-case-document-am-api.jar"] \ No newline at end of file +CMD ["ccd-case-document-am-api.jar"] diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 88ad0852b..98254b216 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -58,6 +58,7 @@ static LinkedHashMap secret(String secretName, String envVar) { env.CCD_DATA_STORE_API_BASE_URL = "https://ccd-data-store-api-${dataStoreApiDevelopPr}.preview.platform.hmcts.net".toLowerCase() env.DEFINITION_STORE_URL_BASE = "https://ccd-definition-store-api-${definitionStoreDevelopPr}.preview.platform.hmcts.net".toLowerCase() env.IDAM_URL = "https://idam-api.aat.platform.hmcts.net" +env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat.internal:8443/openam/oauth2/hmcts" env.S2S_URL = "http://rpe-service-auth-provider-aat.service.core-compute-aat.internal" env.OAUTH2_CLIENT_ID = "ccd_gateway" env.OAUTH2_REDIRECT_URI = "https://www-ccd.aat.platform.hmcts.net/oauth2redirect" @@ -72,6 +73,7 @@ env.BEFTA_S2S_CLIENT_ID_OF_XUI_WEBAPP = "xui_webapp" env.BEFTA_S2S_CLIENT_ID_OF_BULK_SCAN_PROCESSOR = "bulk_scan_processor" env.BEFTA_RESPONSE_HEADER_CHECK_POLICY = "JUST_WARN" +env.VERIFY_OIDC_ISSUER = "true" env.CCD_API_GATEWAY_S2S_ID = "ccd_gw" @@ -82,8 +84,8 @@ env.PACT_BROKER_URL = "pact-broker.platform.hmcts.net" env.PACT_BROKER_PORT = "443" env.PACT_BROKER_SCHEME = "https" -// 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("java", product, component) { onMaster { diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index c345cfae8..20a8ac7bd 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -12,8 +12,10 @@ def type = "java" def product = "ccd" def component = "case-document-am-api" -// 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/" +env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat.internal:8443/openam/oauth2/hmcts" +env.VERIFY_OIDC_ISSUER = "true" withNightlyPipeline(type, product, component) { enableSlackNotifications('#ccd-case-document-am-api-builds') diff --git a/README.md b/README.md index 49bf1b7e4..b656f2c40 100644 --- a/README.md +++ b/README.md @@ -24,26 +24,34 @@ Users & services with sufficient permissions only will be able to upload, modify This service works with the DocStore Api and CaseData Api alongside their databases CCD Data Store and Document Management Store. +### Codex Workflow Docs + +Repo-local workflow docs are indexed in `AGENTS.md`. + #### Environment variables The following environment variables are required: | Name | Default | Description | |------|---------|-------------| - |CASE_DOCUMENT_S2S_AUTHORISED_SERVICES| ccd_case_document_am_api, ccd_gw, xui_webapp, ccd_data, bulk_scan_processor, bulk_scan_orchestrator| - |REFORM_SERVICE_NAME| ccd-case-document-am-api| - |REFORM_TEAM| ccd - |REFORM_ENVIRONMENT| local - |S2S_SECRET| - |S2S_KEY| S2S_KEY - |CCD_DOCUMENT_API_IDAM_KEY| - |DEFINITION_STORE_HOST| - |USER_PROFILE_HOST| - |DM_STORE_BASE_URL| http://dm-store:8080| - |CCD_DATA_STORE_API_BASE_URL| http://ccd-data-store-api:4452| - |app-insights-connection-string| - |IDAM_USER_URL| http://idam-api:5000 | - |IDAM_S2S_URL| http://service-auth-provider-api:8080| - |JAVA_TOOL_OPTIONS| -XX:InitialRAMPercentage=30.0 -XX:MaxRAMPercentage=65.0 -XX:MinRAMPercentage=30.0 -XX:+UseConcMarkSweepGC -agentlib:jdwp=transport=dt_socket, server=y,suspend=n,address=5005 +| CASE_DOCUMENT_S2S_AUTHORISED_SERVICES | ccd_case_document_am_api, ccd_gw, xui_webapp, ccd_data, bulk_scan_processor, bulk_scan_orchestrator | Authorised service names for S2S calls. | +| REFORM_SERVICE_NAME | ccd-case-document-am-api | Service name. | +| REFORM_TEAM | ccd | Owning team. | +| REFORM_ENVIRONMENT | local | Runtime environment name. | +| S2S_SECRET | - | Service-to-service secret. | +| S2S_KEY | S2S_KEY | S2S key alias. | +| CCD_DOCUMENT_API_IDAM_KEY | - | IDAM key for this service where required by local setup. | +| DEFINITION_STORE_HOST | - | Base URL for the definition store dependency. | +| USER_PROFILE_HOST | - | Base URL for the user profile dependency. | +| DM_STORE_BASE_URL | http://dm-store:8080 | Base URL for Document Management Store. | +| CCD_DATA_STORE_API_BASE_URL | http://ccd-data-store-api:4452 | Base URL for CCD Data Store API. | +| app-insights-connection-string | - | Application Insights connection string. | +| IDAM_USER_URL | http://idam-api:5000 | Base URL for IDAM user APIs. | +| IDAM_S2S_URL | http://service-auth-provider-api:8080 | Base URL for S2S auth provider. | +| IDAM_OIDC_URL | - | Base URL for IDAM OIDC discovery and JWKS lookup. | +| OIDC_ISSUER | - | Enforced JWT issuer value. This must match the `iss` claim in real access tokens accepted by this service. | +| JAVA_TOOL_OPTIONS | -XX:InitialRAMPercentage=30.0 -XX:MaxRAMPercentage=65.0 -XX:MinRAMPercentage=30.0 -XX:+UseConcMarkSweepGC -agentlib:jdwp=transport=dt_socket, server=y,suspend=n,address=5005 | JVM options for local running. | + +`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 application @@ -154,6 +162,8 @@ export BEFTA_S2S_CLIENT_SECRET_OF_XUI_WEBAPP=AAAAAAAAAAAAAAAA export DM_STORE_BASE_URL=http://localhost:4506 ``` +To verify the live OIDC issuer locally, export `VERIFY_OIDC_ISSUER=true` together with the normal functional test credentials and `OIDC_ISSUER`. The verifier will fetch a real IDAM token, decode its `iss` claim, and fail if it does not exactly match `OIDC_ISSUER`. + These tests also rely on the `CCD_BEFTA_JURISDICTION2.xlsx` file to be already imported. This file should be available in your local environment already. ####Running the tests diff --git a/acb.tpl.yaml b/acb.tpl.yaml index 1f6f007cc..2f4555af0 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 @@ -57,4 +57,4 @@ steps: when: - manifest-create retries: 3 - retryDelay: 5 \ No newline at end of file + retryDelay: 5 diff --git a/build.gradle b/build.gradle index ee2e79d54..87fab775c 100644 --- a/build.gradle +++ b/build.gradle @@ -124,6 +124,7 @@ tasks.withType(JavaCompile) { } task functional(type: Test, description: 'Runs the functional tests.', group: 'Verification') { + dependsOn 'functionalTestClasses', 'verifyFunctionalTestJwtIssuer' testClassesDirs = sourceSets.functionalTest.output.classesDirs classpath = sourceSets.functionalTest.runtimeClasspath @@ -165,6 +166,7 @@ task integration(type: Test, description: 'Runs the integration tests.', group: } task smoke(type: Test, description: 'Runs the smoke tests.', group: 'Verification') { + dependsOn 'functionalTestClasses', 'verifyFunctionalTestJwtIssuer' setTestClassesDirs(sourceSets.functionalTest.output.classesDirs) setClasspath(sourceSets.functionalTest.runtimeClasspath) include "uk/gov/hmcts/ccd/documentam/befta/**" @@ -197,6 +199,19 @@ task smoke(type: Test, description: 'Runs the smoke tests.', group: 'Verificatio outputs.upToDateWhen { false } } +task verifyFunctionalTestJwtIssuer(type: JavaExec) { + description = 'Verifies the functional/smoke test token issuer matches OIDC_ISSUER' + group = 'Verification' + dependsOn functionalTestClasses + + onlyIf { + System.getenv('VERIFY_OIDC_ISSUER')?.toBoolean() + } + + mainClass = "uk.gov.hmcts.ccd.documentam.befta.JwtIssuerVerificationApp" + classpath += configurations.cucumberRuntime + sourceSets.functionalTest.runtimeClasspath + sourceSets.main.output + sourceSets.test.output +} + jacocoTestReport { executionData(test, integration) reports { @@ -311,7 +326,7 @@ dependencies { implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.9' implementation group: 'com.github.ben-manes.caffeine', name: 'caffeine', version: '3.2.1' - 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.github.hmcts', name: 'idam-java-client', version: '3.0.4' implementation group: 'com.github.hmcts', name: 'service-auth-provider-java-client', version: '5.3.3' implementation group: 'com.github.spotbugs', name: 'spotbugs-annotations', version: '4.9.3' diff --git a/charts/ccd-case-document-am-api/Chart.yaml b/charts/ccd-case-document-am-api/Chart.yaml index 9bf45a111..91ee3e42f 100644 --- a/charts/ccd-case-document-am-api/Chart.yaml +++ b/charts/ccd-case-document-am-api/Chart.yaml @@ -9,4 +9,4 @@ maintainers: dependencies: - name: java version: 5.3.0 - repository: 'oci://hmctspublic.azurecr.io/helm' + repository: 'oci://hmctsprod.azurecr.io/helm' diff --git a/charts/ccd-case-document-am-api/values.yaml b/charts/ccd-case-document-am-api/values.yaml index 3054adfc1..73251a12d 100644 --- a/charts/ccd-case-document-am-api/values.yaml +++ b/charts/ccd-case-document-am-api/values.yaml @@ -1,5 +1,5 @@ java: - image: 'hmctspublic.azurecr.io/ccd/case-document-am-api:latest' + image: 'hmctsprod.azurecr.io/ccd/case-document-am-api:latest' ingressHost: ccd-case-document-am-api-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal applicationPort: 4455 aadIdentityName: ccd diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md new file mode 100644 index 000000000..08dc80563 --- /dev/null +++ b/docs/security/jwt-issuer-validation.md @@ -0,0 +1,82 @@ +# JWT issuer validation + +## Service + +`ccd-case-document-am-api` + +## Summary + +- JWT issuer validation is enabled in the active `JwtDecoder`. +- OIDC discovery and issuer enforcement are configured separately on purpose. +- The enforced issuer must be taken from a real access token `iss` claim, not inferred from discovery metadata or deployment naming. + +## Discovery vs enforced issuer + +- `spring.security.oauth2.client.provider.oidc.issuer-uri` is the discovery location. The service uses it to load OIDC metadata and the JWKS endpoint. +- `oidc.issuer` / `OIDC_ISSUER` is the enforced issuer value. The active `JwtDecoder` validates the token `iss` claim against this value. +- These values can differ. Discovery can point at the public IDAM OIDC endpoint while enforcement pins the exact `iss` emitted in real access tokens. + +## Runtime behavior + +- `SecurityConfiguration.jwtDecoder()` builds the decoder from `issuer-uri`. +- The decoder then applies both `JwtTimestampValidator` and `JwtIssuerValidator(oidc.issuer)`. +- Tokens signed by the discovered JWKS are still rejected if their `iss` does not exactly match `OIDC_ISSUER`. + +## Why this changed + +- The previous decoder wiring validated timestamps only. +- The issuer validator had been left out of the active validator chain. +- That left the service accepting any correctly signed, unexpired token from the discovered JWKS, even if the token came from an unexpected issuer. +- The current configuration restores single-issuer enforcement. + +## Coverage + +- Unit coverage in `src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java` checks valid issuer, invalid issuer, and expired token behaviour at validator level. +- Decoder exception coverage in `src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java` checks decoder-level issuer failures with the active decoder and signed test JWTs. +- Integration coverage in `src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java` exercises authenticated endpoint rejection when a token carries unexpected `iss`. + +## Test and pipeline verification + +- Focused tests cover valid issuer, invalid issuer, and expired token cases. +- The integration harness mints a signed JWT with explicit `iss` in `src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java`. +- WireMock-backed OIDC discovery and JWKS responses are provided by `src/integrationTest/resources/wiremock-stubs/idam/`. +- Integration test config in `src/integrationTest/resources/application-itest.yaml` keeps `issuer-uri` and `oidc.issuer` aligned for the test runtime. +- A single build-integrated verifier acquires a real IDAM access token and compares its `iss` claim to `OIDC_ISSUER` before smoke and functional runs. +- Local runs skip this live check unless `VERIFY_OIDC_ISSUER=true`. +- Jenkins sets `VERIFY_OIDC_ISSUER=true` and exports `OIDC_ISSUER` explicitly for the verifier. + +## Operational guidance + +- Do not invent `OIDC_ISSUER`. +- Resolve it from a real access token for the target environment and keep it aligned with the value enforced by the application. +- In this repo, Helm already separates `IDAM_OIDC_URL` from `OIDC_ISSUER` in `charts/ccd-case-document-am-api/values.yaml`. +- Because the verifier runs in the build container before deployed app env is available, Jenkins and Helm issuer values must stay aligned. +- 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 usually point to `http://localhost:5000`, while `OIDC_ISSUER` must exactly match the `iss` claim in the local access tokens being used. + +## How to derive OIDC_ISSUER + +Derive `OIDC_ISSUER` from a real access token for the target environment. Do not infer it from the public OIDC discovery URL. + +Example: + +1. Acquire a real bearer token for the same caller path your tests or clients use. +2. Split the JWT on `.` and take the second part, which is the payload. +3. Decode the payload and read the `iss` claim. +4. Set `OIDC_ISSUER` to that exact value. + +Python example: + +```python +import base64 +import json +import sys + +payload = sys.argv[1] +payload += '=' * (-len(payload) % 4) +print(json.loads(base64.urlsafe_b64decode(payload))["iss"]) +``` + +## Optional future variant + +Only switch to multi-issuer validation if real traffic genuinely needs both values during migration. In that case, use an explicit allow-list validator rather than disabling issuer validation again. diff --git a/docs/skills/security-jwt-issuer/SKILL.md b/docs/skills/security-jwt-issuer/SKILL.md new file mode 100644 index 000000000..83af1197f --- /dev/null +++ b/docs/skills/security-jwt-issuer/SKILL.md @@ -0,0 +1,36 @@ +--- +name: ccd-case-document-am-security-jwt-issuer +description: Use when working in the HMCTS `ccd-case-document-am-api` repository on JWT issuer validation, OIDC discovery versus enforced issuer configuration, Helm or Jenkins OIDC_ISSUER settings, build-integrated issuer verification, or related regression testing. +--- + +# Security JWT Issuer + +## Overview + +Use this skill for JWT issuer validation changes in `ccd-case-document-am-api`. + +## Workflow + +1. Check current state with `git status --short` and inspect local diffs before editing. +2. Review [`src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java`](../../../src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java), [`src/main/resources/application.yaml`](../../../src/main/resources/application.yaml), [`charts/ccd-case-document-am-api/values.yaml`](../../../charts/ccd-case-document-am-api/values.yaml), and [`Jenkinsfile_CNP`](../../../Jenkinsfile_CNP). +3. Confirm the split between discovery and enforcement: + `spring.security.oauth2.client.provider.oidc.issuer-uri` is for discovery and JWKS. + `oidc.issuer` / `OIDC_ISSUER` is the enforced issuer matched against the token `iss` claim. +4. Search for `issuer`, `issuer-uri`, `JwtDecoder`, `JwtIssuerValidator`, `JwtTimestampValidator`, `OIDC_ISSUER`, and `VERIFY_OIDC_ISSUER` before changing behavior. +5. Keep coverage focused across three layers: + validator-level tests in [`src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java`](../../../src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java), + decoder exception tests in [`src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java`](../../../src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java), + and endpoint integration coverage in [`src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java`](../../../src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java). +6. Preserve the repo’s build-integrated issuer verification path: + use [`src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java`](../../../src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java), + [`src/integrationTest/resources/application-itest.yaml`](../../../src/integrationTest/resources/application-itest.yaml), + [`src/functionalTest/java/uk/gov/hmcts/ccd/documentam/befta/JwtIssuerVerificationApp.java`](../../../src/functionalTest/java/uk/gov/hmcts/ccd/documentam/befta/JwtIssuerVerificationApp.java), + and the WireMock OIDC stubs under [`src/integrationTest/resources/wiremock-stubs/idam/`](../../../src/integrationTest/resources/wiremock-stubs/idam/) + rather than duplicating token-minting or issuer-resolution helpers. +7. Keep issuer terminology for config and docs, and `iss` terminology only for claim-level assertions or validator and decoder messages. +8. Do not guess `OIDC_ISSUER`. Keep Helm and any test-time issuer values aligned with the `iss` claim used by real tokens for the target environment. + +## References + +- Primary repo guidance: [`docs/security/jwt-issuer-validation.md`](../../../docs/security/jwt-issuer-validation.md) +- Security workflow: [`docs/skills/security/SKILL.md`](../security/SKILL.md) diff --git a/docs/skills/security/SKILL.md b/docs/skills/security/SKILL.md new file mode 100644 index 000000000..1444e8d08 --- /dev/null +++ b/docs/skills/security/SKILL.md @@ -0,0 +1,23 @@ +--- +name: ccd-case-document-am-security +description: Use when working in the HMCTS `ccd-case-document-am-api` repository on general Spring Security configuration, IDAM/OIDC integration, security-related regression testing, or other security changes that are not specifically about JWT issuer validation. For JWT issuer validation, use `docs/skills/security-jwt-issuer/SKILL.md`. +--- + +# Security + +## Overview + +Use this skill for general security changes in `ccd-case-document-am-api`. +For JWT issuer validation and issuer-wiring work, use [`docs/skills/security-jwt-issuer/SKILL.md`](../security-jwt-issuer/SKILL.md). + +## Workflow + +1. Check current state with `git status --short` and inspect local diffs before editing. +2. Review the relevant security configuration, runtime wiring, and tests for the change you are making. +3. Search `src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/`, `src/main/java/uk/gov/hmcts/reform/ccd/documentam/security/`, `src/test/java/`, and `src/integrationTest/java/` before changing behavior. +4. Check `src/main/resources/application.yaml`, `charts/ccd-case-document-am-api/values.yaml`, and `Jenkinsfile_CNP` if the change affects deployment or environment wiring. +5. If the task turns into JWT issuer-validation work, switch to [`docs/skills/security-jwt-issuer/SKILL.md`](../security-jwt-issuer/SKILL.md). + +## References + +- JWT issuer-specific guidance: [`docs/skills/security-jwt-issuer/SKILL.md`](../security-jwt-issuer/SKILL.md) diff --git a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java index 8fd811f9d..52823b8d8 100644 --- a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java +++ b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java @@ -6,7 +6,8 @@ import au.com.dius.pact.provider.junitsupport.Provider; import au.com.dius.pact.provider.junitsupport.State; import au.com.dius.pact.provider.junitsupport.loader.PactBroker; -import au.com.dius.pact.provider.junitsupport.loader.VersionSelector; +import au.com.dius.pact.provider.junitsupport.loader.PactBrokerConsumerVersionSelectors; +import au.com.dius.pact.provider.junitsupport.loader.SelectorBuilder; import au.com.dius.pact.provider.spring.junit5.MockMvcTestTarget; import org.apache.commons.collections4.map.HashedMap; import org.junit.jupiter.api.BeforeEach; @@ -32,8 +33,7 @@ @ExtendWith(SpringExtension.class) @Provider("case-document-am-api") -@PactBroker(url = "${PACT_BROKER_FULL_URL:http://localhost}", - consumerVersionSelectors = {@VersionSelector(tag = "master")}) +@PactBroker(url = "${PACT_BROKER_FULL_URL:http://localhost}") @ContextConfiguration(classes = {ContractConfig.class}) @IgnoreNoPactsToVerify public class CaseDocumentAmProviderTest { @@ -49,6 +49,11 @@ public class CaseDocumentAmProviderTest { @Autowired CaseDocumentAmController caseDocumentAmController; + @PactBrokerConsumerVersionSelectors + public static SelectorBuilder consumerVersionSelectors() { + return new SelectorBuilder().tag("master"); + } + @TestTemplate @ExtendWith(PactVerificationInvocationContextProvider.class) void pactVerificationTestTemplate(PactVerificationContext context) { diff --git a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java index 4095725ec..187aec0d3 100644 --- a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java +++ b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java @@ -1,9 +1,9 @@ package uk.gov.hmcts.reform.ccd.documentam.controller.endpoints; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import uk.gov.hmcts.reform.ccd.documentam.ApplicationParams; import uk.gov.hmcts.reform.ccd.documentam.security.SecurityUtils; import uk.gov.hmcts.reform.ccd.documentam.service.DocumentManagementService; @@ -11,13 +11,13 @@ @Configuration public class ContractConfig { - @MockBean + @MockitoBean DocumentManagementService documentManagementService; - @MockBean + @MockitoBean SecurityUtils securityUtils; - @MockBean + @MockitoBean ApplicationParams applicationParams; @Bean diff --git a/src/functionalTest/java/uk/gov/hmcts/ccd/documentam/befta/JwtIssuerVerificationApp.java b/src/functionalTest/java/uk/gov/hmcts/ccd/documentam/befta/JwtIssuerVerificationApp.java new file mode 100644 index 000000000..eb7d28adc --- /dev/null +++ b/src/functionalTest/java/uk/gov/hmcts/ccd/documentam/befta/JwtIssuerVerificationApp.java @@ -0,0 +1,124 @@ +package uk.gov.hmcts.ccd.documentam.befta; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Feign; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import uk.gov.hmcts.befta.auth.AuthApi; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public final class JwtIssuerVerificationApp { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String AUTHORIZATION_CODE = "authorization_code"; + private static final String CODE = "code"; + private static final String BASIC = "Basic "; + + private JwtIssuerVerificationApp() { + } + + public static void main(String[] args) throws Exception { + String expectedIssuer = requireEnv("OIDC_ISSUER"); + String actualIssuer = decodeIssuer(fetchAccessToken()); + + 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 fetchAccessToken() { + AuthApi authApi = Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(AuthApi.class, firstAvailableEnv("IDAM_API_URL_BASE", "IDAM_URL")); + + String[] credentials = firstAvailableCredentials( + "CCD_CASEWORKER_AUTOTEST_EMAIL", "CCD_CASEWORKER_AUTOTEST_PASSWORD", + "DEFINITION_IMPORTER_USERNAME", "DEFINITION_IMPORTER_PASSWORD" + ); + String clientId = firstAvailableEnv("CCD_API_GATEWAY_OAUTH2_CLIENT_ID", "OAUTH2_CLIENT_ID"); + String clientSecret = firstAvailableEnv("CCD_API_GATEWAY_OAUTH2_CLIENT_SECRET", "OAUTH2_CLIENT_SECRET"); + String redirectUri = firstAvailableEnv("CCD_API_GATEWAY_OAUTH2_REDIRECT_URL", "OAUTH2_REDIRECT_URI"); + + String basicAuthorisation = BASIC + Base64.getEncoder() + .encodeToString((credentials[0] + ":" + credentials[1]).getBytes(StandardCharsets.UTF_8)); + + AuthApi.AuthenticateUserResponse authenticateUserResponse = authApi.authenticateUser( + basicAuthorisation, + CODE, + clientId, + redirectUri + ); + + AuthApi.TokenExchangeResponse tokenExchangeResponse = authApi.exchangeCode( + authenticateUserResponse.getCode(), + AUTHORIZATION_CODE, + clientId, + clientSecret, + redirectUri + ); + + return tokenExchangeResponse.getAccessToken(); + } + + private static String[] firstAvailableCredentials(String... envNames) { + for (int index = 0; index < envNames.length; index += 2) { + String username = System.getenv(envNames[index]); + String password = System.getenv(envNames[index + 1]); + if (username != null && !username.isBlank() && password != null && !password.isBlank()) { + 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) { + 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() || issuer.asText().isBlank()) { + 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); + } + + private static String firstAvailableEnv(String... names) { + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + throw new IllegalStateException("Missing required environment variable. Checked: " + String.join(", ", names)); + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required environment variable: " + name); + } + return value; + } +} diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java index db55b73bc..6e7545e6e 100644 --- a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java @@ -9,6 +9,7 @@ import io.jsonwebtoken.Jwts; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; @@ -55,15 +56,29 @@ public class BaseTest { @Autowired protected AuditRepository auditRepository; - public static HttpHeaders createHttpHeaders(String serviceName) throws JOSEException { - return createHttpHeaders(AUTH_TOKEN_TTL, serviceName, AUTH_TOKEN_TTL); + @Value("${oidc.issuer}") + private String oidcIssuer; + + protected HttpHeaders createHttpHeaders(String serviceName) throws JOSEException { + return createHttpHeaders(AUTH_TOKEN_TTL, oidcIssuer, serviceName, AUTH_TOKEN_TTL); + } + + protected HttpHeaders createHttpHeaders(String authIssuer, String serviceName) throws JOSEException { + return createHttpHeaders(AUTH_TOKEN_TTL, authIssuer, serviceName, AUTH_TOKEN_TTL); + } + + protected HttpHeaders createHttpHeaders(long authTtlMillis, + String serviceName, + long s2sAuthTtlMillis) throws JOSEException { + return createHttpHeaders(authTtlMillis, oidcIssuer, serviceName, s2sAuthTtlMillis); } - protected static HttpHeaders createHttpHeaders(long authTtlMillis, + protected HttpHeaders createHttpHeaders(long authTtlMillis, + String authIssuer, String serviceName, - long s2sAuthTtlMillis) throws JOSEException { + long s2sAuthTtlMillis) throws JOSEException { HttpHeaders headers = new HttpHeaders(); - String authToken = BEARER + generateAuthToken(authTtlMillis); + String authToken = BEARER + generateAuthToken(authTtlMillis, authIssuer); headers.add(AUTHORIZATION, authToken); String s2SToken = generateS2SToken(serviceName, s2sAuthTtlMillis); headers.add(SERVICE_AUTHORIZATION, s2SToken); @@ -144,10 +159,11 @@ protected void verifyLogAuditValues(MvcResult result, } } - private static String generateAuthToken(long ttlMillis) throws JOSEException { + public static String generateAuthToken(long ttlMillis, String issuer) throws JOSEException { JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() .subject("API_Stub") + .issuer(issuer) .issueTime(new Date()) .claim(TOKEN_NAME, ACCESS_TOKEN) .expirationTime(new Date(System.currentTimeMillis() + ttlMillis)); diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java new file mode 100644 index 000000000..45dd9d653 --- /dev/null +++ b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java @@ -0,0 +1,42 @@ +package uk.gov.hmcts.reform.ccd.documentam.configuration; + +import com.nimbusds.jose.JOSEException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidationException; +import uk.gov.hmcts.reform.ccd.documentam.BaseTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SecurityConfigurationIT extends BaseTest { + + private static final String UNEXPECTED_ISSUER = "http://unexpected-issuer/o"; + + @Autowired + private JwtDecoder jwtDecoder; + + @Value("${oidc.issuer}") + private String expectedIssuer; + + @Test + void shouldDecodeJwtWhenTokenIssMatchesConfiguredIssuer() throws JOSEException { + Jwt jwt = assertDoesNotThrow(() -> jwtDecoder.decode(generateAuthToken(AUTH_TOKEN_TTL, expectedIssuer))); + + assertThat(jwt.getIssuer().toString()).isEqualTo(expectedIssuer); + } + + @Test + void shouldRejectJwtWhenTokenIssIsUnexpected() throws JOSEException { + JwtValidationException exception = assertThrows( + JwtValidationException.class, + () -> jwtDecoder.decode(generateAuthToken(AUTH_TOKEN_TTL, UNEXPECTED_ISSUER)) + ); + + assertThat(exception.getMessage()).contains("iss"); + } +} diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java index 96e4e242e..ac6ba3f26 100644 --- a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java +++ b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java @@ -109,6 +109,13 @@ void shouldSuccessfullyUploadStreamDocument() throws Exception { uploadDocumentByIsStreamUploadEnabled(true); } + @Test + void shouldRejectRequestWhenTokenIssIsUnexpected() throws Exception { + mockMvc.perform(get(MAIN_URL + "/" + DOCUMENT_ID) + .headers(createHttpHeaders("http://unexpected-issuer/o", SERVICE_NAME_XUI_WEBAPP))) + .andExpect(status().isUnauthorized()); + } + private void uploadDocumentByIsStreamUploadEnabled(boolean isStreamUploadEnabled) throws Exception { Document document = Document.builder() diff --git a/src/integrationTest/resources/application-itest.yaml b/src/integrationTest/resources/application-itest.yaml index 812ae2b72..db1959c25 100644 --- a/src/integrationTest/resources/application-itest.yaml +++ b/src/integrationTest/resources/application-itest.yaml @@ -12,6 +12,9 @@ spring: oidc: issuer-uri: http://localhost:${wiremock.server.port}/o +oidc: + issuer: http://localhost:${wiremock.server.port}/o + azure: application-insights: web: diff --git a/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java b/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java index d3180373e..0fc93ca8b 100644 --- a/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java +++ b/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java @@ -92,8 +92,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); - // 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/reform/ccd/documentam/configuration/SecurityConfigurationTest.java b/src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java new file mode 100644 index 000000000..1c7a75dd0 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java @@ -0,0 +1,59 @@ +package uk.gov.hmcts.reform.ccd.documentam.configuration; + +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.core.OAuth2TokenValidatorResult; +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.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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() { + assertFalse(validator().validate(buildJwt(VALID_ISSUER, Instant.now().plusSeconds(300))).hasErrors()); + } + + @Test + void shouldRejectJwtFromUnexpectedIssuer() { + OAuth2TokenValidatorResult result = + validator().validate(buildJwt(INVALID_ISSUER, Instant.now().plusSeconds(300))); + + assertTrue(result.hasErrors()); + assertThat(result.getErrors()) + .anySatisfy(error -> assertThat(error.getDescription()).contains("iss")); + } + + @Test + void shouldRejectExpiredJwtEvenWhenIssuerMatches() { + assertTrue(validator().validate(buildJwt(VALID_ISSUER, Instant.now().minusSeconds(60))).hasErrors()); + } + + private OAuth2TokenValidator validator() { + return new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), + new JwtIssuerValidator(VALID_ISSUER) + ); + } + + private Jwt buildJwt(String issuer, Instant expiresAt) { + Instant issuedAt = expiresAt.minusSeconds(60); + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .issuer(issuer) + .subject("user") + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .build(); + } +} From c94711e4915990add52ef18a764ee29c52d637f7 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, 24 Mar 2026 14:14:49 +0000 Subject: [PATCH 2/8] Bumping chart version/ fixing aliases --- charts/ccd-case-document-am-api/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/ccd-case-document-am-api/Chart.yaml b/charts/ccd-case-document-am-api/Chart.yaml index 91ee3e42f..1901ad8eb 100644 --- a/charts/ccd-case-document-am-api/Chart.yaml +++ b/charts/ccd-case-document-am-api/Chart.yaml @@ -3,7 +3,7 @@ appVersion: "1.0" description: A Helm chart for CCD Case Document AM API name: ccd-case-document-am-api home: https://github.com/hmcts/ccd-case-document-am-api -version: 1.7.17 +version: 1.7.18 maintainers: - name: CCD Team dependencies: From 7558bcb5c2b1e0b1ee3cd0b3a647cb30c14879d1 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 24 Mar 2026 14:27:24 +0000 Subject: [PATCH 3/8] Fix contract test bean wiring for pipeline context --- .../controller/endpoints/ContractConfig.java | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java index 187aec0d3..ed63111d6 100644 --- a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java +++ b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java @@ -3,26 +3,38 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import uk.gov.hmcts.reform.ccd.documentam.ApplicationParams; import uk.gov.hmcts.reform.ccd.documentam.security.SecurityUtils; import uk.gov.hmcts.reform.ccd.documentam.service.DocumentManagementService; +import static org.mockito.Mockito.mock; + @Configuration public class ContractConfig { - @MockitoBean - DocumentManagementService documentManagementService; + @Bean + @Primary + public DocumentManagementService documentManagementService() { + return mock(DocumentManagementService.class); + } - @MockitoBean - SecurityUtils securityUtils; + @Bean + @Primary + public SecurityUtils securityUtils() { + return mock(SecurityUtils.class); + } - @MockitoBean - ApplicationParams applicationParams; + @Bean + @Primary + public ApplicationParams applicationParams() { + return mock(ApplicationParams.class); + } @Bean @Primary - public CaseDocumentAmController caseDocumentAmController() { + public CaseDocumentAmController caseDocumentAmController(DocumentManagementService documentManagementService, + SecurityUtils securityUtils, + ApplicationParams applicationParams) { return new CaseDocumentAmController(documentManagementService, securityUtils, applicationParams); } From fe70cd522be9bb4e3e8abb6d48e5e4f887065c6e Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 24 Mar 2026 15:54:34 +0000 Subject: [PATCH 4/8] Stabilise provider pact test configuration --- .../endpoints/CaseDocumentAmProviderTest.java | 12 ++++++++++++ .../controller/endpoints/ContractConfig.java | 14 +++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java index 52823b8d8..4eed916b5 100644 --- a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java +++ b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/CaseDocumentAmProviderTest.java @@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; import uk.gov.hmcts.reform.ccd.documentam.model.AuthorisedService; import uk.gov.hmcts.reform.ccd.documentam.model.Document; @@ -35,6 +36,17 @@ @Provider("case-document-am-api") @PactBroker(url = "${PACT_BROKER_FULL_URL:http://localhost}") @ContextConfiguration(classes = {ContractConfig.class}) +@TestPropertySource(properties = { + "case.document.am.api.enabled=true", + "documentStoreUrl=http://dm-store", + "documentTtlInDays=1", + "idam.s2s-auth.totp_secret=test-salt", + "hash.check.enabled=false", + "moving.case.types=", + "request.forwarded_headers.from_client=", + "stream.download.enabled=false", + "stream.upload.enabled=false" +}) @IgnoreNoPactsToVerify public class CaseDocumentAmProviderTest { diff --git a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java index ed63111d6..43df32b23 100644 --- a/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java +++ b/src/contractTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/endpoints/ContractConfig.java @@ -3,10 +3,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; +import org.springframework.test.util.ReflectionTestUtils; import uk.gov.hmcts.reform.ccd.documentam.ApplicationParams; import uk.gov.hmcts.reform.ccd.documentam.security.SecurityUtils; import uk.gov.hmcts.reform.ccd.documentam.service.DocumentManagementService; +import java.util.List; + import static org.mockito.Mockito.mock; @@ -27,7 +30,16 @@ public SecurityUtils securityUtils() { @Bean @Primary public ApplicationParams applicationParams() { - return mock(ApplicationParams.class); + ApplicationParams applicationParams = new ApplicationParams(); + ReflectionTestUtils.setField(applicationParams, "documentURL", "http://dm-store"); + ReflectionTestUtils.setField(applicationParams, "documentTtlInDays", 1); + ReflectionTestUtils.setField(applicationParams, "salt", "test-salt"); + ReflectionTestUtils.setField(applicationParams, "hashCheckEnabled", false); + ReflectionTestUtils.setField(applicationParams, "movingCaseTypes", List.of()); + ReflectionTestUtils.setField(applicationParams, "clientRequestHeadersToForward", List.of()); + ReflectionTestUtils.setField(applicationParams, "isStreamDownloadEnabled", false); + ReflectionTestUtils.setField(applicationParams, "isStreamUploadEnabled", false); + return applicationParams; } @Bean From e71c091ccc064b3967085037d11db93ba5abf21f Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 24 Mar 2026 16:19:20 +0000 Subject: [PATCH 5/8] Align AAT OIDC issuer config with real IDAM token issuer --- Jenkinsfile_CNP | 2 +- Jenkinsfile_nightly | 2 +- charts/ccd-case-document-am-api/values.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index 98254b216..4b56ee8f4 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -58,7 +58,7 @@ static LinkedHashMap secret(String secretName, String envVar) { env.CCD_DATA_STORE_API_BASE_URL = "https://ccd-data-store-api-${dataStoreApiDevelopPr}.preview.platform.hmcts.net".toLowerCase() env.DEFINITION_STORE_URL_BASE = "https://ccd-definition-store-api-${definitionStoreDevelopPr}.preview.platform.hmcts.net".toLowerCase() env.IDAM_URL = "https://idam-api.aat.platform.hmcts.net" -env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat.internal:8443/openam/oauth2/hmcts" +env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat2.internal:8443/openam/oauth2/realms/root/realms/hmcts" env.S2S_URL = "http://rpe-service-auth-provider-aat.service.core-compute-aat.internal" env.OAUTH2_CLIENT_ID = "ccd_gateway" env.OAUTH2_REDIRECT_URI = "https://www-ccd.aat.platform.hmcts.net/oauth2redirect" diff --git a/Jenkinsfile_nightly b/Jenkinsfile_nightly index 20a8ac7bd..ceb4dfdf7 100644 --- a/Jenkinsfile_nightly +++ b/Jenkinsfile_nightly @@ -14,7 +14,7 @@ def component = "case-document-am-api" // 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/" -env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat.internal:8443/openam/oauth2/hmcts" +env.OIDC_ISSUER = "https://forgerock-am.service.core-compute-idam-aat2.internal:8443/openam/oauth2/realms/root/realms/hmcts" env.VERIFY_OIDC_ISSUER = "true" withNightlyPipeline(type, product, component) { diff --git a/charts/ccd-case-document-am-api/values.yaml b/charts/ccd-case-document-am-api/values.yaml index 73251a12d..27685ce1e 100644 --- a/charts/ccd-case-document-am-api/values.yaml +++ b/charts/ccd-case-document-am-api/values.yaml @@ -13,7 +13,7 @@ java: environment: IDAM_API_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-aat2.internal:8443/openam/oauth2/realms/root/realms/hmcts S2S_URL: http://rpe-service-auth-provider-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal CASE_DOCUMENT_S2S_AUTHORISED_SERVICES: ccd_case_document_am_api,ccd_gw,xui_webapp,ccd_data,bulk_scan_processor,sscs,probate_backend,iac,em_npa_app,fprl_dgs_api,dg_docassembly_api,em_stitching_api,em_ccd_orchestrator,cmc_claim_store,civil_service,bulk_scan_orchestrator,ethos_repl_service,divorce_document_generator,finrem_document_generator,finrem_case_orchestration,fpl_case_service,et_cos,prl_cos_api,prl_dgs_api,et_sya_api,adoption_cos_api,adoption_web,nfdiv_case_api,divorce_frontend,sptribs_case_api,sptribs_dss_backend,civil_general_applications,pcs_api DM_STORE_BASE_URL: http://dm-store-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal From b2fc8ce86bada9a4215fde19363ede8b204f2137 Mon Sep 17 00:00:00 2001 From: patelila Date: Tue, 24 Mar 2026 16:21:55 +0000 Subject: [PATCH 6/8] Upgrade java-logging to 8.0.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5618a2c97..4a42b975c 100644 --- a/build.gradle +++ b/build.gradle @@ -314,7 +314,7 @@ dependencies { implementation group: 'com.github.hmcts', name: 'idam-java-client', version: '3.0.5' implementation group: 'com.github.hmcts', name: 'service-auth-provider-java-client', version: '5.3.3' - 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.0' From 48db45eb7af7de81789675541e1386e3da9fd647 Mon Sep 17 00:00:00 2001 From: patelila Date: Thu, 26 Mar 2026 12:22:11 +0000 Subject: [PATCH 7/8] Align JWT issuer validation with standard issuer pattern --- README.md | 6 +-- build.gradle | 31 +++++++++++++- docs/security/jwt-issuer-validation.md | 41 ++++++++++++++++++- .../hmcts/reform/ccd/documentam/BaseTest.java | 12 +++--- .../SecurityConfigurationIT.java | 6 +-- .../configuration/SecurityConfiguration.java | 6 +-- .../SecurityConfigurationTest.java | 16 ++++---- 7 files changed, 93 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index b656f2c40..974d20a7f 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,10 @@ The following environment variables are required: | IDAM_USER_URL | http://idam-api:5000 | Base URL for IDAM user APIs. | | IDAM_S2S_URL | http://service-auth-provider-api:8080 | Base URL for S2S auth provider. | | IDAM_OIDC_URL | - | Base URL for IDAM OIDC discovery and JWKS lookup. | -| OIDC_ISSUER | - | Enforced JWT issuer value. This must match the `iss` claim in real access tokens accepted by this service. | +| OIDC_ISSUER | - | Enforced issuer. This must match the `iss` claim in real access tokens accepted by this service. | | JAVA_TOOL_OPTIONS | -XX:InitialRAMPercentage=30.0 -XX:MaxRAMPercentage=65.0 -XX:MinRAMPercentage=30.0 -XX:+UseConcMarkSweepGC -agentlib:jdwp=transport=dt_socket, server=y,suspend=n,address=5005 | JVM options for local running. | -`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`. +`IDAM_OIDC_URL` and `OIDC_ISSUER` are intentionally separate. `IDAM_OIDC_URL` supplies `issuer-uri` for discovery and JWKS retrieval, while `OIDC_ISSUER` supplies the enforced issuer. If `OIDC_ISSUER` does not match the `iss` used in real caller tokens, authenticated requests will be rejected with `401`. ## Building the application @@ -162,7 +162,7 @@ export BEFTA_S2S_CLIENT_SECRET_OF_XUI_WEBAPP=AAAAAAAAAAAAAAAA export DM_STORE_BASE_URL=http://localhost:4506 ``` -To verify the live OIDC issuer locally, export `VERIFY_OIDC_ISSUER=true` together with the normal functional test credentials and `OIDC_ISSUER`. The verifier will fetch a real IDAM token, decode its `iss` claim, and fail if it does not exactly match `OIDC_ISSUER`. +To verify the live enforced issuer locally, export `VERIFY_OIDC_ISSUER=true` together with the normal functional test credentials and `OIDC_ISSUER`. The verifier will fetch a real IDAM token, decode its `iss` claim, and fail if it does not exactly match `OIDC_ISSUER`. These tests also rely on the `CCD_BEFTA_JURISDICTION2.xlsx` file to be already imported. This file should be available in your local environment already. diff --git a/build.gradle b/build.gradle index 4a42b975c..d2bdee66b 100644 --- a/build.gradle +++ b/build.gradle @@ -200,7 +200,7 @@ task smoke(type: Test, description: 'Runs the smoke tests.', group: 'Verificatio } task verifyFunctionalTestJwtIssuer(type: JavaExec) { - description = 'Verifies the functional/smoke test token issuer matches OIDC_ISSUER' + description = 'Verifies the functional/smoke test token iss matches OIDC_ISSUER' group = 'Verification' dependsOn functionalTestClasses @@ -212,6 +212,35 @@ task verifyFunctionalTestJwtIssuer(type: JavaExec) { classpath += configurations.cucumberRuntime + sourceSets.functionalTest.runtimeClasspath + sourceSets.main.output + sourceSets.test.output } +task verifyOidcIssuerPolicy { + description = 'Fails if oidc.issuer is derived from discovery configuration' + group = 'Verification' + + doLast { + def policyFiles = [ + 'src/main/resources/application.yaml', + 'src/integrationTest/resources/application-itest.yaml' + ] + + def violations = policyFiles.findAll { path -> + def configFile = file(path) + configFile.exists() && configFile.readLines().any { line -> + def normalized = line.replaceAll(/\s+/, '') + normalized.startsWith('issuer:${OIDC_ISSUER:${IDAM_OIDC_URL') + || normalized.startsWith('issuer:${OIDC_ISSUER:${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 + jacocoTestReport { executionData(test, integration) reports { diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index 08dc80563..02823e1f4 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -13,7 +13,7 @@ ## Discovery vs enforced issuer - `spring.security.oauth2.client.provider.oidc.issuer-uri` is the discovery location. The service uses it to load OIDC metadata and the JWKS endpoint. -- `oidc.issuer` / `OIDC_ISSUER` is the enforced issuer value. The active `JwtDecoder` validates the token `iss` claim against this value. +- `oidc.issuer` / `OIDC_ISSUER` is the enforced issuer. The active `JwtDecoder` validates the token `iss` claim against this value. - These values can differ. Discovery can point at the public IDAM OIDC endpoint while enforcement pins the exact `iss` emitted in real access tokens. ## Runtime behavior @@ -80,3 +80,42 @@ print(json.loads(base64.urlsafe_b64decode(payload))["iss"]) ## Optional future variant Only switch to multi-issuer validation if real traffic genuinely needs both values during migration. In that case, use an explicit allow-list validator rather than disabling issuer validation again. + +## 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 `iss` 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, but it is not yet mandatory across all services. +- 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/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java index 6e7545e6e..5be138fbc 100644 --- a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/BaseTest.java @@ -63,8 +63,8 @@ protected HttpHeaders createHttpHeaders(String serviceName) throws JOSEException return createHttpHeaders(AUTH_TOKEN_TTL, oidcIssuer, serviceName, AUTH_TOKEN_TTL); } - protected HttpHeaders createHttpHeaders(String authIssuer, String serviceName) throws JOSEException { - return createHttpHeaders(AUTH_TOKEN_TTL, authIssuer, serviceName, AUTH_TOKEN_TTL); + protected HttpHeaders createHttpHeaders(String tokenIssuer, String serviceName) throws JOSEException { + return createHttpHeaders(AUTH_TOKEN_TTL, tokenIssuer, serviceName, AUTH_TOKEN_TTL); } protected HttpHeaders createHttpHeaders(long authTtlMillis, @@ -74,11 +74,11 @@ protected HttpHeaders createHttpHeaders(long authTtlMillis, } protected HttpHeaders createHttpHeaders(long authTtlMillis, - String authIssuer, + String tokenIssuer, String serviceName, long s2sAuthTtlMillis) throws JOSEException { HttpHeaders headers = new HttpHeaders(); - String authToken = BEARER + generateAuthToken(authTtlMillis, authIssuer); + String authToken = BEARER + generateAuthToken(authTtlMillis, tokenIssuer); headers.add(AUTHORIZATION, authToken); String s2SToken = generateS2SToken(serviceName, s2sAuthTtlMillis); headers.add(SERVICE_AUTHORIZATION, s2SToken); @@ -159,11 +159,11 @@ protected void verifyLogAuditValues(MvcResult result, } } - public static String generateAuthToken(long ttlMillis, String issuer) throws JOSEException { + public static String generateAuthToken(long ttlMillis, String tokenIssuer) throws JOSEException { JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() .subject("API_Stub") - .issuer(issuer) + .issuer(tokenIssuer) .issueTime(new Date()) .claim(TOKEN_NAME, ACCESS_TOKEN) .expirationTime(new Date(System.currentTimeMillis() + ttlMillis)); diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java index 45dd9d653..6e0c48842 100644 --- a/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java +++ b/src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java @@ -21,13 +21,13 @@ class SecurityConfigurationIT extends BaseTest { private JwtDecoder jwtDecoder; @Value("${oidc.issuer}") - private String expectedIssuer; + private String enforcedIssuer; @Test void shouldDecodeJwtWhenTokenIssMatchesConfiguredIssuer() throws JOSEException { - Jwt jwt = assertDoesNotThrow(() -> jwtDecoder.decode(generateAuthToken(AUTH_TOKEN_TTL, expectedIssuer))); + Jwt jwt = assertDoesNotThrow(() -> jwtDecoder.decode(generateAuthToken(AUTH_TOKEN_TTL, enforcedIssuer))); - assertThat(jwt.getIssuer().toString()).isEqualTo(expectedIssuer); + assertThat(jwt.getIssuer().toString()).isEqualTo(enforcedIssuer); } @Test diff --git a/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java b/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java index 0fc93ca8b..01b2aedf7 100644 --- a/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java +++ b/src/main/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfiguration.java @@ -34,7 +34,7 @@ public class SecurityConfiguration { private String issuerUri; @Value("${oidc.issuer}") - private String issuerOverride; + private String enforcedIssuer; private final ServiceAuthFilter serviceAuthFilter; private final ExceptionHandlingFilter exceptionHandlingFilter; @@ -89,9 +89,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @SuppressWarnings("PMD") 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 withIssuer = new JwtIssuerValidator(enforcedIssuer); OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withIssuer); jwtDecoder.setJwtValidator(validator); return jwtDecoder; diff --git a/src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java b/src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java index 1c7a75dd0..7c333a279 100644 --- a/src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java +++ b/src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java @@ -16,18 +16,18 @@ class SecurityConfigurationTest { - private static final String VALID_ISSUER = "http://fr-am:8080/openam/oauth2/hmcts"; - private static final String INVALID_ISSUER = "http://unexpected-issuer"; + private static final String ENFORCED_ISSUER = "http://fr-am:8080/openam/oauth2/hmcts"; + private static final String UNEXPECTED_ISSUER = "http://unexpected-issuer"; @Test void shouldAcceptJwtFromConfiguredIssuer() { - assertFalse(validator().validate(buildJwt(VALID_ISSUER, Instant.now().plusSeconds(300))).hasErrors()); + assertFalse(validator().validate(buildJwt(ENFORCED_ISSUER, Instant.now().plusSeconds(300))).hasErrors()); } @Test void shouldRejectJwtFromUnexpectedIssuer() { OAuth2TokenValidatorResult result = - validator().validate(buildJwt(INVALID_ISSUER, Instant.now().plusSeconds(300))); + validator().validate(buildJwt(UNEXPECTED_ISSUER, Instant.now().plusSeconds(300))); assertTrue(result.hasErrors()); assertThat(result.getErrors()) @@ -36,21 +36,21 @@ void shouldRejectJwtFromUnexpectedIssuer() { @Test void shouldRejectExpiredJwtEvenWhenIssuerMatches() { - assertTrue(validator().validate(buildJwt(VALID_ISSUER, Instant.now().minusSeconds(60))).hasErrors()); + assertTrue(validator().validate(buildJwt(ENFORCED_ISSUER, Instant.now().minusSeconds(60))).hasErrors()); } private OAuth2TokenValidator validator() { return new DelegatingOAuth2TokenValidator<>( new JwtTimestampValidator(), - new JwtIssuerValidator(VALID_ISSUER) + new JwtIssuerValidator(ENFORCED_ISSUER) ); } - private Jwt buildJwt(String issuer, Instant expiresAt) { + private Jwt buildJwt(String tokenIssuer, Instant expiresAt) { Instant issuedAt = expiresAt.minusSeconds(60); return Jwt.withTokenValue("token") .header("alg", "RS256") - .issuer(issuer) + .issuer(tokenIssuer) .subject("user") .issuedAt(issuedAt) .expiresAt(expiresAt) From 2dc6fbec6b0f32017204870d442a8d6a23f101aa Mon Sep 17 00:00:00 2001 From: patelila Date: Wed, 1 Apr 2026 14:10:42 +0100 Subject: [PATCH 8/8] docs: expand JWT issuer validation guidance --- docs/security/jwt-issuer-validation.md | 38 ++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md index 02823e1f4..1183ab164 100644 --- a/docs/security/jwt-issuer-validation.md +++ b/docs/security/jwt-issuer-validation.md @@ -9,12 +9,31 @@ - JWT issuer validation is enabled in the active `JwtDecoder`. - OIDC discovery and issuer enforcement are configured separately on purpose. - The enforced issuer must be taken from a real access token `iss` claim, not inferred from discovery metadata or deployment naming. +- See [HMCTS Guidance](#hmcts-guidance) for the central policy 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) +- Use that guidance as the reference point for service-level issuer decisions and configuration recommendations. + +## Quick Reference + +| Topic | Current repo position | +| --- | --- | +| Validation model | Single configured issuer | +| Discovery source | `spring.security.oauth2.client.provider.oidc.issuer-uri` | +| Enforced issuer | `oidc.issuer` / `OIDC_ISSUER` | +| Repo wiring | Helm and Jenkins currently use explicit issuer values | +| Runtime rule | `OIDC_ISSUER` must match the `iss` claim in real accepted tokens | ## Discovery vs enforced issuer -- `spring.security.oauth2.client.provider.oidc.issuer-uri` is the discovery location. The service uses it to load OIDC metadata and the JWKS endpoint. -- `oidc.issuer` / `OIDC_ISSUER` is the enforced issuer. The active `JwtDecoder` validates the token `iss` claim against this value. -- These values can differ. Discovery can point at the public IDAM OIDC endpoint while enforcement pins the exact `iss` emitted in real access tokens. +| Setting | Purpose | Notes | +| --- | --- | --- | +| `spring.security.oauth2.client.provider.oidc.issuer-uri` | OIDC discovery and JWKS lookup | The service uses it to load OIDC metadata and keys | +| `oidc.issuer` / `OIDC_ISSUER` | Enforced token issuer | The active `JwtDecoder` validates the token `iss` claim against this value | + +These values can differ. Discovery can point at the public IDAM OIDC endpoint while enforcement pins the exact `iss` emitted in real access tokens. ## Runtime behavior @@ -31,9 +50,11 @@ ## Coverage -- Unit coverage in `src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java` checks valid issuer, invalid issuer, and expired token behaviour at validator level. -- Decoder exception coverage in `src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java` checks decoder-level issuer failures with the active decoder and signed test JWTs. -- Integration coverage in `src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java` exercises authenticated endpoint rejection when a token carries unexpected `iss`. +| Test area | Coverage | +| --- | --- | +| `src/test/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationTest.java` | Valid issuer, invalid issuer, and expired token behaviour at validator level | +| `src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/configuration/SecurityConfigurationIT.java` | Decoder-level issuer failures with the active decoder and signed test JWTs | +| `src/integrationTest/java/uk/gov/hmcts/reform/ccd/documentam/controller/CaseDocumentAmControllerIT.java` | Authenticated endpoint rejection when a token carries unexpected `iss` | ## Test and pipeline verification @@ -53,6 +74,7 @@ - Because the verifier runs in the build container before deployed app env is available, Jenkins and Helm issuer values must stay aligned. - 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 usually point to `http://localhost:5000`, while `OIDC_ISSUER` must exactly match the `iss` claim in the local access tokens being used. +- Use [HMCTS Guidance](#hmcts-guidance) as the central policy reference for service-level issuer decisions. ## How to derive OIDC_ISSUER @@ -119,3 +141,7 @@ Do not merge if any of the following are true: - Requiring explicit `OIDC_ISSUER` with no static fallback in main runtime config is the preferred pattern, but it is not yet mandatory across all services. - 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. + +## References + +- [HMCTS Guidance](#hmcts-guidance)