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 c0806fb6d..6e803a502 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..4b56ee8f4 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-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" @@ -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..ceb4dfdf7 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-aat2.internal:8443/openam/oauth2/realms/root/realms/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..974d20a7f 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 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. `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 @@ -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 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. ####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 6a46a699a..d2bdee66b 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,48 @@ 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 iss 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 +} + +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 { @@ -297,8 +341,9 @@ dependencies { // HMCTS 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' diff --git a/charts/ccd-case-document-am-api/Chart.yaml b/charts/ccd-case-document-am-api/Chart.yaml index 9bf45a111..1901ad8eb 100644 --- a/charts/ccd-case-document-am-api/Chart.yaml +++ b/charts/ccd-case-document-am-api/Chart.yaml @@ -3,10 +3,10 @@ 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: - 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..27685ce1e 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 @@ -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 diff --git a/docs/security/jwt-issuer-validation.md b/docs/security/jwt-issuer-validation.md new file mode 100644 index 000000000..1183ab164 --- /dev/null +++ b/docs/security/jwt-issuer-validation.md @@ -0,0 +1,147 @@ +# 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. +- 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 + +| 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 + +- `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 + +| 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 + +- 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. +- Use [HMCTS Guidance](#hmcts-guidance) as the central policy reference for service-level issuer decisions. + +## 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. + +## 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. + +## References + +- [HMCTS Guidance](#hmcts-guidance) 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..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 @@ -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; @@ -16,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; @@ -32,9 +34,19 @@ @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}) +@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 { @@ -49,6 +61,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..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 @@ -1,28 +1,52 @@ 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.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; + @Configuration public class ContractConfig { - @MockBean - DocumentManagementService documentManagementService; + @Bean + @Primary + public DocumentManagementService documentManagementService() { + return mock(DocumentManagementService.class); + } - @MockBean - SecurityUtils securityUtils; + @Bean + @Primary + public SecurityUtils securityUtils() { + return mock(SecurityUtils.class); + } - @MockBean - ApplicationParams applicationParams; + @Bean + @Primary + public ApplicationParams applicationParams() { + 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 @Primary - public CaseDocumentAmController caseDocumentAmController() { + public CaseDocumentAmController caseDocumentAmController(DocumentManagementService documentManagementService, + SecurityUtils securityUtils, + ApplicationParams applicationParams) { return new CaseDocumentAmController(documentManagementService, securityUtils, applicationParams); } 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..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 @@ -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 tokenIssuer, String serviceName) throws JOSEException { + return createHttpHeaders(AUTH_TOKEN_TTL, tokenIssuer, 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 tokenIssuer, String serviceName, - long s2sAuthTtlMillis) throws JOSEException { + long s2sAuthTtlMillis) throws JOSEException { HttpHeaders headers = new HttpHeaders(); - String authToken = BEARER + generateAuthToken(authTtlMillis); + String authToken = BEARER + generateAuthToken(authTtlMillis, tokenIssuer); 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 tokenIssuer) throws JOSEException { JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() .subject("API_Stub") + .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 new file mode 100644 index 000000000..6e0c48842 --- /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 enforcedIssuer; + + @Test + void shouldDecodeJwtWhenTokenIssMatchesConfiguredIssuer() throws JOSEException { + Jwt jwt = assertDoesNotThrow(() -> jwtDecoder.decode(generateAuthToken(AUTH_TOKEN_TTL, enforcedIssuer))); + + assertThat(jwt.getIssuer().toString()).isEqualTo(enforcedIssuer); + } + + @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..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,11 +89,10 @@ 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 validator = new DelegatingOAuth2TokenValidator<>(withTimestamp, withIssuer); - OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withTimestamp); + 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 new file mode 100644 index 000000000..7c333a279 --- /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 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(ENFORCED_ISSUER, Instant.now().plusSeconds(300))).hasErrors()); + } + + @Test + void shouldRejectJwtFromUnexpectedIssuer() { + OAuth2TokenValidatorResult result = + validator().validate(buildJwt(UNEXPECTED_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(ENFORCED_ISSUER, Instant.now().minusSeconds(60))).hasErrors()); + } + + private OAuth2TokenValidator validator() { + return new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), + new JwtIssuerValidator(ENFORCED_ISSUER) + ); + } + + private Jwt buildJwt(String tokenIssuer, Instant expiresAt) { + Instant issuedAt = expiresAt.minusSeconds(60); + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .issuer(tokenIssuer) + .subject("user") + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .build(); + } +}