From e31d8bc81e4a079254dd4bc2baa1e01b1648c272 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Nov 2026 00:35:46 +0700 Subject: [PATCH 1/2] chore: retire the eSignet sign-in path The optional eSignet profile ran the Java eSignet authenticator 0.2.0 against the NIA Relay, which no longer matches how eSignet is built and deployed elsewhere. The portal keeps only its mock login. Remove the local and Coolify eSignet compose profiles, the four eSignet images and their release-candidate builds, the portal OIDC code and settings, the nia-esignet Mint client with its generated key and hosted provisioning input, the authenticator pins, the eSignet just recipes and smokes, and the Visitor Center eSignet status entry. Tests now assert the mock-only behaviour. The NIA Relay project and its esignet-userinfo lookup stay: the Relay is part of the five-Relay topology and the project is a governed, compiled package. Signed-off-by: Jeremi Joslin --- .env.example | 11 - .github/workflows/release-candidate.yml | 53 +-- README.md | 13 +- compose.coolify.esignet.yaml | 140 -------- compose.coolify.provision.yaml | 2 - compose.coolify.yaml | 12 - compose.esignet.yaml | 142 -------- compose.yaml | 1 - config/esignet/init.sql | 276 --------------- config/esignet/nginx-hosted.conf | 105 ------ config/esignet/nginx.conf | 100 ------ docker/esignet-postgres/Dockerfile | 4 - docker/esignet-relay/Dockerfile | 26 -- docker/esignet-seed/Dockerfile | 12 - docker/esignet-ui/Dockerfile | 18 - docker/esignet-ui/hosted-entrypoint.sh | 8 - docker/esignet-ui/render-hosted-nginx.sh | 46 --- docs/changelog.md | 19 + docs/esignet.md | 24 -- docs/hosted-deployment.md | 22 +- docs/solmara.md | 6 +- evidence/scripts/build-cells.py | 7 +- evidence/tests/test_cells.py | 36 +- home/e2e/home.test.ts | 6 +- home/src/lib/server/services.test.ts | 4 +- home/src/lib/server/services.ts | 15 +- justfile | 15 +- metadata/public/metadata/catalog.json | 2 +- metadata/public/metadata/index.json | 4 +- metadata/public/metadata/metadata.yaml | 2 +- metadata/solmara-wave1.metadata.yaml | 2 +- portal/e2e/persona.test.ts | 25 +- portal/e2e/support/auth.ts | 135 +------ portal/playwright.config.ts | 6 +- portal/src/lib/fields/EvidenceField.svelte | 4 +- portal/src/lib/fields/EvidenceField.test.ts | 4 +- portal/src/lib/forms/identity.ts | 6 +- portal/src/lib/proof/ProofTicker.svelte | 4 +- portal/src/lib/proof/ProofTicker.test.ts | 6 +- portal/src/lib/proof/canned-traces.ts | 4 +- portal/src/lib/server/esignet.test.ts | 149 -------- portal/src/lib/server/esignet.ts | 335 ------------------ portal/src/lib/server/personas.ts | 7 +- portal/src/lib/server/session.ts | 14 +- portal/src/lib/types.ts | 2 +- portal/src/routes/auth/auth.test.ts | 81 +++++ portal/src/routes/auth/callback/+server.ts | 31 +- portal/src/routes/auth/login/+server.ts | 24 +- portal/src/routes/gallery/fields/+page.svelte | 2 +- .../50 - Relay V2/02 - NIA eSignet lookup.bru | 20 -- requests/registry-lab/README.md | 4 +- .../registry-lab/environments/Hosted Lab.bru | 1 - .../environments/Local Compose.bru | 1 - scripts/check-config-secrets.py | 9 +- scripts/check-coolify-compose.sh | 14 +- scripts/check-image-pins.py | 4 +- scripts/check-registry-stack-release-pin.py | 18 - scripts/check-runtime-topology.py | 6 - scripts/gen-secrets.py | 35 +- scripts/hosted-image-manifest.py | 4 - scripts/provision-hosted-runtime.py | 4 - scripts/seed-esignet.py | 254 ------------- scripts/smoke-esignet-login.mjs | 22 -- scripts/smoke-esignet.py | 200 ----------- scripts/smoke-hosted-provisioner-image.py | 4 - scripts/start-esignet-relay.sh | 49 --- scripts/test_gen_secrets.py | 27 +- scripts/test_hosted_esignet_topology.py | 195 ---------- scripts/test_hosted_image_manifest.py | 28 +- scripts/test_hosted_provisioning_topology.py | 5 +- scripts/test_image_pins.py | 2 - scripts/test_provision_hosted_runtime.py | 3 +- scripts/test_registry_stack_release_pin.py | 16 +- scripts/test_runtime_topology.py | 122 ++----- scripts/test_smoke_esignet.py | 221 ------------ versions.env | 9 - 76 files changed, 232 insertions(+), 3017 deletions(-) delete mode 100644 compose.coolify.esignet.yaml delete mode 100644 compose.esignet.yaml delete mode 100644 config/esignet/init.sql delete mode 100644 config/esignet/nginx-hosted.conf delete mode 100644 config/esignet/nginx.conf delete mode 100644 docker/esignet-postgres/Dockerfile delete mode 100644 docker/esignet-relay/Dockerfile delete mode 100644 docker/esignet-seed/Dockerfile delete mode 100644 docker/esignet-ui/Dockerfile delete mode 100755 docker/esignet-ui/hosted-entrypoint.sh delete mode 100755 docker/esignet-ui/render-hosted-nginx.sh delete mode 100644 docs/esignet.md delete mode 100644 portal/src/lib/server/esignet.test.ts delete mode 100644 portal/src/lib/server/esignet.ts create mode 100644 portal/src/routes/auth/auth.test.ts delete mode 100644 requests/registry-lab/50 - Relay V2/02 - NIA eSignet lookup.bru delete mode 100755 scripts/seed-esignet.py delete mode 100644 scripts/smoke-esignet-login.mjs delete mode 100755 scripts/smoke-esignet.py delete mode 100755 scripts/start-esignet-relay.sh delete mode 100644 scripts/test_hosted_esignet_topology.py delete mode 100644 scripts/test_smoke_esignet.py diff --git a/.env.example b/.env.example index 7e12e99..7931e5b 100644 --- a/.env.example +++ b/.env.example @@ -13,14 +13,3 @@ NAGDI_RELAY_CURSOR_KEY= CHILD_BENEFIT_FEDERATOR_TOKEN= SOLMARA_EVIDENCE_CLIENT_KEY=/absolute/path/to/solmara-lab/config/evidence/local/cells/mint/clients/solmara-demo-client-key PORTAL_SESSION_SECRET= -PORTAL_AUTH_PROVIDER=mock - -# Optional local eSignet profile. -SOLMARA_ESIGNET_POSTGRES_PASSWORD= -NIA_ESIGNET_CLIENT_PRIVATE_JWK= -REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD= -REGISTRY_ESIGNET_KYC_TOKEN_SECRET= -REGISTRY_ESIGNET_PSUT_SECRET= -PORTAL_ESIGNET_CLIENT_ID=solmara-portal -PORTAL_ESIGNET_CLIENT_KEY_ID=solmara-portal-key-1 -PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64= diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index c14e754..253a7b5 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -33,13 +33,11 @@ jobs: printf 'source_commit=%s\n' "$REGISTRY_STACK_SOURCE_COMMIT" >> "$GITHUB_OUTPUT" for key in REGISTRY_STACK_REQUIRED_VERSION \ REGISTRY_RELAY_IMAGE SOLMARA_EVIDENCE_IMAGE SOLMARA_MINT_IMAGE \ - ESIGNET_BASE_IMAGE ESIGNET_POSTGRES_IMAGE ESIGNET_UI_IMAGE \ - ESIGNET_AUTHENTICATOR_JAR_URL ESIGNET_AUTHENTICATOR_JAR_SHA256 \ REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256; do value="$(printenv "$key")" printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV" done - - name: Require published Registry Stack and authenticator pins + - name: Require published Registry Stack pins run: scripts/check-registry-stack-release-pin.py --require-public - name: Checkout the exact Registry Stack source uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -388,51 +386,6 @@ jobs: platforms: linux/amd64 push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-portal:${{ env.SOLMARA_IMAGE_TAG }} - - name: Build and push eSignet Relay V2 authenticator image - id: esignet_relay - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: docker/esignet-relay/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-relay:${{ env.SOLMARA_IMAGE_TAG }} - build-args: | - ESIGNET_BASE_IMAGE=${{ env.ESIGNET_BASE_IMAGE }} - ESIGNET_AUTHENTICATOR_JAR_URL=${{ env.ESIGNET_AUTHENTICATOR_JAR_URL }} - ESIGNET_AUTHENTICATOR_JAR_SHA256=${{ env.ESIGNET_AUTHENTICATOR_JAR_SHA256 }} - - name: Build and push isolated eSignet database - id: esignet_postgres - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: docker/esignet-postgres/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-postgres:${{ env.SOLMARA_IMAGE_TAG }} - build-args: | - POSTGRES_IMAGE=${{ env.ESIGNET_POSTGRES_IMAGE }} - - name: Build and push eSignet UI - id: esignet_ui - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: docker/esignet-ui/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-ui:${{ env.SOLMARA_IMAGE_TAG }} - build-args: | - ESIGNET_UI_IMAGE=${{ env.ESIGNET_UI_IMAGE }} - ESIGNET_NGINX_CONF=config/esignet/nginx-hosted.conf - - name: Build and push eSignet seed image - id: esignet_seed - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: docker/esignet-seed/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-seed:${{ env.SOLMARA_IMAGE_TAG }} - name: Generate Coolify image manifest env: REGISTRY_RELAY_IMAGE: ${{ env.REGISTRY_RELAY_IMAGE }} @@ -444,10 +397,6 @@ jobs: SOLMARA_SCENARIO_RUNNER_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-scenario-runner@${{ steps.scenario_runner.outputs.digest }} SOLMARA_HOME_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-home@${{ steps.home.outputs.digest }} SOLMARA_PORTAL_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-portal@${{ steps.portal.outputs.digest }} - SOLMARA_ESIGNET_RELAY_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-relay@${{ steps.esignet_relay.outputs.digest }} - SOLMARA_ESIGNET_POSTGRES_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-postgres@${{ steps.esignet_postgres.outputs.digest }} - SOLMARA_ESIGNET_UI_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-ui@${{ steps.esignet_ui.outputs.digest }} - SOLMARA_ESIGNET_SEED_IMAGE: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-seed@${{ steps.esignet_seed.outputs.digest }} run: | set -euo pipefail manifest="$RUNNER_TEMP/solmara-release/solmara-hosted-images.env" diff --git a/README.md b/README.md index 2a74eb8..c4d0e36 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ The reset has two evidence cadences: Six Evidence gateways have distinct providers, issuers, signing keys, JWKS, audit sinks, subject-binding secrets, and endpoints. Five Relays expose only the -named non-enumerating operations needed by the lab. NIA's Relay is reserved for -the optional eSignet UserInfo profile; NIA Evidence reads its own extract. +named non-enumerating operations needed by the lab. NIA's Relay serves one +exact lookup that no lab client calls; NIA Evidence reads its own extract. ## Release prerequisite @@ -30,9 +30,7 @@ official OCI references by digest, and the `relayctl` binary checksum in same full references without reconstructing them from a second deployment input. -The eSignet profile uses the separately released -`esignet-relay-authenticator` v0.2.0 JAR and its matching SHA-256 checksum. No -source-build, locally wrapped runtime, floating-tag, or v0.19 compatibility +No source-build, locally wrapped runtime, floating-tag, or v0.19 compatibility fallback is accepted. ## Quick start @@ -72,7 +70,7 @@ never assumes a national Evidence host. | Authority | Evidence source | Relay V2 role | |---|---|---| | CRA | immutable birth extract; Relay for death and civil link | `civil-person/death-by-uin`, `civil-person/citizen-link-by-uin` | -| NIA | immutable population extract | `population-person/esignet-userinfo` for eSignet | +| NIA | immutable population extract | `population-person/esignet-userinfo` (retained; no lab client calls it) | | SRO | immutable poverty extract | none | | MoSD | Relay lookup | `beneficiary-enrolment/by-uin` | | SIPF | Relay lookups | `pension-payment/by-pensioner-uin`, `survivor-case/by-spouse-uin` | @@ -115,13 +113,12 @@ just relay-check # relayctl check --production, generate, test, package just evidence-check # six bundles, 11 requirements, source/error fixtures just lint # metadata, redaction, portal and Visitor Center checks just test # Python and web unit/integration suites -just compose # local, hosted, Coolify, and optional eSignet config +just compose # local, hosted, and Coolify config just smoke # local UI and Relay health just programme-acceptance # programme stories and generic denial controls just lifecycle-proof # live Relay and immutable-extract cadence just portal-live-e2e just home-live-e2e -just smoke-esignet # optional NIA Relay V2 login profile ``` Generated SQLite databases, Relay packages, runtime bundles, keys, secrets, diff --git a/compose.coolify.esignet.yaml b/compose.coolify.esignet.yaml deleted file mode 100644 index d2eb6ec..0000000 --- a/compose.coolify.esignet.yaml +++ /dev/null @@ -1,140 +0,0 @@ -name: solmara-esignet-authority-cells - -services: - esignet-database: - image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:?set digest-pinned eSignet database image} - environment: - POSTGRES_USER: esignet - POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?required} - volumes: [esignet-db-data:/var/lib/postgresql] - healthcheck: - test: [CMD-SHELL, pg_isready -U esignet -d esignet] - interval: 5s - timeout: 5s - retries: 30 - - esignet-redis: - image: ${ESIGNET_REDIS_IMAGE:?versions.env must pin Redis} - command: [redis-server] - volumes: [esignet-redis-data:/data] - healthcheck: - test: [CMD, redis-cli, ping] - interval: 10s - timeout: 5s - retries: 30 - - esignet: - image: ${SOLMARA_ESIGNET_RELAY_IMAGE:?set digest-pinned eSignet image} - restart: unless-stopped - user: root - environment: - active_profile_env: default,local - spring_config_label_env: "" - spring_config_url_env: "" - hsm_client_zip_url_env: "" - hsm_local_dir_env: hsm-client - loader_path_env: /home/mosip/additional_jars/ - work_dir: /home/mosip - container_user: mosip - plugin_name_env: esignet-mock-plugin.jar,esignet-relay-authenticator.jar - plugin_url_env: "" - plugins_path_env: /home/mosip/plugins - amr_acr_mapping_file_path_env: /home/mosip/amr_acr_mapping.json - KAFKA_ENABLED: "false" - SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration - SPRING_CACHE_TYPE: redis - SPRING_DATA_REDIS_HOST: esignet-redis - MOSIP_ESIGNET_HOST: ${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org} - MOSIP_ESIGNET_DISCOVERY_ISSUER_ID: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org} - MOSIP_ESIGNET_DISCOVERY_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token','userinfo_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oidc/userinfo','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" - MOSIP_ESIGNET_OAUTH_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" - MOSIP_ESIGNET_DATABASE_URL: jdbc:postgresql://esignet-database:5432/mosip_esignet?currentSchema=esignet - MOSIP_ESIGNET_DATABASE_USERNAME: esignet - MOSIP_ESIGNET_DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?required} - MOSIP_ESIGNET_INTEGRATION_SCAN_BASE_PACKAGE: io.mosip.esignet.mock.integration,io.registry.esignet.relay - MOSIP_ESIGNET_INTEGRATION_AUTHENTICATOR: RelayAuthenticationService - MOSIP_ESIGNET_INTEGRATION_KEY_BINDER: MockKeyBindingWrapperService - MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS: "{'profile' : {'given_name','family_name','gender','birthdate','individual_id'}}" - REGISTRY_RELAY_BASE_URL: https://nia-relay-authority-cells.solmara.registrystack.org - REGISTRY_RELAY_RESOURCE: population-person - REGISTRY_RELAY_LOOKUP: esignet-userinfo - REGISTRY_RELAY_ACCESS_PROFILE: esignet - REGISTRY_RELAY_DEFAULT_CLAIMS: individualId,givenName,familyName,birthdate,gender - SPRING_APPLICATION_JSON: '{"registry":{"esignet":{"claim-map":{"sub":"$$psut","individual_id":"individualId","given_name":"givenName","family_name":"familyName","birthdate":"birthdate","gender":"gender"}}}}' - REGISTRY_MINT_TOKEN_ENDPOINT: https://mint-authority-cells.solmara.registrystack.org/token - REGISTRY_MINT_CLIENT_ID: nia-esignet - REGISTRY_MINT_PRIVATE_JWK: ${NIA_ESIGNET_CLIENT_PRIVATE_JWK:?required} - REGISTRY_MINT_TOKEN_CACHE_MAX_SECONDS: "300" - REGISTRY_ESIGNET_AUTH_OTP_STATIC_ENABLED: "true" - REGISTRY_ESIGNET_AUTH_OTP_STATIC_VALUE: ${ESIGNET_DEMO_OTP:?required} - REGISTRY_ESIGNET_AUTH_OTP_CHANNELS: EMAIL,PHONE - REGISTRY_ESIGNET_ACCOUNT_CHECK_CLAIMS: individualId - MOSIP_ESIGNET_AUTHENTICATOR_IDA_OTP_CHANNELS: EMAIL,PHONE - REGISTRY_ESIGNET_KYC_TOKEN_HMAC_SECRET: ${REGISTRY_ESIGNET_KYC_TOKEN_SECRET:?required} - REGISTRY_ESIGNET_PSUT_HMAC_SECRET: ${REGISTRY_ESIGNET_PSUT_SECRET:?required} - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PATH: /home/mosip/kyc-signing/kyc-signing.p12 - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_TYPE: PKCS12 - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?required} - REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS: esignet-relay-kyc - REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?required} - MOSIP_KERNEL_KEYMANAGER_HSM_CONFIG_PATH: /home/mosip/keystore/esignet_local.p12 - volumes: - - esignet-keystore:/home/mosip/keystore - - esignet-kyc-signing:/home/mosip/kyc-signing - depends_on: - esignet-database: {condition: service_healthy} - esignet-redis: {condition: service_healthy} - labels: - solmara.rollout.slot: authority-cells - - esignet_ui: - image: ${SOLMARA_ESIGNET_UI_IMAGE:?set digest-pinned eSignet UI image} - environment: - SOLMARA_ESIGNET_PUBLIC_HOST: ${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org} - SOLMARA_ESIGNET_UI_PUBLIC_HOST: ${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-esignet-ui.solmara.registrystack.org} - depends_on: - esignet: {condition: service_started} - labels: - solmara.lab.host: ${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-esignet-ui.solmara.registrystack.org} - solmara.rollout.slot: authority-cells - - # eSignet answers only under /v1/esignet, so the issuer origin would publish - # neither its OpenID discovery document nor its RFC 8414 authorization-server - # metadata. The UI image is a host-agnostic reverse proxy that serves both at - # the root and forwards /v1/esignet, so it also fronts the issuer origin. - # Coolify matches a routed service against the compose key but resolves it - # after rewriting "-" to "_", so both proxies carry underscore names. - esignet_edge: - image: ${SOLMARA_ESIGNET_UI_IMAGE:?set digest-pinned eSignet UI image} - environment: - SOLMARA_ESIGNET_PUBLIC_HOST: ${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org} - SOLMARA_ESIGNET_UI_PUBLIC_HOST: ${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-esignet-ui.solmara.registrystack.org} - depends_on: - esignet: {condition: service_started} - labels: - solmara.lab.host: ${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org} - solmara.rollout.slot: authority-cells - - esignet-seed: - image: ${SOLMARA_ESIGNET_SEED_IMAGE:?set digest-pinned eSignet seed image} - restart: "no" - environment: - PGHOST: esignet-database - PGUSER: esignet - PGPASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?required} - ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} - ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?required} - ESIGNET_REDIS_HOST: esignet-redis - ESIGNET_REQUIRE_HTTPS_REDIRECTS: "true" - ESIGNET_CLIENT_REDIRECT_URIS_JSON: '["${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback"]' - entrypoint: [seed-esignet.py] - depends_on: - esignet-database: {condition: service_healthy} - esignet: {condition: service_started} - -volumes: - esignet-db-data: - esignet-keystore: - esignet-kyc-signing: - esignet-redis-data: diff --git a/compose.coolify.provision.yaml b/compose.coolify.provision.yaml index 6f617c8..fd63db9 100644 --- a/compose.coolify.provision.yaml +++ b/compose.coolify.provision.yaml @@ -136,7 +136,6 @@ services: - {source: sipf-survivor-evidence-client-public-jwk, target: /tmp/solmara-provisioning/sipf-survivor-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} - {source: nagdi-voucher-evidence-client-public-jwk, target: /tmp/solmara-provisioning/nagdi-voucher-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} - {source: nagdi-livestock-evidence-client-public-jwk, target: /tmp/solmara-provisioning/nagdi-livestock-evidence-public.jwk, uid: "0", gid: "0", mode: 0400} - - {source: nia-esignet-client-public-jwk, target: /tmp/solmara-provisioning/nia-esignet-public.jwk, uid: "0", gid: "0", mode: 0400} - {source: solmara-demo-client-public-jwk, target: /tmp/solmara-provisioning/solmara-demo-client-public.jwk, uid: "0", gid: "0", mode: 0400} provisioning-ready: @@ -198,5 +197,4 @@ secrets: sipf-survivor-evidence-client-public-jwk: {environment: SIPF_SURVIVOR_EVIDENCE_CLIENT_PUBLIC_JWK} nagdi-voucher-evidence-client-public-jwk: {environment: NAGDI_VOUCHER_EVIDENCE_CLIENT_PUBLIC_JWK} nagdi-livestock-evidence-client-public-jwk: {environment: NAGDI_LIVESTOCK_EVIDENCE_CLIENT_PUBLIC_JWK} - nia-esignet-client-public-jwk: {environment: NIA_ESIGNET_CLIENT_PUBLIC_JWK} solmara-demo-client-public-jwk: {environment: SOLMARA_DEMO_CLIENT_PUBLIC_JWK} diff --git a/compose.coolify.yaml b/compose.coolify.yaml index 6865cf4..42a97d9 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -138,19 +138,7 @@ services: environment: <<: *hosted-evidence PORTAL_PROVIDER: live - PORTAL_AUTH_PROVIDER: ${PORTAL_AUTH_PROVIDER:-mock} PORTAL_SECURE_COOKIES: "true" - PORTAL_ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} - PORTAL_ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:-} - PORTAL_ESIGNET_ISSUER: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org} - PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT: ${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize - PORTAL_ESIGNET_TOKEN_ENDPOINT: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token - PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token - PORTAL_ESIGNET_USERINFO_ENDPOINT: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oidc/userinfo - PORTAL_ESIGNET_REDIRECT_URI: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback - PORTAL_ESIGNET_SCOPE: openid profile - PORTAL_ESIGNET_SUBJECT_CLAIM: sub SCENARIO_RUNNER_URL: ${SOLMARA_SCENARIO_RUNNER_PUBLIC_BASE_URL:-https://scenarios.solmara.registrystack.org} CHILD_BENEFIT_FEDERATOR_URL: ${SOLMARA_CHILD_BENEFIT_FEDERATOR_PUBLIC_BASE_URL:-https://child-benefit.solmara.registrystack.org} secrets: diff --git a/compose.esignet.yaml b/compose.esignet.yaml deleted file mode 100644 index dd43ad8..0000000 --- a/compose.esignet.yaml +++ /dev/null @@ -1,142 +0,0 @@ -services: - esignet-database: - build: - context: . - dockerfile: docker/esignet-postgres/Dockerfile - args: {POSTGRES_IMAGE: "${ESIGNET_POSTGRES_IMAGE:?versions.env must pin eSignet database image}"} - image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:-solmara-lab-esignet-db:local} - environment: {POSTGRES_USER: esignet, POSTGRES_PASSWORD: "${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets}"} - volumes: ["esignet-db-data:/var/lib/postgresql"] - networks: [runtime] - healthcheck: - test: ["CMD-SHELL", "pg_isready -U esignet -d esignet"] - interval: 5s - timeout: 5s - retries: 30 - - esignet-redis: - image: ${ESIGNET_REDIS_IMAGE:?versions.env must pin eSignet Redis} - command: ["redis-server"] - volumes: ["esignet-redis-data:/data"] - networks: [runtime] - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 30 - - esignet: - build: - context: . - dockerfile: docker/esignet-relay/Dockerfile - args: - ESIGNET_BASE_IMAGE: ${ESIGNET_BASE_IMAGE:?versions.env must pin eSignet} - ESIGNET_AUTHENTICATOR_JAR_URL: ${ESIGNET_AUTHENTICATOR_JAR_URL:?eSignet authenticator v0.2.0 JAR is not published} - ESIGNET_AUTHENTICATOR_JAR_SHA256: ${ESIGNET_AUTHENTICATOR_JAR_SHA256:?eSignet authenticator v0.2.0 checksum is not published} - image: ${SOLMARA_ESIGNET_RELAY_IMAGE:-solmara-lab-esignet:local} - user: root - environment: - active_profile_env: default,local - spring_config_label_env: "" - spring_config_url_env: "" - hsm_client_zip_url_env: "" - hsm_local_dir_env: hsm-client - loader_path_env: /home/mosip/additional_jars/ - work_dir: /home/mosip - container_user: mosip - plugin_name_env: esignet-mock-plugin.jar,esignet-relay-authenticator.jar - plugin_url_env: "" - plugins_path_env: /home/mosip/plugins - amr_acr_mapping_file_path_env: /home/mosip/amr_acr_mapping.json - KAFKA_ENABLED: "false" - SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration - SPRING_CACHE_TYPE: redis - SPRING_DATA_REDIS_HOST: esignet-redis - MOSIP_ESIGNET_HOST: esignet - MOSIP_ESIGNET_DISCOVERY_ISSUER_ID: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308} - MOSIP_ESIGNET_DISCOVERY_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-http://127.0.0.1:4309}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/v2/token','userinfo_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oidc/userinfo','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" - MOSIP_ESIGNET_OAUTH_KEY_VALUES: "{'issuer':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}','authorization_endpoint':'${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-http://127.0.0.1:4309}/authorize','token_endpoint':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/v2/token','jwks_uri':'${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/.well-known/jwks.json','token_endpoint_auth_methods_supported':{'private_key_jwt'},'token_endpoint_auth_signing_alg_values_supported':{'RS256','PS256','ES256'}}" - MOSIP_ESIGNET_DATABASE_URL: jdbc:postgresql://esignet-database:5432/mosip_esignet?currentSchema=esignet - MOSIP_ESIGNET_DATABASE_USERNAME: esignet - MOSIP_ESIGNET_DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} - MOSIP_ESIGNET_INTEGRATION_SCAN_BASE_PACKAGE: io.mosip.esignet.mock.integration,io.registry.esignet.relay - MOSIP_ESIGNET_INTEGRATION_AUTHENTICATOR: RelayAuthenticationService - MOSIP_ESIGNET_INTEGRATION_KEY_BINDER: MockKeyBindingWrapperService - MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS: "{'profile' : {'given_name','family_name','gender','birthdate','individual_id'}}" - REGISTRY_RELAY_BASE_URL: http://nia-relay:8080 - REGISTRY_RELAY_RESOURCE: population-person - REGISTRY_RELAY_LOOKUP: esignet-userinfo - REGISTRY_RELAY_ACCESS_PROFILE: esignet - REGISTRY_RELAY_DEFAULT_CLAIMS: individualId,givenName,familyName,birthdate,gender - SPRING_APPLICATION_JSON: '{"registry":{"esignet":{"claim-map":{"sub":"$$psut","individual_id":"individualId","given_name":"givenName","family_name":"familyName","birthdate":"birthdate","gender":"gender"}}}}' - REGISTRY_MINT_TOKEN_ENDPOINT: https://mint.solmara.registrystack.org/token - REGISTRY_MINT_CLIENT_ID: nia-esignet - REGISTRY_MINT_PRIVATE_JWK: ${NIA_ESIGNET_CLIENT_PRIVATE_JWK:?run just gen-secrets} - REGISTRY_MINT_TOKEN_CACHE_MAX_SECONDS: "300" - REGISTRY_TLS_CA_CERT: /etc/solmara-evidence/tls/ca.crt - REGISTRY_ESIGNET_AUTH_OTP_STATIC_ENABLED: "true" - REGISTRY_ESIGNET_AUTH_OTP_STATIC_VALUE: ${ESIGNET_DEMO_OTP:-111111} - REGISTRY_ESIGNET_AUTH_OTP_CHANNELS: EMAIL,PHONE - REGISTRY_ESIGNET_ACCOUNT_CHECK_CLAIMS: individualId - MOSIP_ESIGNET_AUTHENTICATOR_IDA_OTP_CHANNELS: EMAIL,PHONE - REGISTRY_ESIGNET_KYC_TOKEN_HMAC_SECRET: ${REGISTRY_ESIGNET_KYC_TOKEN_SECRET:?run just gen-secrets} - REGISTRY_ESIGNET_PSUT_HMAC_SECRET: ${REGISTRY_ESIGNET_PSUT_SECRET:?run just gen-secrets} - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PATH: /home/mosip/kyc-signing/kyc-signing.p12 - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_TYPE: PKCS12 - REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?run just gen-secrets} - REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS: esignet-relay-kyc - REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD: ${REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD:?run just gen-secrets} - MOSIP_KERNEL_KEYMANAGER_HSM_CONFIG_PATH: /home/mosip/keystore/esignet_local.p12 - volumes: ["esignet-keystore:/home/mosip/keystore", "esignet-kyc-signing:/home/mosip/kyc-signing", "./config/evidence/local/tls/ca.crt:/etc/solmara-evidence/tls/ca.crt:ro"] - extra_hosts: ["mint.solmara.registrystack.org:172.29.1.10"] - networks: [runtime] - depends_on: {esignet-database: {condition: service_healthy}, esignet-redis: {condition: service_healthy}, nia-relay: {condition: service_healthy}, mint: {condition: service_started}} - - esignet-ui: - build: {context: ., dockerfile: docker/esignet-ui/Dockerfile, args: {ESIGNET_UI_IMAGE: "${ESIGNET_UI_IMAGE:?versions.env must pin eSignet UI}", ESIGNET_NGINX_CONF: config/esignet/nginx.conf}} - image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-ui:local} - ports: ["${SOLMARA_ESIGNET_UI_PORT:-4309}:3000"] - networks: [runtime] - depends_on: {esignet: {condition: service_started}} - - esignet-edge: - build: {context: ., dockerfile: docker/esignet-ui/Dockerfile, args: {ESIGNET_UI_IMAGE: "${ESIGNET_UI_IMAGE:?versions.env must pin eSignet UI}", ESIGNET_NGINX_CONF: config/esignet/nginx.conf}} - image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-ui:local} - ports: ["${SOLMARA_ESIGNET_PORT:-4308}:3000"] - networks: [runtime] - depends_on: {esignet: {condition: service_started}} - - esignet-seed: - build: {context: ., dockerfile: docker/esignet-seed/Dockerfile, args: {POSTGRES_IMAGE: "${ESIGNET_POSTGRES_IMAGE:?versions.env must pin eSignet database image}"}} - image: ${SOLMARA_ESIGNET_SEED_IMAGE:-solmara-lab-esignet-seed:local} - environment: - PGHOST: esignet-database - PGUSER: esignet - PGPASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} - ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} - ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?run just gen-secrets} - ESIGNET_REDIS_HOST: esignet-redis - entrypoint: ["seed-esignet.py"] - networks: [runtime] - depends_on: {esignet-database: {condition: service_healthy}, esignet: {condition: service_started}} - - portal: - environment: - PORTAL_AUTH_PROVIDER: esignet - PORTAL_ESIGNET_CLIENT_ID: ${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal} - PORTAL_ESIGNET_CLIENT_KEY_ID: ${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1} - PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64: ${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?run just gen-secrets} - PORTAL_ESIGNET_ISSUER: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308} - PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT: ${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-http://127.0.0.1:4309}/authorize - PORTAL_ESIGNET_TOKEN_ENDPOINT: http://esignet:8088/v1/esignet/oauth/v2/token - PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308}/v1/esignet/oauth/v2/token - PORTAL_ESIGNET_USERINFO_ENDPOINT: http://esignet:8088/v1/esignet/oidc/userinfo - PORTAL_ESIGNET_REDIRECT_URI: http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}/auth/callback - PORTAL_ESIGNET_SUBJECT_CLAIM: sub - -volumes: - esignet-db-data: - esignet-keystore: - esignet-kyc-signing: - esignet-redis-data: diff --git a/compose.yaml b/compose.yaml index 808825d..fad314b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -621,7 +621,6 @@ services: HOST: 0.0.0.0 PORT: "4000" PORTAL_PROVIDER: live - PORTAL_AUTH_PROVIDER: ${PORTAL_AUTH_PROVIDER:-mock} PORTAL_SESSION_SECRET: ${PORTAL_SESSION_SECRET:?run just gen-secrets} CHILD_BENEFIT_FEDERATOR_URL: http://child-benefit-federator:8080 CHILD_BENEFIT_FEDERATOR_TOKEN: ${CHILD_BENEFIT_FEDERATOR_TOKEN:?run just gen-secrets} diff --git a/config/esignet/init.sql b/config/esignet/init.sql deleted file mode 100644 index eb91703..0000000 --- a/config/esignet/init.sql +++ /dev/null @@ -1,276 +0,0 @@ -CREATE DATABASE mosip_esignet; -CREATE DATABASE mosip_mockidentitysystem; - -\connect mosip_esignet - -CREATE SCHEMA IF NOT EXISTS esignet; - -CREATE TABLE IF NOT EXISTS esignet.ca_cert_store ( - cert_id varchar(36) PRIMARY KEY, - cert_subject varchar(500) NOT NULL, - cert_issuer varchar(500) NOT NULL, - issuer_id varchar(36) NOT NULL, - cert_not_before timestamp, - cert_not_after timestamp, - crl_uri varchar(120), - cert_data varchar(4000), - cert_thumbprint varchar(100), - cert_serial_no varchar(50), - partner_domain varchar(36), - cr_by varchar(256), - cr_dtimes timestamp, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp, - ca_cert_type varchar(25), - UNIQUE (cert_thumbprint, partner_domain) -); - -CREATE TABLE IF NOT EXISTS esignet.client_detail ( - id varchar(100) PRIMARY KEY, - name varchar(600) NOT NULL, - rp_id varchar(100) NOT NULL, - logo_uri varchar(2048) NOT NULL, - redirect_uris varchar(2048) NOT NULL, - claims varchar(2048) NOT NULL, - acr_values varchar(1024) NOT NULL, - public_key varchar(1024) NOT NULL, - public_key_hash varchar(128) NOT NULL UNIQUE, - enc_public_key varchar(1024), - enc_public_key_hash varchar(128), - enc_public_key_cert varchar(4000), - grant_types varchar(512) NOT NULL, - auth_methods varchar(512) NOT NULL, - status varchar(20) NOT NULL, - additional_config varchar(2048), - cr_dtimes timestamp NOT NULL, - upd_dtimes timestamp -); - -CREATE TABLE IF NOT EXISTS esignet.consent_detail ( - id varchar(36) PRIMARY KEY, - client_id varchar(256) NOT NULL, - psu_token varchar(256) NOT NULL, - claims varchar(2048) NOT NULL, - authorization_scopes varchar(1024) NOT NULL, - cr_dtimes timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, - expire_dtimes timestamp, - signature varchar(1024), - hash varchar(100), - accepted_claims varchar(1024), - permitted_scopes varchar(1024) -); - -CREATE TABLE IF NOT EXISTS esignet.consent_history ( - id varchar(36) PRIMARY KEY, - client_id varchar(256) NOT NULL, - psu_token varchar(256) NOT NULL, - claims varchar(2048) NOT NULL, - authorization_scopes varchar(1024) NOT NULL, - cr_dtimes timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, - expire_dtimes timestamp, - signature varchar(1024), - hash varchar(1024), - accepted_claims varchar(1024), - permitted_scopes varchar(1024) -); - -CREATE TABLE IF NOT EXISTS esignet.key_alias ( - id varchar(36) PRIMARY KEY, - app_id varchar(36) NOT NULL, - ref_id varchar(128), - key_gen_dtimes timestamp, - key_expire_dtimes timestamp, - status_code varchar(36), - lang_code varchar(3), - cr_by varchar(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp, - cert_thumbprint varchar(100), - uni_ident varchar(50) -); - -CREATE TABLE IF NOT EXISTS esignet.key_policy_def ( - app_id varchar(36) PRIMARY KEY, - key_validity_duration smallint, - is_active boolean NOT NULL, - pre_expire_days smallint, - access_allowed varchar(1024), - cr_by varchar(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp -); - -CREATE TABLE IF NOT EXISTS esignet.key_store ( - id varchar(36) PRIMARY KEY, - master_key varchar(36) NOT NULL, - private_key varchar(2500) NOT NULL, - certificate_data varchar(4000) NOT NULL, - cr_by varchar(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp -); - -CREATE TABLE IF NOT EXISTS esignet.public_key_registry ( - id_hash varchar(100) PRIMARY KEY, - auth_factor varchar(25) NOT NULL, - psu_token varchar(256) NOT NULL, - public_key varchar(2500) NOT NULL, - expire_dtimes timestamp NOT NULL, - wallet_binding_id varchar(256) NOT NULL, - public_key_hash varchar(100) NOT NULL, - certificate varchar(4000) NOT NULL, - cr_dtimes timestamp NOT NULL, - thumbprint varchar(128) NOT NULL -); - -CREATE TABLE IF NOT EXISTS esignet.server_profile ( - profile_name varchar(100) NOT NULL, - feature varchar(100) NOT NULL, - additional_config_key varchar(200) NOT NULL, - PRIMARY KEY (profile_name, feature) -); - -INSERT INTO esignet.key_policy_def ( - app_id, key_validity_duration, is_active, pre_expire_days, access_allowed, - cr_by, cr_dtimes, is_deleted -) VALUES - ('BINDING_SERVICE', 1095, true, 50, 'NA', 'mosipadmin', now(), false), - ('MOCK_BINDING_SERVICE', 1095, true, 50, 'NA', 'mosipadmin', now(), false), - ('OIDC_PARTNER', 1095, true, 50, 'NA', 'mosipadmin', now(), false), - ('OIDC_SERVICE', 1095, true, 50, 'NA', 'mosipadmin', now(), false), - ('ROOT', 2920, true, 1125, 'NA', 'mosipadmin', now(), false) -ON CONFLICT (app_id) DO NOTHING; - -INSERT INTO esignet.server_profile ( - profile_name, feature, additional_config_key -) VALUES - ('fapi2.0', 'PAR', 'require_pushed_authorization_requests'), - ('fapi2.0', 'DPOP', 'dpop_bound_access_tokens'), - ('fapi2.0', 'PKCE', 'require_pkce') -ON CONFLICT (profile_name, feature) DO NOTHING; - -\connect mosip_mockidentitysystem - -CREATE SCHEMA IF NOT EXISTS mockidentitysystem; - -CREATE TABLE IF NOT EXISTS mockidentitysystem.ca_cert_store ( - cert_id varchar(36) PRIMARY KEY, - cert_subject varchar(500) NOT NULL, - cert_issuer varchar(500) NOT NULL, - issuer_id varchar(36) NOT NULL, - cert_not_before timestamp, - cert_not_after timestamp, - crl_uri varchar(120), - cert_data varchar, - cert_thumbprint varchar(100), - cert_serial_no varchar(50), - partner_domain varchar(36), - cr_by varchar(256), - cr_dtimes timestamp, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp, - ca_cert_type varchar(25), - UNIQUE (cert_thumbprint, partner_domain) -); - -CREATE TABLE IF NOT EXISTS mockidentitysystem.key_alias ( - id varchar(36) PRIMARY KEY, - app_id varchar(36) NOT NULL, - ref_id varchar(128), - key_gen_dtimes timestamp, - key_expire_dtimes timestamp, - status_code varchar(36), - lang_code varchar(3), - cr_by varchar(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp, - cert_thumbprint varchar(100), - uni_ident varchar(50) -); - -CREATE TABLE IF NOT EXISTS mockidentitysystem.key_policy_def ( - app_id varchar(36) PRIMARY KEY, - key_validity_duration smallint, - is_active boolean NOT NULL, - pre_expire_days smallint, - access_allowed varchar(1024), - cr_by varchar(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp -); - -CREATE TABLE IF NOT EXISTS mockidentitysystem.key_store ( - id varchar(36) PRIMARY KEY, - master_key varchar(36) NOT NULL, - private_key varchar(2500) NOT NULL, - certificate_data varchar NOT NULL, - cr_by varchar(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by varchar(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT false, - del_dtimes timestamp -); - -CREATE TABLE IF NOT EXISTS mockidentitysystem.kyc_auth ( - kyc_token varchar(255), - individual_id varchar(255), - partner_specific_user_token varchar(255), - response_time timestamp, - transaction_id varchar(255), - validity integer -); - -CREATE TABLE IF NOT EXISTS mockidentitysystem.mock_identity ( - individual_id varchar(36) PRIMARY KEY, - identity_json varchar NOT NULL -); - -CREATE TABLE IF NOT EXISTS mockidentitysystem.partner_data ( - partner_id varchar(100) NOT NULL, - client_id varchar(100) NOT NULL, - public_key text, - status varchar(50), - cr_dtimes timestamp NOT NULL, - PRIMARY KEY (partner_id, client_id) -); - -CREATE TABLE IF NOT EXISTS mockidentitysystem.verified_claim ( - id varchar(100) PRIMARY KEY, - individual_id varchar(36) NOT NULL, - claim varchar NOT NULL, - trust_framework varchar NOT NULL, - detail varchar, - cr_by varchar(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by varchar(256), - upd_dtimes timestamp, - is_active boolean DEFAULT true -); - -INSERT INTO mockidentitysystem.key_policy_def ( - app_id, key_validity_duration, is_active, pre_expire_days, access_allowed, - cr_by, cr_dtimes, is_deleted -) VALUES - ('MOCK_AUTHENTICATION_SERVICE', 1095, true, 50, 'NA', 'mosipadmin', now(), false), - ('ROOT', 2920, true, 1125, 'NA', 'mosipadmin', now(), false) -ON CONFLICT (app_id) DO NOTHING; diff --git a/config/esignet/nginx-hosted.conf b/config/esignet/nginx-hosted.conf deleted file mode 100644 index b0fd9c0..0000000 --- a/config/esignet/nginx-hosted.conf +++ /dev/null @@ -1,105 +0,0 @@ -worker_processes 1; -pid /tmp/nginx.pid; - -events { - worker_connections 1024; -} - -http { - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; - client_body_temp_path /tmp/nginx-client-body; - proxy_temp_path /tmp/nginx-proxy; - fastcgi_temp_path /tmp/nginx-fastcgi; - uwsgi_temp_path /tmp/nginx-uwsgi; - scgi_temp_path /tmp/nginx-scgi; - - server { - listen 3000; - server_name __ESIGNET_UI_PUBLIC_HOST__; - server_tokens off; - - root /usr/share/nginx/html; - index index.html index.htm; - include /etc/nginx/mime.types; - - gzip on; - gzip_min_length 1000; - gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; - - location /v1/esignet { - proxy_pass http://esignet:8088/v1/esignet; - proxy_redirect off; - proxy_set_header Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Forwarded-Proto https; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/openid-configuration { - proxy_pass http://esignet:8088/v1/esignet/oidc/.well-known/openid-configuration; - proxy_redirect off; - proxy_set_header Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Forwarded-Proto https; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/jwks.json { - proxy_pass http://esignet:8088/v1/esignet/oauth/.well-known/jwks.json; - proxy_redirect off; - proxy_set_header Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Forwarded-Proto https; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/oauth-authorization-server { - proxy_pass http://esignet:8088/v1/esignet/oauth/.well-known/oauth-authorization-server; - proxy_redirect off; - proxy_set_header Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Forwarded-Proto https; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/openid-credential-issuer { - proxy_pass http://esignet:8088/v1/esignet/vci/.well-known/openid-credential-issuer; - proxy_redirect off; - proxy_set_header Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host __ESIGNET_PUBLIC_HOST__; - proxy_set_header X-Forwarded-Proto https; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location / { - try_files $uri $uri/ /index.html; - add_header Content-Security-Policy " - default-src 'self'; - style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; - font-src 'self' https://fonts.gstatic.com; - img-src 'self' data: https://cdn.jsdelivr.net https://*.mosip.net; - script-src 'self' https://www.google.com https://www.gstatic.com; - frame-src https://www.google.com; - connect-src 'self' https://__ESIGNET_PUBLIC_HOST__ https://__ESIGNET_UI_PUBLIC_HOST__; - " always; - add_header Referrer-Policy "no-referrer" always; - } - } -} diff --git a/config/esignet/nginx.conf b/config/esignet/nginx.conf deleted file mode 100644 index e27cc60..0000000 --- a/config/esignet/nginx.conf +++ /dev/null @@ -1,100 +0,0 @@ -worker_processes 1; -pid /tmp/nginx.pid; - -events { - worker_connections 1024; -} - -http { - access_log /dev/stdout; - error_log /dev/stderr; - client_body_temp_path /tmp/nginx-client-body; - proxy_temp_path /tmp/nginx-proxy; - fastcgi_temp_path /tmp/nginx-fastcgi; - uwsgi_temp_path /tmp/nginx-uwsgi; - scgi_temp_path /tmp/nginx-scgi; - - server { - listen 3000; - server_name localhost; - server_tokens off; - - root /usr/share/nginx/html; - index index.html index.htm; - include /etc/nginx/mime.types; - - gzip on; - gzip_min_length 1000; - gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; - - location /v1/esignet { - proxy_pass http://esignet:8088/v1/esignet; - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/openid-configuration { - proxy_pass http://esignet:8088/v1/esignet/oidc/.well-known/openid-configuration; - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/jwks.json { - proxy_pass http://esignet:8088/v1/esignet/oauth/.well-known/jwks.json; - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/oauth-authorization-server { - proxy_pass http://esignet:8088/v1/esignet/oauth/.well-known/oauth-authorization-server; - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location /.well-known/openid-credential-issuer { - proxy_pass http://esignet:8088/v1/esignet/vci/.well-known/openid-credential-issuer; - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - add_header Content-Security-Policy "default-src 'none'" always; - add_header Referrer-Policy "no-referrer" always; - } - - location / { - try_files $uri $uri/ /index.html; - add_header Content-Security-Policy " - default-src 'self'; - style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; - font-src 'self' https://fonts.gstatic.com; - img-src 'self' data: https://cdn.jsdelivr.net https://*.mosip.net; - script-src 'self' https://www.google.com https://www.gstatic.com; - frame-src https://www.google.com; - connect-src 'self' http://127.0.0.1:* http://localhost:*; - " always; - add_header Referrer-Policy "no-referrer" always; - } - } -} diff --git a/docker/esignet-postgres/Dockerfile b/docker/esignet-postgres/Dockerfile deleted file mode 100644 index f1fafb8..0000000 --- a/docker/esignet-postgres/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -ARG POSTGRES_IMAGE=postgres@sha256:9479eac93922431e8a512b016d7362ae264f4ba139f840891b51f02931e450db -FROM ${POSTGRES_IMAGE} - -COPY config/esignet/init.sql /docker-entrypoint-initdb.d/init.sql diff --git a/docker/esignet-relay/Dockerfile b/docker/esignet-relay/Dockerfile deleted file mode 100644 index f383312..0000000 --- a/docker/esignet-relay/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 - -ARG ESIGNET_BASE_IMAGE=mosipid/esignet-with-plugins@sha256:47fffdb5a45198b29885a533841129877a7385a12bcb6020c0f6d4335477be39 -ARG ESIGNET_AUTHENTICATOR_JAR_URL -ARG ESIGNET_AUTHENTICATOR_JAR_SHA256 - -FROM alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS authenticator -ARG ESIGNET_AUTHENTICATOR_JAR_URL -ARG ESIGNET_AUTHENTICATOR_JAR_SHA256 -RUN test -n "$ESIGNET_AUTHENTICATOR_JAR_URL" && \ - test -n "$ESIGNET_AUTHENTICATOR_JAR_SHA256" && \ - wget -q -O /esignet-relay-authenticator.jar "$ESIGNET_AUTHENTICATOR_JAR_URL" && \ - printf '%s %s\n' "$ESIGNET_AUTHENTICATOR_JAR_SHA256" /esignet-relay-authenticator.jar | sha256sum -c - - -FROM ${ESIGNET_BASE_IMAGE} - -COPY --from=authenticator /esignet-relay-authenticator.jar /home/mosip/plugins/esignet-relay-authenticator.jar -COPY scripts/start-esignet-relay.sh /usr/local/bin/start-esignet-relay.sh - -USER root -RUN chmod 0755 /usr/local/bin/start-esignet-relay.sh && \ - chown root:root /usr/local/bin/start-esignet-relay.sh && \ - chown mosip:mosip /home/mosip/plugins/esignet-relay-authenticator.jar - -ENTRYPOINT ["/usr/local/bin/start-esignet-relay.sh"] -CMD ["java", "-jar", "-Dloader.path=${loader_path_env}", "-Dspring.cloud.config.label=${spring_config_label_env}", "-Dspring.profiles.active=${active_profile_env}", "-Dspring.cloud.config.uri=${spring_config_url_env}", "esignet-service.jar"] diff --git a/docker/esignet-seed/Dockerfile b/docker/esignet-seed/Dockerfile deleted file mode 100644 index c079781..0000000 --- a/docker/esignet-seed/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 - -FROM python:3.12.3-slim-bookworm@sha256:afc139a0a640942491ec481ad8dda10f2c5b753f5c969393b12480155fe15a63 AS python-runtime - -FROM postgres:bookworm@sha256:9479eac93922431e8a512b016d7362ae264f4ba139f840891b51f02931e450db - -COPY --from=python-runtime /usr/local/ /usr/local/ -COPY scripts/seed-esignet.py /usr/local/bin/seed-esignet.py - -ENV PATH="/usr/local/bin:${PATH}" - -RUN chmod 0755 /usr/local/bin/seed-esignet.py diff --git a/docker/esignet-ui/Dockerfile b/docker/esignet-ui/Dockerfile deleted file mode 100644 index 6d6b955..0000000 --- a/docker/esignet-ui/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -ARG ESIGNET_UI_IMAGE=mosipid/oidc-ui@sha256:8a2a6839b4e22be6c967dabc6308190c165c54604a778c2d9b1aae8091db93e7 -FROM ${ESIGNET_UI_IMAGE} - -ARG ESIGNET_NGINX_CONF=config/esignet/nginx-hosted.conf -COPY ${ESIGNET_NGINX_CONF} /home/mosip/nginx-hosted.conf.template -COPY docker/esignet-ui/render-hosted-nginx.sh /home/mosip/render-hosted-nginx.sh -COPY docker/esignet-ui/hosted-entrypoint.sh /home/mosip/hosted-entrypoint.sh - -USER root -RUN mv /home/mosip/configure_start.sh /home/mosip/configure-ui.sh && \ - chmod 0555 /home/mosip/configure-ui.sh \ - /home/mosip/render-hosted-nginx.sh \ - /home/mosip/hosted-entrypoint.sh && \ - chmod 0444 /home/mosip/nginx-hosted.conf.template -USER 1001:1001 - -ENTRYPOINT ["/home/mosip/hosted-entrypoint.sh"] -CMD ["nginx", "-c", "/tmp/solmara-nginx.conf", "-g", "daemon off;"] diff --git a/docker/esignet-ui/hosted-entrypoint.sh b/docker/esignet-ui/hosted-entrypoint.sh deleted file mode 100755 index 4d6b2dd..0000000 --- a/docker/esignet-ui/hosted-entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -eu - -/home/mosip/render-hosted-nginx.sh \ - /home/mosip/nginx-hosted.conf.template \ - /tmp/solmara-nginx.conf - -exec sh /home/mosip/configure-ui.sh "$@" diff --git a/docker/esignet-ui/render-hosted-nginx.sh b/docker/esignet-ui/render-hosted-nginx.sh deleted file mode 100755 index cfed5d4..0000000 --- a/docker/esignet-ui/render-hosted-nginx.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -ne 2 ]; then - exit 64 -fi - -template=$1 -output=$2 - -validate_host() { - candidate=$1 - [ -n "$candidate" ] || return 1 - [ "${#candidate}" -le 253 ] || return 1 - printf '%s\n' "$candidate" | awk -F. ' - NF < 2 { exit 1 } - { - for (i = 1; i <= NF; i++) { - if (length($i) < 1 || length($i) > 63 || - $i !~ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/) { - exit 1 - } - } - } - ' -} - -# Only the hosted template carries host placeholders. The local template binds -# loopback ports instead, so it renders unchanged and needs no public hosts. -if ! grep -q '__ESIGNET_' "$template"; then - cat "$template" >"$output" - exit 0 -fi - -if ! validate_host "${SOLMARA_ESIGNET_PUBLIC_HOST:-}" || - ! validate_host "${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-}"; then - echo "eSignet hosted nginx host configuration is invalid" >&2 - exit 78 -fi - -# The validated values contain only lower-case DNS hostname characters. This -# makes these two literal substitutions safe in nginx directive and CSP slots. -sed \ - -e "s/__ESIGNET_PUBLIC_HOST__/${SOLMARA_ESIGNET_PUBLIC_HOST}/g" \ - -e "s/__ESIGNET_UI_PUBLIC_HOST__/${SOLMARA_ESIGNET_UI_PUBLIC_HOST}/g" \ - "$template" >"$output" diff --git a/docs/changelog.md b/docs/changelog.md index a085554..e8098cd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,25 @@ A small dated log of what changed in the visitor center and the lab topology. Newest entry first. +## 2026-11-07 The eSignet sign-in path is retired + +The optional eSignet profile is removed. It ran the Java eSignet service with +the `esignet-relay-authenticator` v0.2.0 plugin against the NIA Relay, which no +longer matches how eSignet is built and deployed elsewhere. The portal keeps +its mock login, which binds a published persona server-side; the persona +handoff from the Visitor Center is unchanged. + +Removed: `compose.esignet.yaml`, `compose.coolify.esignet.yaml`, the four +eSignet images and their release-candidate builds, the portal OIDC code and +`PORTAL_AUTH_PROVIDER` and `PORTAL_ESIGNET_*` settings, the `nia-esignet` Mint +client with its generated key and hosted provisioning input, the eSignet +authenticator pins in `versions.env`, the `up-esignet`, `down-esignet`, and +`smoke-esignet` recipes, and the Visitor Center "Optional eSignet" status +entry. The NIA Relay project and its `esignet-userinfo` lookup are kept because +the Relay is part of the five-Relay topology, but no lab client is registered +for that lookup and the Bruno request that used it is gone. Existing history +below that mentions eSignet describes the lab as it was then. + ## 2026-08-21 The local front door is `local-edge` The local Compose service that terminates TLS on port 4341 was called diff --git a/docs/esignet.md b/docs/esignet.md deleted file mode 100644 index e8258cd..0000000 --- a/docs/esignet.md +++ /dev/null @@ -1,24 +0,0 @@ -# Optional eSignet profile - -eSignet is an optional authentication profile, not an Evidence authority. The -v0.2.0 authenticator validates the challenge and consent first, then obtains a -short-lived Mint token using the `nia-esignet` private-key JWT client and calls -`POST /v2/resources/population-person/lookups/esignet-userinfo`. - -The request contains only the selected UIN and consented `fields`. The adapter -parses only `data.domainData`, preserves PSUT, KYC-token and JWS behaviour, and -collapses unresolved, concealed, denied, authentication, and dependency -failures to generic subject-facing results. It never logs selectors, tokens, or -source values. - -The deployment verifies the v0.2.0 JAR against its published SHA-256 before -building the eSignet image. The NIA Relay, Mint client, and authenticator private -JWK remain independent operator-owned runtime material. - -For hosted deployment, `compose.coolify.esignet.yaml` is applied as an overlay -on `compose.coolify.yaml`. It switches the existing Portal to the exact hosted -issuer, authorization, token, UserInfo, private-key-JWT client, and callback -configuration. The Portal client private key is provided only to Portal and the -one-time eSignet client seeder. It is separate from both the `nia-esignet` Mint -client key held by the authenticator and the `solmara-demo` Evidence client key -used by the programme application. diff --git a/docs/hosted-deployment.md b/docs/hosted-deployment.md index ace3fd9..35ca5e8 100644 --- a/docs/hosted-deployment.md +++ b/docs/hosted-deployment.md @@ -41,8 +41,7 @@ Do not reuse an unrelated package or deploy a mutable tag. ## Authority topology The hosted topology contains five Relay V2 services, six independently signed -Evidence gateways, one shared lab Mint, and the programme application. The optional -eSignet profile uses the NIA Relay lookup. +Evidence gateways, one shared lab Mint, and the programme application. Evidence hosts are: @@ -143,7 +142,7 @@ path; private issuer signing keys remain confined to signer tmpfs. ## Mint clients -The shared Mint registers nine clients under the common lab audience. Eight are +The shared Mint registers eight clients under the common lab audience. Seven are least-authority source clients, each limited to its named operation: - `cra-pension-evidence` @@ -153,9 +152,8 @@ least-authority source clients, each limited to its named operation: - `sipf-survivor-evidence` - `nagdi-voucher-evidence` - `nagdi-livestock-evidence` -- `nia-esignet` -The ninth client, `solmara-demo`, belongs to the programme application and is +The eighth client, `solmara-demo`, belongs to the programme application and is used to request assertions from the authority Evidence gateways. The shared Mint is a lab convenience, not production tenancy guidance. @@ -166,14 +164,12 @@ Compose file, repository file, build log, or delivery record: - one private and public signing JWK pair for Mint and for each of the six Evidence issuers; -- one private and public client JWK pair for each of the nine Mint clients; +- one private and public client JWK pair for each of the eight Mint clients; - authority-specific Relay audit HMAC keys, plus cursor HMAC keys for the Relay contracts that require cursors; - Mint and Evidence audit HMAC keys; - one subject-binding HMAC key for each Evidence gateway; -- the programme federator token; -- when eSignet is enabled, its database credential, KYC-token and PSUT HMAC - secrets, KYC keystore credentials, and portal OIDC client key. +- the programme federator token. Each signing private JWK is projected only to its dedicated signer. That signer also receives the matching public half and refuses startup unless it is the @@ -252,9 +248,7 @@ Deploy the reset alongside the existing deployment in this order: Evidence gateways are ready on the private routes. 6. Run the hosted programme, denial, JWKS, source-label, redaction, and UI smoke against the new routes before changing public routing. -7. If required for this deployment, add `compose.coolify.esignet.yaml` and run - the citizen-login smoke through the NIA Relay V2 lookup. -8. Switch programme authority URLs and public metadata routing only after every +7. Switch programme authority URLs and public metadata routing only after every required smoke passes. Disable the superseded services, but retain their volumes and exact deployment references for recovery. @@ -276,9 +270,7 @@ requires: - the portal to prove authority URL selection, per-authority JWKS verification, source-type labels, and redaction; - the Visitor Center to show the publisher, six Evidence gateways, five Relays, - shared Mint, and programme application; -- when the optional profile is deployed, eSignet login to complete through the - NIA Relay V2 lookup. + shared Mint, and programme application. Attach only sanitized pass or fail results and public artifact identities to the delivery record. Do not attach selectors, source rows, tokens, private diff --git a/docs/solmara.md b/docs/solmara.md index 127b860..cb79d0e 100644 --- a/docs/solmara.md +++ b/docs/solmara.md @@ -10,7 +10,7 @@ several authorities and owns the programme decision. | Authority | Evidence source | Relay operation | |---|---|---| | CRA | immutable birth extract | civil death and citizen-link exact lookups | -| NIA | immutable population extract | eSignet UserInfo exact lookup | +| NIA | immutable population extract | population-person exact lookup (no lab client calls it) | | SRO | immutable poverty extract | none | | MoSD Programme MIS | live read-only SQLite | beneficiary enrolment exact lookup | | SIPF | live read-only SQLite | pension payment and survivor exact lookups | @@ -69,8 +69,8 @@ consultation. is separately requested for the spouse. - Agriculture requests NAgDI voucher or livestock assertions under isolated purposes. -- Optional citizen login uses the NIA Relay V2 lookup through eSignet, while the - application can separately compose CRA and NIA Evidence assertions. +- Citizen login in the portal is a mock login that binds a published persona; + the application can separately compose CRA and NIA Evidence assertions. The preserved `/v1` suffixes in requirement and evidence-type identifiers are domain identifier versions, not transport routes. diff --git a/evidence/scripts/build-cells.py b/evidence/scripts/build-cells.py index 3a65882..bc03d1d 100644 --- a/evidence/scripts/build-cells.py +++ b/evidence/scripts/build-cells.py @@ -26,7 +26,6 @@ "sipf-survivor-evidence": ("solmara:relay:sipf:by-spouse-uin", "https://id.registrystack.org/solmara/purpose/survivor-benefit-determination"), "nagdi-voucher-evidence": ("solmara:relay:nagdi:voucher-by-farmer-id", "https://id.registrystack.org/solmara/purpose/voucher-eligibility-review"), "nagdi-livestock-evidence": ("solmara:relay:nagdi:movement-by-farmer-id", "https://id.registrystack.org/solmara/purpose/livestock-movement-control"), - "nia-esignet": ("solmara:relay:nia:esignet-userinfo", "https://id.registrystack.org/solmara/purpose/esignet-identity-verification"), } CLIENT_CELLS = { "cra-pension-evidence": "cra", @@ -125,11 +124,7 @@ def build(private_root: Path, output: Path, evidence_binary: Path | None) -> Non "clientId": client, "principal": f"https://id.registrystack.org/solmara/client/{client}", "authorization": {"scopes": [scope], "claims": {"purpose": purpose}}, - "keys": [public_jwk( - private_root / CLIENT_CELLS[client] / "secrets" / f"{client}-client-key" - if client in CLIENT_CELLS - else private_root / "mint" / "clients" / "nia-esignet-rsa-client-key" - )], + "keys": [public_jwk(private_root / CLIENT_CELLS[client] / "secrets" / f"{client}-client-key")], } path = mint_destination / "clients" / f"{client}.yaml" path.parent.mkdir(parents=True, exist_ok=True) diff --git a/evidence/tests/test_cells.py b/evidence/tests/test_cells.py index 879da41..825f32c 100644 --- a/evidence/tests/test_cells.py +++ b/evidence/tests/test_cells.py @@ -9,7 +9,7 @@ from pathlib import Path import yaml -from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.hazmat.primitives.asymmetric import ec ROOT = Path(__file__).resolve().parents[2] @@ -57,22 +57,6 @@ def encode(value: int) -> str: return public -def rsa_private_jwk() -> str: - key = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_numbers() - - def encode(value: int) -> str: - return base64.urlsafe_b64encode(value.to_bytes((value.bit_length() + 7) // 8, "big")).rstrip(b"=").decode() - - value = { - "kty": "RSA", "alg": "RS256", "n": encode(key.public_numbers.n), "e": encode(key.public_numbers.e), - "d": encode(key.d), "p": encode(key.p), "q": encode(key.q), "dp": encode(key.dmp1), - "dq": encode(key.dmq1), "qi": encode(key.iqmp), - } - thumbprint = {member: value[member] for member in ("e", "kty", "n")} - value["kid"] = base64.urlsafe_b64encode(hashlib.sha256(json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode()).digest()).rstrip(b"=").decode() - return json.dumps(value) - - class CellContractsTest(unittest.TestCase): def configs(self): return {cell: load_yaml(ROOT / "evidence" / "cells" / cell / "bundle" / "evidence.yaml") for cell in CELLS} @@ -198,10 +182,11 @@ def test_builder_injects_public_halves_and_closed_mint_registrations(self): mint_signing.parent.mkdir(parents=True) mint_signing.write_text(json.dumps(private_jwk()), encoding="utf-8") for client in module.RELAY_CLIENTS: - path = (private_root / module.CLIENT_CELLS[client] / "secrets" / f"{client}-client-key" if client in module.CLIENT_CELLS else private_root / "mint" / "clients" / "nia-esignet-rsa-client-key") + path = private_root / module.CLIENT_CELLS[client] / "secrets" / f"{client}-client-key" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(rsa_private_jwk() if client == "nia-esignet" else json.dumps(private_jwk()), encoding="utf-8") + path.write_text(json.dumps(private_jwk()), encoding="utf-8") application_key = private_root / "mint" / "clients" / "solmara-demo-client-key" + application_key.parent.mkdir(parents=True, exist_ok=True) application_key.write_text(json.dumps(private_jwk()), encoding="utf-8") output = temp / "output" module.build(private_root, output, None) @@ -221,16 +206,9 @@ def test_builder_injects_public_halves_and_closed_mint_registrations(self): scope, purpose = module.RELAY_CLIENTS[client] self.assertEqual(registration["authorization"], {"scopes": [scope], "claims": {"purpose": purpose}}) - nia_registration = registrations["nia-esignet"] - self.assertEqual(nia_registration["keys"][0]["kty"], "RSA") - self.assertEqual(nia_registration["keys"][0]["alg"], "RS256") - self.assertNotIn("d", nia_registration["keys"][0]) - nia_relay = load_yaml(ROOT / "relays" / "nia" / "registry.yaml") - nia_profile = nia_relay["resources"][0]["operations"]["lookups"][0]["accessProfiles"]["esignet"] - self.assertEqual( - nia_registration["authorization"]["claims"]["purpose"], - nia_profile["access"]["purpose"]["allowed"][0], - ) + self.assertNotIn("nia-esignet", registrations) + for registration in registrations.values(): + self.assertEqual(registration["keys"][0]["kty"], "EC") if __name__ == "__main__": diff --git a/home/e2e/home.test.ts b/home/e2e/home.test.ts index 9278868..b831e36 100644 --- a/home/e2e/home.test.ts +++ b/home/e2e/home.test.ts @@ -130,7 +130,7 @@ test('country, developer, and status inventories have dedicated routes', async ( await expect(page.locator('#engineer-door')).toContainText('pension-payment-review'); await page.goto('/status'); - await expect(page.locator('#status .status')).toHaveCount(18); + await expect(page.locator('#status .status')).toHaveCount(17); }); test('no page publishes runtime tokens', async ({ request }) => { @@ -176,9 +176,9 @@ test('anatomy lists five Relay V2 projects and six authority Evidence gateways w test('status grid shows the whole topology', async ({ page }) => { await page.goto('/status'); - await expect(page.locator('#status .status')).toHaveCount(18); + await expect(page.locator('#status .status')).toHaveCount(17); await expect(page.locator('#status .status.up')).toHaveCount(17); - await expect(page.locator('#status .status', { hasText: 'Optional eSignet' })).toContainText('down'); + await expect(page.locator('#status .status', { hasText: 'eSignet' })).toHaveCount(0); }); test('engineer door always shows the copy-as-curl examples', async ({ page }) => { diff --git a/home/src/lib/server/services.test.ts b/home/src/lib/server/services.test.ts index 9ba4b54..2f2a93d 100644 --- a/home/src/lib/server/services.test.ts +++ b/home/src/lib/server/services.test.ts @@ -30,10 +30,10 @@ describe('authority-cell topology', () => { it('renders the approved deterministic topology groups and owned configs', () => { const groups = topologyGroups('https://github.com/registrystack/solmara-lab'); - expect(groups.map((group) => group.key)).toEqual(['publisher', 'relays', 'evidence', 'programme', 'identity']); + expect(groups.map((group) => group.key)).toEqual(['publisher', 'relays', 'evidence', 'programme']); expect(groups.find((group) => group.key === 'relays')?.services).toHaveLength(5); expect(groups.find((group) => group.key === 'evidence')?.services.filter((service) => service.role === 'evidence')).toHaveLength(6); expect(groups.find((group) => group.key === 'evidence')?.services.find((service) => service.id === 'cra-evidence')?.config[0].path).toBe('evidence/cells/cra'); - expect(groups.find((group) => group.key === 'identity')?.services[0].blurb).toContain('NIA Relay'); + expect(groups.flatMap((group) => group.services).some((service) => service.id === 'esignet')).toBe(false); }); }); diff --git a/home/src/lib/server/services.ts b/home/src/lib/server/services.ts index 514e55b..6fa52b5 100644 --- a/home/src/lib/server/services.ts +++ b/home/src/lib/server/services.ts @@ -65,7 +65,7 @@ export const SERVICES: ServiceDef[] = [ id: 'nia-relay', label: 'NIA Relay', authority: 'National Identity Agency', - blurb: 'Relay V2 exact lookups over the population source, also available to the optional eSignet path.', + blurb: 'Relay V2 exact lookups over the population source.', probeEnv: 'NIA_POPULATION_RELAY_URL', probeDefault: 'http://127.0.0.1:4312', probePath: '/ready', @@ -189,13 +189,6 @@ export const SERVICES: ServiceDef[] = [ probePath: '/health', configPaths: [{ label: 'Scenario modules', path: 'scenarios' }] }), - shared({ - id: 'esignet', - label: 'Optional eSignet', - authority: 'National Identity Agency', - blurb: 'Optional resident sign-in. It resolves identity through the NIA Relay and is not an Evidence authority.', - configPaths: [{ label: 'Portal eSignet integration', path: 'portal/src/lib/server/esignet.ts' }] - }), shared({ id: 'portal', label: 'Citizen portal', @@ -279,12 +272,6 @@ export function topologyGroups(repoUrl: string): TopologyGroup[] { title: 'Programme and visitor applications', blurb: 'The child benefit app combines signed authority values into a programme decision. The scenario runner, portal, and Visitor Center present the result.', services: byId(['child-benefit-programme', 'scenario-runner', 'portal', 'home']) - }, - { - key: 'identity', - title: 'Optional resident identity', - blurb: 'eSignet is optional and uses the NIA Relay for identity resolution. It is not a national Evidence service.', - services: byId(['esignet']) } ]; } diff --git a/justfile b/justfile index 280417d..eca5a6d 100644 --- a/justfile +++ b/justfile @@ -54,15 +54,14 @@ lint: test: cd generator && uv run python -m unittest discover -s tests uv run python -m unittest discover -s scenario-runner -p 'test_*.py' - uv run python -m unittest relays/test_relay_projects.py evidence/tests/test_cells.py scripts/test_metadata_authority_contracts.py scripts/test_image_pins.py scripts/test_build_registry_stack_runtime.py scripts/test_hosted_image_manifest.py scripts/test_hosted_provisioning_topology.py scripts/test_hosted_network_isolation.py scripts/test_hosted_home_topology.py scripts/test_hosted_esignet_topology.py scripts/test_hosted_evidence_routes.py scripts/test_hosted_runtime_assets.py scripts/test_provision_hosted_runtime.py scripts/test_hosted_transit_signer.py scripts/test_smoke_hosted_provisioner_image.py scripts/test_runtime_topology.py scripts/test_registry_stack_release_pin.py scripts/test_hosted_authority_rollout.py scripts/test_local_relay_runtime_stager.py scripts/test_local_transit_proxy.py scripts/test_local_transit_signers.py scripts/test_local_transit_providers.py scripts/test_signer_public_keys.py scripts/test_project_runtime_secrets.py scripts/test_gen_secrets.py scripts/test_publish_runtime_extracts.py scripts/test_lifecycle_proof.py scripts/test_live_lifecycle_proof.py scripts/test_local_relay_source_publisher.py scripts/test_smoke_programme_acceptance.py scripts/test_smoke_esignet.py + uv run python -m unittest relays/test_relay_projects.py evidence/tests/test_cells.py scripts/test_metadata_authority_contracts.py scripts/test_image_pins.py scripts/test_build_registry_stack_runtime.py scripts/test_hosted_image_manifest.py scripts/test_hosted_provisioning_topology.py scripts/test_hosted_network_isolation.py scripts/test_hosted_home_topology.py scripts/test_hosted_evidence_routes.py scripts/test_hosted_runtime_assets.py scripts/test_provision_hosted_runtime.py scripts/test_hosted_transit_signer.py scripts/test_smoke_hosted_provisioner_image.py scripts/test_runtime_topology.py scripts/test_registry_stack_release_pin.py scripts/test_hosted_authority_rollout.py scripts/test_local_relay_runtime_stager.py scripts/test_local_transit_proxy.py scripts/test_local_transit_signers.py scripts/test_local_transit_providers.py scripts/test_signer_public_keys.py scripts/test_project_runtime_secrets.py scripts/test_gen_secrets.py scripts/test_publish_runtime_extracts.py scripts/test_lifecycle_proof.py scripts/test_live_lifecycle_proof.py scripts/test_local_relay_source_publisher.py scripts/test_smoke_programme_acceptance.py cd portal && pnpm test cd home && pnpm test -# Validate local, hosted, eSignet, and every Coolify topology without starting it. +# Validate local, hosted, and every Coolify topology without starting it. compose: @test -f .env || { echo ".env is missing; run just gen-secrets" >&2; exit 1; } COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml config >/dev/null - COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml config >/dev/null @scripts/check-hosted-compose.sh @scripts/check-coolify-compose.sh @@ -89,17 +88,10 @@ signers-down: up: prepare COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml up -d --build --force-recreate -up-esignet: prepare - COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml up -d --build --force-recreate - down: COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml down just signers-down -down-esignet: - COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.esignet.yaml down - just signers-down - # Destructive reset is intentionally local-only. Hosted rollout never deletes volumes. reset: COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml down -v @@ -110,9 +102,6 @@ rollout phase: smoke: scripts/smoke.sh -smoke-esignet: - @uv run scripts/smoke-esignet.py >/dev/null && node scripts/smoke-esignet-login.mjs || { echo "smoke-esignet: FAIL"; exit 1; } - programme-acceptance: @COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-{{ compose_project_name }}}" docker compose --env-file versions.env --env-file .env -f compose.yaml exec -T scenario-runner python - < scripts/smoke-programme-acceptance.py 2>/dev/null || { echo "programme-acceptance: FAIL scenario-runner-execution"; exit 1; } diff --git a/metadata/public/metadata/catalog.json b/metadata/public/metadata/catalog.json index a8d6085..3b8535c 100644 --- a/metadata/public/metadata/catalog.json +++ b/metadata/public/metadata/catalog.json @@ -181,7 +181,7 @@ "iri": "did:web:id.registrystack.org:solmara:authority:nia", "name": "National Identity Agency" }, - "description": "NIA-owned immutable population extract and governed eSignet Relay source.", + "description": "NIA-owned immutable population extract and governed Relay source.", "entities": [ { "description": "Population person entity metadata.", diff --git a/metadata/public/metadata/index.json b/metadata/public/metadata/index.json index 9a6d07d..818e785 100644 --- a/metadata/public/metadata/index.json +++ b/metadata/public/metadata/index.json @@ -9,7 +9,7 @@ { "media_type": "application/json", "path": "metadata/catalog.json", - "sha256": "sha256:c9c540a49ef5945b62a035b285fc992987227b5bf371f1a8d3908f9608ddc8ef" + "sha256": "sha256:02c346b3351edf9cad88c0ac2d6ed17eadb4a7ea2e4a0b7259be45a3fc9f24dd" }, { "media_type": "application/json", @@ -89,7 +89,7 @@ { "media_type": "application/yaml", "path": "metadata/metadata.yaml", - "sha256": "sha256:f3842a3527c81a513a23587591f21d767edd7147f01ddcc9f1332152ce7cb783" + "sha256": "sha256:4066a2c3f83c92c3c9886f2b1185c1ca10b9f4d91e7d79b16fe9be8aa781860d" }, { "media_type": "application/json", diff --git a/metadata/public/metadata/metadata.yaml b/metadata/public/metadata/metadata.yaml index aa0d0cb..1ffe6c4 100644 --- a/metadata/public/metadata/metadata.yaml +++ b/metadata/public/metadata/metadata.yaml @@ -283,7 +283,7 @@ datasets: fields: [{name: uin, type: string, required: true}] - id: nia-population title: {en: NIA population publication} - description: {en: NIA-owned immutable population extract and governed eSignet Relay source.} + description: {en: NIA-owned immutable population extract and governed Relay source.} access_rights: restricted entities: - name: population_person diff --git a/metadata/solmara-wave1.metadata.yaml b/metadata/solmara-wave1.metadata.yaml index aa0d0cb..1ffe6c4 100644 --- a/metadata/solmara-wave1.metadata.yaml +++ b/metadata/solmara-wave1.metadata.yaml @@ -283,7 +283,7 @@ datasets: fields: [{name: uin, type: string, required: true}] - id: nia-population title: {en: NIA population publication} - description: {en: NIA-owned immutable population extract and governed eSignet Relay source.} + description: {en: NIA-owned immutable population extract and governed Relay source.} access_rights: restricted entities: - name: population_person diff --git a/portal/e2e/persona.test.ts b/portal/e2e/persona.test.ts index 59be332..9c56f85 100644 --- a/portal/e2e/persona.test.ts +++ b/portal/e2e/persona.test.ts @@ -1,7 +1,5 @@ import { test, expect } from '@playwright/test'; -import { beginSignIn, completeSignIn } from './support/auth'; - -const MOCK_ONLY = 'the persona handoff is mock-only: eSignet takes the subject from UserInfo'; +import { beginSignIn } from './support/auth'; // The visitor center hands a persona into the portal as /?persona=. The mock // login must log in as that persona instead of always Elena Dela Cruz. @@ -11,9 +9,7 @@ test('a persona handoff logs in as that persona', async ({ page }) => { // The landing acknowledges the handed-off persona before sign-in. await expect(page.getByTestId('persona-hint')).toContainText('Mateo Santos'); - const mode = await beginSignIn(page); - test.skip(mode === 'esignet', MOCK_ONLY); - await completeSignIn(page, mode); + await beginSignIn(page); await expect(page).toHaveURL(/\/services$/); // The authenticated catalog greets the handed-off persona, not the default. @@ -27,22 +23,7 @@ test('an unknown persona hint falls back to the default session', async ({ page await page.goto('/?persona=9999999999'); await expect(page.getByTestId('persona-hint')).toHaveCount(0); - const mode = await beginSignIn(page); - test.skip(mode === 'esignet', MOCK_ONLY); - await completeSignIn(page, mode); + await beginSignIn(page); await expect(page).toHaveURL(/\/services$/); await expect(page.getByRole('heading', { name: /Welcome, Elena Dela Cruz/ })).toBeVisible(); }); - -// The same hint under eSignet must not reach the session: the subject comes from -// UserInfo, so a query parameter cannot sign anyone in as someone else. -test('eSignet ignores a persona handoff', async ({ page }) => { - await page.goto('/?persona=2300010248'); - - const mode = await beginSignIn(page); - test.skip(mode === 'mock', 'the mock handoff is covered by the tests above'); - await completeSignIn(page, mode); - await expect(page).toHaveURL(/\/services$/); - - await expect(page.getByRole('heading', { name: /Welcome, Mateo Santos/ })).toHaveCount(0); -}); diff --git a/portal/e2e/support/auth.ts b/portal/e2e/support/auth.ts index da2a208..8a4aa78 100644 --- a/portal/e2e/support/auth.ts +++ b/portal/e2e/support/auth.ts @@ -1,139 +1,22 @@ -// The eSignet sign-in the portal e2e suite drives is the same one the hosted -// smoke checks, so both import this module. It stays free of runtime imports -// from @playwright/test: the smoke drives a browser it launched itself, outside -// the test runner. It also keeps every eSignet detail out of the errors it -// throws, because the smoke's output is a fixed line on a delivery record. -import type { Page, Response } from '@playwright/test'; - -// The portal's auth provider is server-side configuration the browser cannot -// read, so the suite discovers it from where the sign-in lands: the mock -// provider binds a persona session and continues straight to the catalog, while -// eSignet redirects to its own login and asks the citizen for an OTP. Driving -// whichever answers lets one suite run against a mock build and against a -// hosted deployment. -export type AuthMode = 'mock' | 'esignet'; - -// The synthetic demo identity eSignet is seeded with. 2300018263 is Elena Dela -// Cruz, the same persona the mock session binds, so every assertion that -// follows a sign-in reads the same in both modes. -const DEMO_SUBJECT = process.env.ESIGNET_DEMO_SUBJECT || '2300018263'; -const DEMO_OTP = process.env.ESIGNET_DEMO_OTP || '111111'; +import type { Page } from '@playwright/test'; const CATALOG_PATH = '/services'; -const ESIGNET_LOGIN_PATH = '/login'; -// One attempt to reach a provider, a few attempts before giving up, and the -// budget for the redirects back from eSignet. The sum has to stay inside the -// per-test timeout the config gives a live run, or a reissued request is cut -// off before it can answer. -const ATTEMPT_TIMEOUT_MS = 15_000; -const PROVIDER_TIMEOUT_MS = 60_000; +// The portal signs in with its mock login: the sign-in button binds a persona +// session server-side and continues straight to the catalog. const LOGIN_TIMEOUT_MS = 30_000; -/** Sign in from the landing page and report which provider answered. */ -export async function signIn(page: Page): Promise { +/** Sign in from the landing page and wait for the catalog. */ +export async function signIn(page: Page): Promise { await page.goto('/'); - const mode = await beginSignIn(page); - await completeSignIn(page, mode); - return mode; + await beginSignIn(page); } /** - * Click the sign-in on the page already loaded and report which provider - * answered. Callers that only apply to one provider use this to decide before - * paying for a full login. + * Click the sign-in on the page already loaded and wait for the catalog. + * Callers that arrive with a persona hint load the landing page themselves. */ -export async function beginSignIn(page: Page): Promise { +export async function beginSignIn(page: Page): Promise { await page.getByTestId('signin').click(); - const deadline = Date.now() + PROVIDER_TIMEOUT_MS; - for (;;) { - try { - await page.waitForURL( - (url) => url.pathname === CATALOG_PATH || url.pathname === ESIGNET_LOGIN_PATH, - { timeout: ATTEMPT_TIMEOUT_MS } - ); - return new URL(page.url()).pathname === CATALOG_PATH ? 'mock' : 'esignet'; - } catch (err) { - // eSignet refuses an authorization request while it is still starting and - // the refusal is terminal for that request, so the whole request has to be - // reissued rather than waited out. Past the deadline the timeout stands. - if (Date.now() >= deadline) throw err; - await page.goto('/auth/login'); - } - } -} - -/** Finish an eSignet sign-in. A mock sign-in is already complete. */ -export async function completeSignIn(page: Page, mode: AuthMode): Promise { - if (mode === 'mock') return; - - await page.getByRole('button', { name: 'Verify with OTP' }).click(); - await page.getByRole('textbox', { name: 'UIN/VID' }).fill(DEMO_SUBJECT); - const sendOtp = page.waitForResponse((response) => response.url().includes('/authorization/send-otp')); - await page.getByRole('button', { name: 'Get OTP' }).click(); - await requireNoErrors(await sendOtp, 'send the demo OTP'); - - await enterOtp(page); - const authenticate = page.waitForResponse((response) => response.url().includes('/authenticate')); - await page.getByRole('button', { name: /verify|continue/i }).click(); - await requireNoErrors(await authenticate, 'authenticate the demo identity'); - - // Leaving the eSignet login means the OTP was accepted. What follows is either - // the claim screen or the portal itself. - await page.waitForURL((url) => url.pathname !== ESIGNET_LOGIN_PATH, { timeout: LOGIN_TIMEOUT_MS }); - await grantConsentIfAsked(page); await page.waitForURL((url) => url.pathname === CATALOG_PATH, { timeout: LOGIN_TIMEOUT_MS }); } - -// eSignet reports a refused request as a 200 carrying an error list, so the -// response has to be read rather than trusted. An identity eSignet does not hold -// still passes send-otp and is only denied at authenticate, where the NIA Relay -// lookup answers, so both steps are checked. Naming the step is the whole -// message: the codes eSignet returns stay out of the smoke's output. -async function requireNoErrors(response: Response, step: string): Promise { - const body = (await response.json()) as { errors?: unknown[] }; - if (body.errors?.length) { - throw new Error(`eSignet refused to ${step}`); - } -} - -// This eSignet build renders one input per OTP digit and advances the focus -// itself as each digit arrives, so the digits are typed into the field the -// component currently owns and paced to let that focus move. An older build -// renders a single input instead. -async function enterOtp(page: Page): Promise { - const digits = page.locator('input[type="tel"]'); - await digits.first().waitFor({ timeout: ATTEMPT_TIMEOUT_MS }); - if ((await digits.count()) === 1) { - await digits.fill(DEMO_OTP); - return; - } - for (const digit of DEMO_OTP) { - await digits.first().press(digit); - await page.waitForTimeout(100); - } -} - -// eSignet asks for claim consent the first time a subject signs in to this -// client and replays the stored consent on every later sign-in, so the flow -// either stops on the consent screen or carries on to the portal by itself. -async function grantConsentIfAsked(page: Page): Promise { - const consent = page.getByRole('button', { name: /allow|consent|continue|accept/i }).first(); - const catalog = page.getByRole('heading', { name: /^Welcome, / }); - await consent.or(catalog).first().waitFor({ timeout: LOGIN_TIMEOUT_MS }); - if (!(await consent.isVisible())) return; - - // Grant every claim the portal asked for. These are the synthetic profile - // claims of the demo identity; the portal only keeps the subject and name. - const allClaims = page.getByRole('checkbox', { name: 'voluntary_claims' }); - if (await allClaims.count()) { - await allClaims.check({ force: true }); - } else { - const claims = page.locator('input[type="checkbox"]'); - for (let index = 0; index < (await claims.count()); index += 1) { - const claim = claims.nth(index); - if (!(await claim.isChecked())) await claim.check({ force: true }); - } - } - await consent.click(); -} diff --git a/portal/playwright.config.ts b/portal/playwright.config.ts index a64a450..1fc4f51 100644 --- a/portal/playwright.config.ts +++ b/portal/playwright.config.ts @@ -5,10 +5,8 @@ const hostedMode = process.env.SOLMARA_PORTAL_E2E_MODE === 'hosted'; const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`; export default defineConfig({ - // A live stack may put eSignet in front of the portal, where signing in is a - // full OIDC round trip through another origin and can be reissued while - // eSignet is still starting. The mock build lands on the catalog immediately - // and keeps the default budget. + // A hosted run crosses the network to a deployed portal, so it gets a larger + // per-test budget than the local build, which lands on the catalog immediately. timeout: hostedMode ? 120_000 : 30_000, // The portal BFF is a single-process server whose proof feed is an in-process // store with a long-lived SSE feed on /proof/stream. Hosted sessions are scoped diff --git a/portal/src/lib/fields/EvidenceField.svelte b/portal/src/lib/fields/EvidenceField.svelte index 40e64e2..a7bd5ca 100644 --- a/portal/src/lib/fields/EvidenceField.svelte +++ b/portal/src/lib/fields/EvidenceField.svelte @@ -93,11 +93,11 @@ /> {:else if fieldState === 'prefilled'} - +
{result?.display} - self - from eSignet + self - from sign-in
{:else if fieldState === 'in_flight'} diff --git a/portal/src/lib/fields/EvidenceField.test.ts b/portal/src/lib/fields/EvidenceField.test.ts index 35ae0a5..1c0e45e 100644 --- a/portal/src/lib/fields/EvidenceField.test.ts +++ b/portal/src/lib/fields/EvidenceField.test.ts @@ -53,13 +53,13 @@ describe('EvidenceField - self', () => { expect(input).toBeInTheDocument(); }); - it('renders the locked identity chip with "self - from eSignet" when prefilled', () => { + it('renders the locked identity chip with "self - from sign-in" when prefilled', () => { render(EvidenceField, { field: selfField, result: result({ state: 'prefilled', display: 'Elena Dela Cruz · 2300018263' }) }); expect(screen.getByText('Elena Dela Cruz · 2300018263')).toBeInTheDocument(); - expect(screen.getByText(/self - from eSignet/i)).toBeInTheDocument(); + expect(screen.getByText(/self - from sign-in/i)).toBeInTheDocument(); }); }); diff --git a/portal/src/lib/forms/identity.ts b/portal/src/lib/forms/identity.ts index 40a654f..ba290ad 100644 --- a/portal/src/lib/forms/identity.ts +++ b/portal/src/lib/forms/identity.ts @@ -13,7 +13,7 @@ export function buildIdentityTrace(displayName: string): ProofTrace { id: IDENTITY_TRACE_ID, seq: 0, headline: `Identity bound by SolmaraID: ${displayName} signed in, the portal never typed her name`, - answered: 'SolmaraID (eSignet) answered: identity verified, subject bound server-side', + answered: 'SolmaraID answered: identity verified, subject bound server-side', notDisclosed: 'Not disclosed: the raw national id and the tokens, held server-side only and never sent to the browser', status: 'ok', @@ -23,9 +23,9 @@ export function buildIdentityTrace(displayName: string): ProofTrace { presentations: [], responseStatus: 200, proof: { - signedBy: 'SolmaraID (eSignet)', + signedBy: 'SolmaraID', algorithm: 'EdDSA-Ed25519', - issuerKey: 'did:web:esignet.gov.solmara.example#key-1', + issuerKey: 'did:web:id.gov.solmara.example#key-1', holderBound: 'Bound to the signed-in portal session', credential: 'OIDC session identity held server-side' } diff --git a/portal/src/lib/proof/ProofTicker.svelte b/portal/src/lib/proof/ProofTicker.svelte index 4fe6b0b..fbc48b1 100644 --- a/portal/src/lib/proof/ProofTicker.svelte +++ b/portal/src/lib/proof/ProofTicker.svelte @@ -39,11 +39,11 @@ } function authorityShort(trace: ProofTrace): string { - // The identity-binding trace has no Evidence authority (eSignet is the identity + // The identity-binding trace has no Evidence authority (SolmaraID is the identity // provider, not one of the source authorities), so name its issuer explicitly rather // than falling through to a bare "Unknown". if (trace.id === IDENTITY_TRACE_ID) { - return 'eSignet'; + return 'SolmaraID'; } switch (trace.authority) { case 'civil': diff --git a/portal/src/lib/proof/ProofTicker.test.ts b/portal/src/lib/proof/ProofTicker.test.ts index e801783..c78bb78 100644 --- a/portal/src/lib/proof/ProofTicker.test.ts +++ b/portal/src/lib/proof/ProofTicker.test.ts @@ -63,12 +63,12 @@ describe('ProofTicker', () => { expect(screen.getAllByText('Portal').length).toBeGreaterThan(0); }); - it('labels the identity-binding trace as eSignet, never Unknown', () => { - // The identity trace carries no authority Evidence source (eSignet is the + it('labels the identity-binding trace as SolmaraID, never Unknown', () => { + // The identity trace carries no authority Evidence source (SolmaraID is the // identity provider), so a naive lookup falls through to "Unknown". It // must instead name its issuer so the first proof line reads honestly. render(ProofTicker, { props: { traces: [buildIdentityTrace('Elena Dela Cruz')] } }); expect(screen.queryByText('Unknown')).toBeNull(); - expect(screen.getByText('eSignet')).toBeInTheDocument(); + expect(screen.getByText('SolmaraID')).toBeInTheDocument(); }); }); diff --git a/portal/src/lib/proof/canned-traces.ts b/portal/src/lib/proof/canned-traces.ts index be57298..24b7648 100644 --- a/portal/src/lib/proof/canned-traces.ts +++ b/portal/src/lib/proof/canned-traces.ts @@ -98,8 +98,8 @@ export const CANNED_TRACES: ProofTrace[] = [ id: 'event-0', seq: 0, fieldId: 'identity', - headline: 'Identity bound through eSignet', - answered: 'eSignet bound the signed-in portal session', + headline: 'Identity bound through SolmaraID', + answered: 'SolmaraID bound the signed-in portal session', notDisclosed: 'No additional identity attributes were shared', status: 'ok', ts: '2026-06-21T12:03:58.000Z', diff --git a/portal/src/lib/server/esignet.test.ts b/portal/src/lib/server/esignet.test.ts deleted file mode 100644 index d4de4e2..0000000 --- a/portal/src/lib/server/esignet.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Cookies } from '@sveltejs/kit'; -import { generateKeyPairSync } from 'node:crypto'; -import { - completeEsignetLogin, - createEsignetLogin, - esignetConfigFor, - ESIGNET_LOGIN_COOKIE, - resetEsignetLoginStates, - type EsignetConfig -} from './esignet'; - -class MemoryCookies { - readonly values = new Map(); - - get(name: string): string | undefined { - return this.values.get(name); - } - - set(name: string, value: string): void { - this.values.set(name, value); - } - - delete(name: string): void { - this.values.delete(name); - } -} - -function cookiesForTest(jar: MemoryCookies): Cookies { - return jar as unknown as Cookies; -} - -function privateKeyPem(): string { - const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); - return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); -} - -function config(): EsignetConfig { - return { - issuer: 'https://esignet.example.test', - authorizationEndpoint: 'https://esignet-ui.example.test/authorize', - tokenEndpoint: 'https://esignet.example.test/v1/esignet/oauth/v2/token', - clientAssertionAudience: 'https://esignet.example.test/v1/esignet/oauth/v2/token', - userinfoEndpoint: 'https://esignet.example.test/v1/esignet/oidc/userinfo', - clientId: 'solmara-portal', - clientKeyId: 'solmara-portal-key-1', - clientPrivateKeyPem: privateKeyPem(), - redirectUri: 'https://portal.example.test/auth/callback', - scope: 'openid profile', - subjectClaim: 'individual_id', - secureCookies: true - }; -} - -function callbackUrl(authorizeUrl: URL): URL { - const state = authorizeUrl.searchParams.get('state'); - expect(state).toBeTruthy(); - return new URL(`https://portal.example.test/auth/callback?code=test-code&state=${state}`); -} - -describe('eSignet portal login', () => { - beforeEach(() => { - resetEsignetLoginStates(); - vi.useRealTimers(); - }); - - afterEach(() => { - resetEsignetLoginStates(); - vi.useRealTimers(); - }); - - it('creates an authorization URL with PKCE and stores only opaque state in the cookie', () => { - const jar = new MemoryCookies(); - const authorize = createEsignetLogin(cookiesForTest(jar), config()); - - expect(authorize.origin + authorize.pathname).toBe('https://esignet-ui.example.test/authorize'); - expect(authorize.searchParams.get('response_type')).toBe('code'); - expect(authorize.searchParams.get('code_challenge_method')).toBe('S256'); - expect(authorize.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/); - expect(jar.values.get(ESIGNET_LOGIN_COOKIE)).toBe(authorize.searchParams.get('state')); - expect(jar.values.get(ESIGNET_LOGIN_COOKIE)).not.toMatch( - new RegExp(String.raw`\b[2-9]\d{9}\b|NID` + String.raw`-\d+`) - ); - }); - - it('exchanges the code and derives the session from the configured UserInfo claim', async () => { - const jar = new MemoryCookies(); - const cfg = config(); - const authorize = createEsignetLogin(cookiesForTest(jar), cfg); - let tokenRequestBody = ''; - const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url === cfg.tokenEndpoint) { - tokenRequestBody = init?.body?.toString() ?? ''; - return new Response(JSON.stringify({ access_token: 'test-access-token', token_type: 'Bearer' }), { - headers: { 'content-type': 'application/json' } - }); - } - if (url === cfg.userinfoEndpoint) { - expect(init?.headers).toMatchObject({ authorization: 'Bearer test-access-token' }); - return new Response( - JSON.stringify({ - iss: cfg.issuer, - sub: 'partner-specific-user-token', - individual_id: '2300018263', - name: 'Elena Dela Cruz' - }), - { headers: { 'content-type': 'application/json' } } - ); - } - return new Response('not found', { status: 404 }); - }); - - const session = await completeEsignetLogin(cookiesForTest(jar), callbackUrl(authorize), cfg, fetchFn); - const body = new URLSearchParams(tokenRequestBody); - - expect(body.get('grant_type')).toBe('authorization_code'); - expect(body.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); - expect(body.get('client_assertion')?.split('.')).toHaveLength(3); - expect(body.get('code_verifier')).toBeTruthy(); - expect(session).toEqual({ subject: '2300018263', displayName: 'Elena Dela Cruz' }); - expect(jar.values.has(ESIGNET_LOGIN_COOKIE)).toBe(false); - }); - - it('rejects UserInfo that omits the configured subject claim instead of falling back to sub', async () => { - const jar = new MemoryCookies(); - const cfg = config(); - const authorize = createEsignetLogin(cookiesForTest(jar), cfg); - const fetchFn = vi.fn(async (input: RequestInfo | URL) => { - const url = input.toString(); - if (url === cfg.tokenEndpoint) { - return new Response(JSON.stringify({ access_token: 'test-access-token' }), { - headers: { 'content-type': 'application/json' } - }); - } - return new Response(JSON.stringify({ iss: cfg.issuer, sub: '2300018263', name: 'Elena Dela Cruz' }), { - headers: { 'content-type': 'application/json' } - }); - }); - - await expect(completeEsignetLogin(cookiesForTest(jar), callbackUrl(authorize), cfg, fetchFn)).rejects.toThrow( - 'configured subject claim individual_id' - ); - }); - - it('keeps the eSignet client disabled unless PORTAL_AUTH_PROVIDER=esignet', () => { - expect(esignetConfigFor(new URL('https://portal.example.test/auth/login'), {} as NodeJS.ProcessEnv)).toBeNull(); - }); -}); diff --git a/portal/src/lib/server/esignet.ts b/portal/src/lib/server/esignet.ts deleted file mode 100644 index a1e3317..0000000 --- a/portal/src/lib/server/esignet.ts +++ /dev/null @@ -1,335 +0,0 @@ -import type { Cookies } from '@sveltejs/kit'; -import { createHash, createPrivateKey, createSign, randomBytes, randomUUID } from 'node:crypto'; -import type { PortalSession } from './session'; - -const AUTH_PROVIDER_ENV = 'PORTAL_AUTH_PROVIDER'; -export const ESIGNET_LOGIN_COOKIE = 'solmara_esignet_login'; -const LOGIN_MAX_AGE_SECONDS = 10 * 60; -const LOGIN_MAX_AGE_MS = LOGIN_MAX_AGE_SECONDS * 1000; -const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; - -type StoredLogin = { - verifier: string; - nonce: string; - redirectUri: string; - expiresAt: number; -}; - -export type EsignetConfig = { - issuer: string; - authorizationEndpoint: string; - tokenEndpoint: string; - clientAssertionAudience: string; - userinfoEndpoint: string; - clientId: string; - clientKeyId: string; - clientPrivateKeyPem: string; - redirectUri: string; - scope: string; - subjectClaim: string; - secureCookies: boolean; -}; - -export class EsignetConfigError extends Error { - constructor(message: string) { - super(message); - this.name = 'EsignetConfigError'; - } -} - -export class EsignetAuthError extends Error { - constructor(message: string) { - super(message); - this.name = 'EsignetAuthError'; - } -} - -const loginStates = new Map(); - -export function esignetConfigFor(url: URL, env: NodeJS.ProcessEnv = process.env): EsignetConfig | null { - const provider = env[AUTH_PROVIDER_ENV] ?? 'mock'; - if (provider === 'mock') return null; - if (provider !== 'esignet') { - throw new EsignetConfigError(`${AUTH_PROVIDER_ENV} must be "mock" or "esignet"`); - } - - const redirectUri = env.PORTAL_ESIGNET_REDIRECT_URI || new URL('/auth/callback', url.origin).toString(); - return { - issuer: requiredIssuer(env, 'PORTAL_ESIGNET_ISSUER'), - authorizationEndpoint: requiredUrl(env, 'PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT'), - tokenEndpoint: requiredUrl(env, 'PORTAL_ESIGNET_TOKEN_ENDPOINT'), - clientAssertionAudience: optionalUrl(env, 'PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE') || requiredUrl(env, 'PORTAL_ESIGNET_TOKEN_ENDPOINT'), - userinfoEndpoint: requiredUrl(env, 'PORTAL_ESIGNET_USERINFO_ENDPOINT'), - clientId: env.PORTAL_ESIGNET_CLIENT_ID || 'solmara-portal', - clientKeyId: env.PORTAL_ESIGNET_CLIENT_KEY_ID || 'solmara-portal-key-1', - clientPrivateKeyPem: privateKeyFromEnv(env), - redirectUri, - scope: env.PORTAL_ESIGNET_SCOPE || 'openid profile', - subjectClaim: env.PORTAL_ESIGNET_SUBJECT_CLAIM || 'individual_id', - secureCookies: env.PORTAL_SECURE_COOKIES === 'true' - }; -} - -export function createEsignetLogin(cookies: Cookies, config: EsignetConfig): URL { - reclaimExpiredLogins(); - const state = randomToken(); - const verifier = randomToken(); - const nonce = randomToken(); - loginStates.set(state, { - verifier, - nonce, - redirectUri: config.redirectUri, - expiresAt: Date.now() + LOGIN_MAX_AGE_MS - }); - cookies.set(ESIGNET_LOGIN_COOKIE, state, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: config.secureCookies, - maxAge: LOGIN_MAX_AGE_SECONDS - }); - - const authorize = new URL(config.authorizationEndpoint); - authorize.searchParams.set('response_type', 'code'); - authorize.searchParams.set('client_id', config.clientId); - authorize.searchParams.set('redirect_uri', config.redirectUri); - authorize.searchParams.set('scope', config.scope); - authorize.searchParams.set('state', state); - authorize.searchParams.set('nonce', nonce); - authorize.searchParams.set('code_challenge', pkceChallenge(verifier)); - authorize.searchParams.set('code_challenge_method', 'S256'); - return authorize; -} - -export async function completeEsignetLogin( - cookies: Cookies, - url: URL, - config: EsignetConfig, - fetchFn: typeof fetch -): Promise { - const login = consumeLogin(cookies, url, config); - const token = await exchangeCode(url.searchParams.get('code') ?? '', login, config, fetchFn); - const claims = await fetchUserInfo(token.accessToken, config, fetchFn); - return sessionFromClaims(claims, config); -} - -export function resetEsignetLoginStates(): void { - loginStates.clear(); -} - -export function reclaimExpiredLogins(now = Date.now()): number { - let reclaimed = 0; - for (const [state, login] of loginStates) { - if (login.expiresAt <= now) { - loginStates.delete(state); - reclaimed += 1; - } - } - return reclaimed; -} - -function requiredUrl(env: NodeJS.ProcessEnv, name: string): string { - const value = env[name]; - if (!value) throw new EsignetConfigError(`${name} is required when ${AUTH_PROVIDER_ENV}=esignet`); - try { - return new URL(value).toString(); - } catch { - throw new EsignetConfigError(`${name} must be an absolute URL`); - } -} - -function requiredIssuer(env: NodeJS.ProcessEnv, name: string): string { - const value = env[name]; - if (!value) throw new EsignetConfigError(`${name} is required when ${AUTH_PROVIDER_ENV}=esignet`); - try { - return normalizeIssuer(value); - } catch { - throw new EsignetConfigError(`${name} must be an absolute URL`); - } -} - -function optionalUrl(env: NodeJS.ProcessEnv, name: string): string | null { - const value = env[name]; - if (!value) return null; - try { - return new URL(value).toString(); - } catch { - throw new EsignetConfigError(`${name} must be an absolute URL`); - } -} - -function normalizeIssuer(value: string): string { - return new URL(value).toString().replace(/\/$/, ''); -} - -function privateKeyFromEnv(env: NodeJS.ProcessEnv): string { - const encoded = env.PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64; - if (!encoded) { - throw new EsignetConfigError( - `PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64 is required when ${AUTH_PROVIDER_ENV}=esignet` - ); - } - try { - const pem = Buffer.from(encoded, 'base64').toString('utf8'); - createPrivateKey(pem); - return pem; - } catch { - throw new EsignetConfigError('PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64 must contain a base64-encoded PEM key'); - } -} - -function consumeLogin(cookies: Cookies, url: URL, config: EsignetConfig): StoredLogin { - reclaimExpiredLogins(); - const state = url.searchParams.get('state'); - const code = url.searchParams.get('code'); - const cookieState = cookies.get(ESIGNET_LOGIN_COOKIE); - cookies.delete(ESIGNET_LOGIN_COOKIE, { path: '/' }); - - if (!code || !state || !cookieState || state !== cookieState) { - throw new EsignetAuthError('invalid eSignet callback state'); - } - const login = loginStates.get(state); - loginStates.delete(state); - if (!login || login.expiresAt <= Date.now() || login.redirectUri !== config.redirectUri) { - throw new EsignetAuthError('expired eSignet login state'); - } - return login; -} - -async function exchangeCode( - code: string, - login: StoredLogin, - config: EsignetConfig, - fetchFn: typeof fetch -): Promise<{ accessToken: string }> { - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: login.redirectUri, - client_id: config.clientId, - code_verifier: login.verifier, - client_assertion_type: CLIENT_ASSERTION_TYPE, - client_assertion: clientAssertion(config) - }); - const response = await fetchFn(config.tokenEndpoint, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, - body - }); - if (!response.ok) throw new EsignetAuthError('eSignet token exchange failed'); - const token = (await response.json()) as Record; - const accessToken = stringClaim(token, 'access_token'); - if (!accessToken) throw new EsignetAuthError('eSignet token response omitted access_token'); - return { accessToken }; -} - -async function fetchUserInfo( - accessToken: string, - config: EsignetConfig, - fetchFn: typeof fetch -): Promise> { - const response = await fetchFn(config.userinfoEndpoint, { - headers: { accept: 'application/json, application/jwt', authorization: `Bearer ${accessToken}` } - }); - if (!response.ok) throw new EsignetAuthError('eSignet UserInfo request failed'); - const text = await response.text(); - const claims = parseUserInfo(text); - const issuer = stringClaim(claims, 'iss'); - if (issuer) { - try { - if (normalizeIssuer(issuer) !== config.issuer) { - throw new EsignetAuthError('eSignet UserInfo issuer mismatch'); - } - } catch (err) { - if (err instanceof EsignetAuthError) throw err; - throw new EsignetAuthError('eSignet UserInfo issuer mismatch'); - } - } - return claims; -} - -function sessionFromClaims(claims: Record, config: EsignetConfig): PortalSession { - const subject = stringClaim(claims, config.subjectClaim); - if (!subject) { - throw new EsignetAuthError(`eSignet UserInfo omitted configured subject claim ${config.subjectClaim}`); - } - return { - subject, - displayName: - stringClaim(claims, 'name') || - [stringClaim(claims, 'given_name'), stringClaim(claims, 'family_name')].filter(Boolean).join(' ') || - subject - }; -} - -function clientAssertion(config: EsignetConfig): string { - const now = Math.floor(Date.now() / 1000); - const header = base64urlJson({ alg: 'RS256', kid: config.clientKeyId, typ: 'JWT' }); - const payload = base64urlJson({ - iss: config.clientId, - sub: config.clientId, - aud: config.clientAssertionAudience, - jti: randomUUID(), - iat: now, - exp: now + 300 - }); - const signingInput = `${header}.${payload}`; - const signer = createSign('RSA-SHA256'); - signer.update(signingInput); - const signature = signer.sign(config.clientPrivateKeyPem); - return `${signingInput}.${base64url(signature)}`; -} - -function parseUserInfo(text: string): Record { - const trimmed = text.trim(); - if (!trimmed) throw new EsignetAuthError('eSignet UserInfo response was empty'); - if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/.test(trimmed)) return decodeJwtPayload(trimmed); - try { - const value = JSON.parse(trimmed) as unknown; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('not an object'); - } - return value as Record; - } catch { - throw new EsignetAuthError('eSignet UserInfo response was not JSON or compact JWT'); - } -} - -function decodeJwtPayload(jwt: string): Record { - try { - const payload = jwt.split('.')[1]; - const json = Buffer.from(base64urlToBase64(payload), 'base64').toString('utf8'); - const value = JSON.parse(json) as unknown; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('not an object'); - } - return value as Record; - } catch { - throw new EsignetAuthError('eSignet compact JWT payload could not be decoded'); - } -} - -function stringClaim(claims: Record, name: string): string | null { - const value = claims[name]; - return typeof value === 'string' && value.trim() ? value : null; -} - -function pkceChallenge(verifier: string): string { - return base64url(createHash('sha256').update(verifier).digest()); -} - -function randomToken(): string { - return randomBytes(32).toString('base64url'); -} - -function base64urlJson(value: unknown): string { - return base64url(Buffer.from(JSON.stringify(value), 'utf8')); -} - -function base64url(raw: Buffer): string { - return raw.toString('base64url'); -} - -function base64urlToBase64(value: string): string { - return value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '='); -} diff --git a/portal/src/lib/server/personas.ts b/portal/src/lib/server/personas.ts index 8686c68..61e2a38 100644 --- a/portal/src/lib/server/personas.ts +++ b/portal/src/lib/server/personas.ts @@ -1,8 +1,7 @@ -// MOCK-ONLY persona roster for the Phase 0 handoff from the visitor center. +// MOCK-ONLY persona roster for the handoff from the visitor center. // -// The visitor center's persona cards link to `/?persona=`. In Phase 1 the -// login is a real eSignet flow and this file goes away: the subject comes from -// UserInfo, never from a query parameter. For Phase 0 we accept a persona hint +// The visitor center's persona cards link to `/?persona=`. The subject is +// never taken from a query parameter directly: we accept a persona hint // ONLY when it matches this published, synthetic roster, so a stranger UIN can // never forge a session for someone off the cast list. Everything here is // synthetic Solmara data. diff --git a/portal/src/lib/server/session.ts b/portal/src/lib/server/session.ts index 34fdffe..a2aee33 100644 --- a/portal/src/lib/server/session.ts +++ b/portal/src/lib/server/session.ts @@ -1,10 +1,8 @@ -// Server-side portal sessions. Mock mode binds a canned/persona subject without -// eSignet; eSignet mode binds the subject returned by UserInfo. The session -// shape stays the same in both modes. +// Server-side portal sessions. The mock login binds a canned or persona subject +// server-side. // // We NEVER forge or store a token here: a mock session carries only the subject -// and display name, never bearer material. In eSignet mode the BFF derives the -// same session shape from UserInfo and does not store access tokens here. +// and display name, never bearer material. // Server-only module. import type { Cookies } from '@sveltejs/kit'; @@ -13,7 +11,7 @@ import { PERSONA } from '$lib/providers/mock'; export type PortalSession = { subject: string; // Solmara UIN bound server-side, e.g. 2300010248 - displayName: string; // from eSignet UserInfo in Phase 1; canned here + displayName: string; // canned by the mock login }; export const SESSION_COOKIE = 'solmara_session'; @@ -34,8 +32,8 @@ type StoredSession = { const sessions = new Map(); // Establish the mock session cookie. httpOnly so the browser script never reads -// it; no token material is stored. Phase 1 stores the eSignet session server-side -// keyed by an opaque id instead of carrying the subject in the cookie value. +// it; no token material is stored. The session is held server-side keyed by an +// opaque id rather than carrying the subject in the cookie value. // // The optional `session` selects which mock persona to bind. It defaults to the // canned Elena Dela Cruz session. Callers pass a persona ONLY after validating diff --git a/portal/src/lib/types.ts b/portal/src/lib/types.ts index 995710f..e6c06f5 100644 --- a/portal/src/lib/types.ts +++ b/portal/src/lib/types.ts @@ -13,7 +13,7 @@ export type AuthorityId = // Lifecycle state of an EvidenceField (UX section 4 + Phase 0 DoD). export type FieldState = | 'idle' // self-entry, empty input - | 'prefilled' // self identity from eSignet, locked + | 'prefilled' // self identity from sign-in, locked | 'in_flight' // request issued, authority named | 'slow' // > ~6-8s, elapsed counter, "it's a live call" | 'verified' // predicate true (GREEN) diff --git a/portal/src/routes/auth/auth.test.ts b/portal/src/routes/auth/auth.test.ts new file mode 100644 index 0000000..5e88d18 --- /dev/null +++ b/portal/src/routes/auth/auth.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Cookies } from '@sveltejs/kit'; +import { getSession, resetMockSessions, SESSION_COOKIE } from '$lib/server/session'; +import { GET as login } from './login/+server'; +import { GET as callback } from './callback/+server'; + +class MemoryCookies { + readonly values = new Map(); + + get(name: string): string | undefined { + return this.values.get(name); + } + + set(name: string, value: string): void { + this.values.set(name, value); + } + + delete(name: string): void { + this.values.delete(name); + } +} + +// The handlers redirect by throwing; capture the redirect status and location. +async function redirectOf(run: () => unknown): Promise<{ status: number; location: string }> { + try { + await run(); + } catch (thrown) { + const redirect = thrown as { status?: number; location?: string }; + if (redirect.status !== undefined && redirect.location !== undefined) { + return { status: redirect.status, location: redirect.location }; + } + throw thrown; + } + throw new Error('handler did not redirect'); +} + +function callbackEvent(jar: MemoryCookies, search = '') { + // Test double implements only the pieces of the event these handlers read. + return { cookies: jar as unknown as Cookies, url: new URL(`http://portal.test/auth/callback${search}`) }; +} + +describe('mock login routes', () => { + beforeEach(() => resetMockSessions()); + + it('login redirects to the callback', async () => { + const event = { url: new URL('http://portal.test/auth/login') }; + const result = await redirectOf(() => login(event as never)); + expect(result).toEqual({ status: 302, location: '/auth/callback' }); + }); + + it('login carries a persona hint through to the callback', async () => { + const event = { url: new URL('http://portal.test/auth/login?persona=2300010248') }; + const result = await redirectOf(() => login(event as never)); + expect(result.location).toBe('/auth/callback?persona=2300010248'); + }); + + it('callback binds the default session without a hint', async () => { + const jar = new MemoryCookies(); + const result = await redirectOf(() => callback(callbackEvent(jar) as never)); + expect(result).toEqual({ status: 302, location: '/services' }); + expect(getSession(jar as unknown as Cookies)?.displayName).toBe('Elena Dela Cruz'); + expect(jar.get(SESSION_COOKIE)).toBeTruthy(); + }); + + it('callback binds a rostered persona', async () => { + const jar = new MemoryCookies(); + await redirectOf(() => callback(callbackEvent(jar, '?persona=2300010248') as never)); + expect(getSession(jar as unknown as Cookies)).toEqual({ + subject: '2300010248', + displayName: 'Mateo Santos' + }); + }); + + it('callback ignores a persona off the roster and ignores OIDC parameters', async () => { + const jar = new MemoryCookies(); + await redirectOf(() => + callback(callbackEvent(jar, '?persona=9999999999&code=abc&state=xyz') as never) + ); + expect(getSession(jar as unknown as Cookies)?.subject).toBe('2300018263'); + }); +}); diff --git a/portal/src/routes/auth/callback/+server.ts b/portal/src/routes/auth/callback/+server.ts index 5d034d2..7a0fd80 100644 --- a/portal/src/routes/auth/callback/+server.ts +++ b/portal/src/routes/auth/callback/+server.ts @@ -1,33 +1,16 @@ // GET /auth/callback. // -// In eSignet mode this verifies login state, exchanges the code with -// private-key JWT, fetches UserInfo, and stores only the derived subject/display -// name in the server-side portal session. In mock mode it establishes a -// server-side persona session. No token is forged, stored in the browser, or -// logged here. +// Establishes a server-side persona session for the mock login. No token is +// forged, stored in the browser, or logged here. -import { error, redirect } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { completeEsignetLogin, esignetConfigFor, EsignetAuthError, EsignetConfigError } from '$lib/server/esignet'; -import { setMockSession, setPortalSession } from '$lib/server/session'; +import { setMockSession } from '$lib/server/session'; import { resolvePersona } from '$lib/server/personas'; -export const GET: RequestHandler = async ({ cookies, fetch, url }) => { - try { - const esignet = esignetConfigFor(url); - if (esignet) { - const session = await completeEsignetLogin(cookies, url, esignet, fetch); - setPortalSession(cookies, session); - throw redirect(302, '/services'); - } - } catch (err) { - if (err instanceof EsignetConfigError) throw error(500, 'eSignet login is not configured'); - if (err instanceof EsignetAuthError) throw error(401, 'eSignet login failed'); - throw err; - } - - // Mock mode: set a subject-bound mock session and continue to the catalog. A - // valid persona hint from the visitor center binds that persona; an unknown or +export const GET: RequestHandler = ({ cookies, url }) => { + // Set a subject-bound mock session and continue to the catalog. A valid + // persona hint from the visitor center binds that persona; an unknown or // absent hint falls back to the default Elena Dela Cruz session, so a query // parameter can never forge a session for someone off the published roster. const persona = resolvePersona(url.searchParams.get('persona')); diff --git a/portal/src/routes/auth/login/+server.ts b/portal/src/routes/auth/login/+server.ts index 30610c9..c9319b0 100644 --- a/portal/src/routes/auth/login/+server.ts +++ b/portal/src/routes/auth/login/+server.ts @@ -1,29 +1,15 @@ // GET /auth/login. // -// In eSignet mode this starts Authorization Code + PKCE and redirects to the -// configured eSignet authorize endpoint. In mock mode it redirects straight to -// the callback, which establishes a server-side persona session. No token is +// The portal signs in with its mock login: this redirects straight to the +// callback, which establishes a server-side persona session. No token is // forged, stored in the browser, or logged here. -import { error, redirect } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { createEsignetLogin, esignetConfigFor, EsignetConfigError } from '$lib/server/esignet'; -export const GET: RequestHandler = ({ cookies, url }) => { - try { - const esignet = esignetConfigFor(url); - if (esignet) { - throw redirect(302, createEsignetLogin(cookies, esignet).toString()); - } - } catch (err) { - if (err instanceof EsignetConfigError) throw error(500, 'eSignet login is not configured'); - throw err; - } - - // Mock mode: go to callback. +export const GET: RequestHandler = ({ url }) => { // A `persona` hint from the visitor center handoff is carried through so the - // callback can bind that persona's mock session. eSignet mode ignores it: the - // subject comes from UserInfo, never a query parameter. + // callback can bind that persona's mock session. const persona = url.searchParams.get('persona'); throw redirect(302, persona ? `/auth/callback?persona=${encodeURIComponent(persona)}` : '/auth/callback'); }; diff --git a/portal/src/routes/gallery/fields/+page.svelte b/portal/src/routes/gallery/fields/+page.svelte index 6b0fc08..6067305 100644 --- a/portal/src/routes/gallery/fields/+page.svelte +++ b/portal/src/routes/gallery/fields/+page.svelte @@ -59,7 +59,7 @@ result: null }, { - note: 'prefilled - locked identity chip, "self - from eSignet"', + note: 'prefilled - locked identity chip, "self - from sign-in"', field: selfField, result: { state: 'prefilled', display: 'Elena Dela Cruz · 2300018263', traceId: 'event-0' } }, diff --git a/requests/registry-lab/50 - Relay V2/02 - NIA eSignet lookup.bru b/requests/registry-lab/50 - Relay V2/02 - NIA eSignet lookup.bru deleted file mode 100644 index c2c5d0b..0000000 --- a/requests/registry-lab/50 - Relay V2/02 - NIA eSignet lookup.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: 02 - NIA eSignet lookup - type: http - seq: 2 -} - -post { - url: {{nia_relay_url}}/v2/resources/population-person/lookups/esignet-userinfo?fields=individualId,givenName,familyName,gender,birthdate,active - body: json - auth: none -} - -headers { - Authorization: Bearer {{NIA_ESIGNET_RELAY_TOKEN}} - Accept: application/json -} - -body:json { - {"selectors":{"uin":"2300010248"}} -} diff --git a/requests/registry-lab/README.md b/requests/registry-lab/README.md index 9e4e65a..3941a56 100644 --- a/requests/registry-lab/README.md +++ b/requests/registry-lab/README.md @@ -5,8 +5,8 @@ The examples exercise the authority-owned reset: 1. The child-benefit programme composes four independently signed assertions. 2. CRA and SIPF issue separate pension and survivor assertions. 3. NAgDI issues separate voucher and livestock assertions. -4. The Relay V2 folder demonstrates only the eight governed exact lookups used - by Evidence and the optional NIA eSignet profile. +4. The Relay V2 folder demonstrates only the seven governed exact lookups used + by Evidence. Set `EVIDENCE_ACCESS_TOKEN` to a short-lived Registry Mint token for the authority Evidence endpoints. Set `CHILD_BENEFIT_FEDERATOR_TOKEN` only when diff --git a/requests/registry-lab/environments/Hosted Lab.bru b/requests/registry-lab/environments/Hosted Lab.bru index 42df55b..4c671d1 100644 --- a/requests/registry-lab/environments/Hosted Lab.bru +++ b/requests/registry-lab/environments/Hosted Lab.bru @@ -16,7 +16,6 @@ vars { EVIDENCE_ACCESS_TOKEN: CRA_PENSION_RELAY_TOKEN: CRA_CITIZEN_RELAY_TOKEN: - NIA_ESIGNET_RELAY_TOKEN: MOSD_CHILD_BENEFIT_RELAY_TOKEN: SIPF_PENSION_RELAY_TOKEN: SIPF_SURVIVOR_RELAY_TOKEN: diff --git a/requests/registry-lab/environments/Local Compose.bru b/requests/registry-lab/environments/Local Compose.bru index b7a784f..53412ab 100644 --- a/requests/registry-lab/environments/Local Compose.bru +++ b/requests/registry-lab/environments/Local Compose.bru @@ -16,7 +16,6 @@ vars { EVIDENCE_ACCESS_TOKEN: CRA_PENSION_RELAY_TOKEN: CRA_CITIZEN_RELAY_TOKEN: - NIA_ESIGNET_RELAY_TOKEN: MOSD_CHILD_BENEFIT_RELAY_TOKEN: SIPF_PENSION_RELAY_TOKEN: SIPF_SURVIVOR_RELAY_TOKEN: diff --git a/scripts/check-config-secrets.py b/scripts/check-config-secrets.py index 59e9b1a..a215a79 100755 --- a/scripts/check-config-secrets.py +++ b/scripts/check-config-secrets.py @@ -16,7 +16,6 @@ "config/hosted-relay-bundles", "compose.yaml", "compose.hosted.yaml", - "compose.esignet.yaml", ] RAW_SECRET_KEYS = re.compile( r"(?i)\b(token|secret|password|private[_-]?key|client[_-]?secret)\s*:\s*['\"]?[^${\s][^#\n]+" @@ -30,7 +29,7 @@ "POSTGRES_PASSWORD:", ) WORKLOAD_TOKEN_VOLUME = re.compile( - r"^\s*-\s*[a-z0-9-]+-workload-token:/run/(?:esignet-)?secrets(?::ro)?\s*$" + r"^\s*-\s*[a-z0-9-]+-workload-token:/run/secrets(?::ro)?\s*$" ) PROJECT_SECRET_REFERENCE = re.compile( r"^\s*[a-z][a-z0-9_]*:\s*\{\s*secret:\s*[A-Z][A-Z0-9_]*\s*\}\s*$" @@ -62,11 +61,7 @@ def main() -> int: for path in iter_files(): if ( path.suffix not in {".yaml", ".yml", ".env", ""} - and path.name - not in { - "compose.yaml", - "compose.esignet.yaml", - } + and path.name != "compose.yaml" and not path.name.startswith("compose.coolify") ): continue diff --git a/scripts/check-coolify-compose.sh b/scripts/check-coolify-compose.sh index c8515bc..731cf8e 100755 --- a/scripts/check-coolify-compose.sh +++ b/scripts/check-coolify-compose.sh @@ -20,18 +20,6 @@ check_compose() { SOLMARA_TRANSIT_SIGNER_IMAGE="$test_image" \ SOLMARA_HOME_IMAGE="$test_image" \ SOLMARA_PORTAL_IMAGE="$test_image" \ - SOLMARA_ESIGNET_RELAY_IMAGE="$test_image" \ - SOLMARA_ESIGNET_SEED_IMAGE="$test_image" \ - SOLMARA_ESIGNET_POSTGRES_IMAGE="$test_image" \ - SOLMARA_ESIGNET_UI_IMAGE="$test_image" \ - ESIGNET_REDIS_IMAGE="redis@sha256:b99ffd0554dc8d300230b9d1b9f2a129a6abf595bf8589883beb980ed1feae3d" \ - SOLMARA_ESIGNET_POSTGRES_PASSWORD=test \ - ESIGNET_DEMO_OTP=111111 \ - REGISTRY_ESIGNET_KYC_TOKEN_SECRET=test \ - REGISTRY_ESIGNET_PSUT_SECRET=test \ - REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD=test \ - PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64=test \ - NIA_ESIGNET_CLIENT_PRIVATE_JWK="$test_private_jwk" \ SOLMARA_DEMO_CLIENT_PRIVATE_JWK="$test_private_jwk" \ CHILD_BENEFIT_FEDERATOR_TOKEN=test \ CRA_RELAY_AUDIT_KEY=test NIA_RELAY_AUDIT_KEY=test MOSD_RELAY_AUDIT_KEY=test \ @@ -52,7 +40,7 @@ check_compose() { MOSD_CHILD_BENEFIT_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" \ SIPF_PENSION_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" SIPF_SURVIVOR_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" \ NAGDI_VOUCHER_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" NAGDI_LIVESTOCK_EVIDENCE_CLIENT_PUBLIC_JWK="$test_public_jwk" \ - NIA_ESIGNET_CLIENT_PUBLIC_JWK="$test_public_jwk" SOLMARA_DEMO_CLIENT_PUBLIC_JWK="$test_public_jwk" \ + SOLMARA_DEMO_CLIENT_PUBLIC_JWK="$test_public_jwk" \ docker compose "$@" config >"$scratch/rendered.yaml" rendered_bytes=$(wc -c <"$scratch/rendered.yaml" | tr -d ' ') diff --git a/scripts/check-image-pins.py b/scripts/check-image-pins.py index f3ec540..3770937 100755 --- a/scripts/check-image-pins.py +++ b/scripts/check-image-pins.py @@ -19,8 +19,6 @@ PINNED_IMAGE_KEYS = { "VOLUME_INIT_IMAGE", "LOCAL_EDGE_IMAGE", "PYTHON_STATIC_IMAGE", "NODE_BUILD_IMAGE", "UV_BUILD_IMAGE", - "ESIGNET_REDIS_IMAGE", "ESIGNET_BASE_IMAGE", "ESIGNET_UI_IMAGE", - "ESIGNET_POSTGRES_IMAGE", } @@ -59,7 +57,7 @@ def main() -> int: if key not in values: failures.append(f"versions.env: {key} is required") - compose_files = [ROOT / "compose.yaml", ROOT / "compose.esignet.yaml"] + compose_files = [ROOT / "compose.yaml"] required_counts = {key: 0 for key in SOURCE_IMAGE_KEYS} for compose in compose_files: if not compose.exists(): diff --git a/scripts/check-registry-stack-release-pin.py b/scripts/check-registry-stack-release-pin.py index 9b4dfcd..72e28b3 100755 --- a/scripts/check-registry-stack-release-pin.py +++ b/scripts/check-registry-stack-release-pin.py @@ -58,24 +58,6 @@ def validate(values: dict[str, str], *, require_public: bool) -> list[str]: failures.append(f"{key} must bind the exact v{REQUIRED_VERSION} linux-amd64 asset") if require_public and not asset_url: failures.append(f"{key} is not published; v{REQUIRED_VERSION} promotion is blocked") - if values.get("ESIGNET_AUTHENTICATOR_VERSION") != "0.2.0": - failures.append("ESIGNET_AUTHENTICATOR_VERSION must be 0.2.0") - release_url = "https://github.com/jeremi/esignet-relay-authenticator/releases/tag/v0.2.0" - if values.get("ESIGNET_AUTHENTICATOR_RELEASE_URL") != release_url: - failures.append("ESIGNET_AUTHENTICATOR_RELEASE_URL must bind the exact v0.2.0 release") - jar_url = values.get("ESIGNET_AUTHENTICATOR_JAR_URL", "") - jar_sha = values.get("ESIGNET_AUTHENTICATOR_JAR_SHA256", "") - if jar_url and not HTTPS.fullmatch(jar_url): - failures.append("ESIGNET_AUTHENTICATOR_JAR_URL must be an exact HTTPS URL") - if jar_sha and not DIGEST.fullmatch(jar_sha): - failures.append("ESIGNET_AUTHENTICATOR_JAR_SHA256 must be 64 lowercase hex characters") - expected_base = "https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/" - if jar_url and jar_url != expected_base + "esignet-relay-authenticator-0.2.0.jar": - failures.append("ESIGNET_AUTHENTICATOR_JAR_URL must bind the exact v0.2.0 release asset") - if values.get("ESIGNET_AUTHENTICATOR_CHECKSUM_URL", "") != expected_base + "esignet-relay-authenticator-0.2.0.jar.sha256": - failures.append("ESIGNET_AUTHENTICATOR_CHECKSUM_URL must bind the exact v0.2.0 checksum asset") - if require_public and (not jar_url or not jar_sha): - failures.append("eSignet authenticator v0.2.0 JAR URL/checksum is not published; promotion is blocked") return failures diff --git a/scripts/check-runtime-topology.py b/scripts/check-runtime-topology.py index 29400f6..17a5107 100755 --- a/scripts/check-runtime-topology.py +++ b/scripts/check-runtime-topology.py @@ -11,7 +11,6 @@ ACTIVE = [ ROOT / "compose.yaml", ROOT / "compose.hosted.yaml", - ROOT / "compose.esignet.yaml", ROOT / "justfile", ROOT / "config/evidence/Caddyfile", ROOT / "config/walt/Caddyfile", @@ -33,12 +32,7 @@ def failures(paths: list[Path] = ACTIVE) -> list[str]: found: list[str] = [] for path in paths: text = path.read_text(encoding="utf-8") - # eSignet is the only surviving stateful third-party component. Its - # database is deliberately isolated from Registry Stack runtime state. - suppress_database = path.name in {"compose.esignet.yaml", "compose.coolify.esignet.yaml"} for label, pattern in FORBIDDEN.items(): - if suppress_database and label == "retired database": - continue for match in pattern.finditer(text): line = text.count("\n", 0, match.start()) + 1 found.append(f"{path.name}:{line}: {label}") diff --git a/scripts/gen-secrets.py b/scripts/gen-secrets.py index c4ab270..3c9c036 100755 --- a/scripts/gen-secrets.py +++ b/scripts/gen-secrets.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Callable -from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.hazmat.primitives.asymmetric import ec from compose_project_name import compose_project_name @@ -28,10 +28,6 @@ "NAGDI_RELAY_CURSOR_KEY", "CHILD_BENEFIT_FEDERATOR_TOKEN", "PORTAL_SESSION_SECRET", - "SOLMARA_ESIGNET_POSTGRES_PASSWORD", - "REGISTRY_ESIGNET_KYC_KEYSTORE_PASSWORD", - "REGISTRY_ESIGNET_KYC_TOKEN_SECRET", - "REGISTRY_ESIGNET_PSUT_SECRET", ) @@ -57,23 +53,6 @@ def p256_jwk() -> str: return json.dumps(jwk, separators=(",", ":"), sort_keys=True) -def rsa_jwk() -> str: - private = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_numbers() - public = private.public_numbers - - def encode(number: int) -> str: - return b64url(number.to_bytes((number.bit_length() + 7) // 8, "big")) - - jwk = { - "kty": "RSA", "alg": "RS256", "n": encode(public.n), "e": encode(public.e), - "d": encode(private.d), "p": encode(private.p), "q": encode(private.q), - "dp": encode(private.dmp1), "dq": encode(private.dmq1), "qi": encode(private.iqmp), - } - thumbprint = {key: jwk[key] for key in ("e", "kty", "n")} - jwk["kid"] = b64url(hashlib.sha256(json.dumps(thumbprint, separators=(",", ":"), sort_keys=True).encode()).digest()) - return json.dumps(jwk, separators=(",", ":"), sort_keys=True) - - def write_private(path: Path, value: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(value.rstrip("\n") + "\n", encoding="utf-8") @@ -128,15 +107,9 @@ def compose_environment_values( key: create_environment_value(existing, key, raw_key) for key in RANDOM_ENV_KEYS } - values["PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64"] = create_environment_value( - existing, "PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64", rsa_private_key_b64 - ) values.update( { "COMPOSE_PROJECT_NAME": compose_project_name(ROOT), - "PORTAL_AUTH_PROVIDER": "mock", - "PORTAL_ESIGNET_CLIENT_ID": "solmara-portal", - "PORTAL_ESIGNET_CLIENT_KEY_ID": "solmara-portal-key-1", **operator_values, } ) @@ -183,10 +156,8 @@ def ensure_operator_material() -> dict[str, str]: directory.mkdir(parents=True, exist_ok=True) create_once(mint / "secrets/signing.jwk", p256_jwk) create_once(mint / "secrets/audit-hmac-key", raw_key) - create_once(mint / "clients/nia-esignet-rsa-client-key", rsa_jwk) create_once(mint / "clients/solmara-demo-client-key", p256_jwk) return { - "NIA_ESIGNET_CLIENT_PRIVATE_JWK": (mint / "clients/nia-esignet-rsa-client-key").read_text().strip(), "SOLMARA_EVIDENCE_CLIENT_KEY": str(mint / "clients/solmara-demo-client-key"), } @@ -221,10 +192,6 @@ def ensure_tls() -> None: crt.chmod(0o644) -def rsa_private_key_b64() -> str: - return base64.b64encode(subprocess.run(["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"], check=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout).decode() - - def main() -> int: operator_values = ensure_operator_material() ensure_tls() diff --git a/scripts/hosted-image-manifest.py b/scripts/hosted-image-manifest.py index 32bfbc5..7d09607 100755 --- a/scripts/hosted-image-manifest.py +++ b/scripts/hosted-image-manifest.py @@ -25,10 +25,6 @@ ("SOLMARA_SCENARIO_RUNNER_IMAGE", "solmara-lab-scenario-runner"), ("SOLMARA_HOME_IMAGE", "solmara-lab-home"), ("SOLMARA_PORTAL_IMAGE", "solmara-lab-portal"), - ("SOLMARA_ESIGNET_RELAY_IMAGE", "solmara-lab-esignet-relay"), - ("SOLMARA_ESIGNET_POSTGRES_IMAGE", "solmara-lab-esignet-postgres"), - ("SOLMARA_ESIGNET_UI_IMAGE", "solmara-lab-esignet-ui"), - ("SOLMARA_ESIGNET_SEED_IMAGE", "solmara-lab-esignet-seed"), ) EXPECTED_KEYS = tuple(key for key, _repository in IMAGE_REPOSITORIES) EXPECTED_REPOSITORIES = dict(IMAGE_REPOSITORIES) diff --git a/scripts/provision-hosted-runtime.py b/scripts/provision-hosted-runtime.py index e2f4f47..cb916ad 100644 --- a/scripts/provision-hosted-runtime.py +++ b/scripts/provision-hosted-runtime.py @@ -87,10 +87,6 @@ "solmara:relay:nagdi:movement-by-farmer-id", "https://id.registrystack.org/solmara/purpose/livestock-movement-control", ), - "nia-esignet": ( - "solmara:relay:nia:esignet-userinfo", - "https://id.registrystack.org/solmara/purpose/esignet-identity-verification", - ), } # Re-adopting an extract this provisioner already published imposes no freshness # ceiling. `maximumExtractAgeSeconds` is a serving policy the Evidence cell diff --git a/scripts/seed-esignet.py b/scripts/seed-esignet.py deleted file mode 100755 index ae185a5..0000000 --- a/scripts/seed-esignet.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -"""Seed MOSIP eSignet for the Solmara portal client.""" - -from __future__ import annotations - -import base64 -import hashlib -import json -import os -import socket -import subprocess -import sys -import time -from ipaddress import ip_address -from pathlib import Path -from urllib.parse import urlparse - - -CLIENT_ID = os.environ.get("ESIGNET_CLIENT_ID", "solmara-portal") -CLIENT_KEY_ID = os.environ.get("ESIGNET_CLIENT_KEY_ID", "solmara-portal-key-1") -RELYING_PARTY_ID = os.environ.get("ESIGNET_RELYING_PARTY_ID", "solmara") -CLIENT_DETAIL_CACHE_KEY = f"esignet:clientdetails::{CLIENT_ID}" -DEFAULT_CLIENT_CLAIMS = [ - "individual_id", - "name", - "given_name", - "family_name", - "gender", - "birthdate", -] - - -def run(args: list[str], *, input_text: str | None = None, capture: bool = False) -> str: - result = subprocess.run( - args, - input=input_text, - text=True, - check=True, - stdout=subprocess.PIPE if capture else None, - ) - return result.stdout if capture else "" - - -def psql(database: str, sql: str, *, capture: bool = False) -> str: - return run(["psql", "-v", "ON_ERROR_STOP=1", "-d", database, "-At"], input_text=sql, capture=capture) - - -def wait_for_table(database: str, table_name: str) -> None: - deadline = time.time() + 180 - query = f"select to_regclass('{table_name}') is not null;\n" - while time.time() < deadline: - try: - if psql(database, query, capture=True).strip() == "t": - return - except subprocess.CalledProcessError: - pass - time.sleep(2) - raise RuntimeError(f"timed out waiting for {database}.{table_name}") - - -def read_der_length(data: bytes, offset: int) -> tuple[int, int]: - first = data[offset] - offset += 1 - if first < 0x80: - return first, offset - size = first & 0x7F - length = int.from_bytes(data[offset : offset + size], "big") - return length, offset + size - - -def read_der_tlv(data: bytes, offset: int, expected_tag: int | None = None) -> tuple[int, bytes, int]: - tag = data[offset] - if expected_tag is not None and tag != expected_tag: - raise ValueError(f"expected ASN.1 tag {expected_tag:#x}, got {tag:#x}") - length, value_offset = read_der_length(data, offset + 1) - end = value_offset + length - return tag, data[value_offset:end], end - - -def read_der_int(data: bytes, offset: int) -> tuple[int, int]: - _, value, end = read_der_tlv(data, offset, 0x02) - return int.from_bytes(value.lstrip(b"\x00"), "big"), end - - -def public_jwk(private_key: Path) -> str: - der = subprocess.check_output( - ["openssl", "rsa", "-in", str(private_key), "-pubout", "-outform", "DER"], - stderr=subprocess.DEVNULL, - ) - _, spki, _ = read_der_tlv(der, 0, 0x30) - _, _, offset = read_der_tlv(spki, 0, 0x30) - _, bit_string, _ = read_der_tlv(spki, offset, 0x03) - if bit_string[0] != 0: - raise ValueError("unsupported subject public key bit string") - _, rsa_public_key, _ = read_der_tlv(bit_string[1:], 0, 0x30) - modulus, offset = read_der_int(rsa_public_key, 0) - exponent, _ = read_der_int(rsa_public_key, offset) - - def b64url_int(value: int) -> str: - width = max(1, (value.bit_length() + 7) // 8) - raw = value.to_bytes(width, "big") - return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") - - jwk = { - "kty": "RSA", - "kid": CLIENT_KEY_ID, - "use": "sig", - "alg": "RS256", - "n": b64url_int(modulus), - "e": b64url_int(exponent), - } - return json.dumps(jwk, separators=(",", ":")) - - -def ensure_private_key() -> tuple[Path, str, str]: - key_file = Path(os.environ.get("ESIGNET_CLIENT_PRIVATE_KEY_FILE", "/var/lib/esignet-seed/client-private.pem")) - key_file.parent.mkdir(parents=True, exist_ok=True) - encoded = os.environ.get("ESIGNET_CLIENT_PRIVATE_KEY_B64") - if encoded: - key_file.write_bytes(base64.b64decode(encoded)) - key_file.chmod(0o600) - elif not key_file.exists(): - run(["openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(key_file)]) - key_file.chmod(0o600) - jwk = public_jwk(key_file) - key_hash = hashlib.sha256(jwk.encode("utf-8")).hexdigest() - return key_file, jwk, key_hash - - -def sql_literal(value: object) -> str: - if not isinstance(value, str): - value = json.dumps(value, separators=(",", ":")) - return "'" + value.replace("'", "''") + "'" - - -def default_redirect_uris() -> list[str]: - return [ - "http://127.0.0.1:4300/auth/callback", - "http://localhost:4300/auth/callback", - ] - - -def redirect_uris() -> list[str]: - raw = os.environ.get("ESIGNET_CLIENT_REDIRECT_URIS_JSON", json.dumps(default_redirect_uris(), separators=(",", ":"))) - value = json.loads(raw) - if not isinstance(value, list) or not value or not all(isinstance(uri, str) for uri in value): - raise ValueError("ESIGNET_CLIENT_REDIRECT_URIS_JSON must be a non-empty JSON string array") - if os.environ.get("ESIGNET_REQUIRE_HTTPS_REDIRECTS") == "true": - for uri in value: - parsed = urlparse(uri) - host = (parsed.hostname or "").lower() - try: - is_loopback = ip_address(host).is_loopback - except ValueError: - is_loopback = host == "localhost" or host.endswith(".localhost") - if parsed.scheme != "https" or is_loopback: - raise ValueError(f"hosted eSignet redirect URI must be public HTTPS: {uri}") - return value - - -def seed_esignet(jwk: str, key_hash: str) -> None: - client_name = {"@none": os.environ.get("ESIGNET_CLIENT_NAME", "Solmara Portal")} - claims = json.loads( - os.environ.get("ESIGNET_CLIENT_CLAIMS_JSON", json.dumps(DEFAULT_CLIENT_CLAIMS, separators=(",", ":"))) - ) - additional_config = { - "userinfo_response_type": "JWS", - "purpose": {"type": "verify"}, - "signup_banner_required": False, - "forgot_pwd_link_required": False, - "consent_expire_in_mins": 20, - } - sql = f""" -insert into esignet.client_detail ( - id, name, rp_id, logo_uri, redirect_uris, claims, acr_values, public_key, - public_key_hash, grant_types, auth_methods, status, additional_config, - cr_dtimes, upd_dtimes -) values ( - {sql_literal(CLIENT_ID)}, - {sql_literal(client_name)}, - {sql_literal(RELYING_PARTY_ID)}, - 'https://example.invalid/logo.png', - {sql_literal(redirect_uris())}, - {sql_literal(claims)}, - {sql_literal(["mosip:idp:acr:generated-code", "mosip:idp:acr:password", "mosip:idp:acr:linked-wallet"])}, - {sql_literal(jwk)}, - {sql_literal(key_hash)}, - {sql_literal(["authorization_code"])}, - {sql_literal(["private_key_jwt"])}, - 'ACTIVE', - {sql_literal(additional_config)}, - now(), - now() -) -on conflict (id) do update set - public_key = excluded.public_key, - public_key_hash = excluded.public_key_hash, - redirect_uris = excluded.redirect_uris, - claims = excluded.claims, - acr_values = excluded.acr_values, - grant_types = excluded.grant_types, - auth_methods = excluded.auth_methods, - status = excluded.status, - additional_config = excluded.additional_config, - upd_dtimes = now(); -""" - psql("mosip_esignet", sql) - - -def redis_command(*parts: str) -> bytes: - encoded = [part.encode("utf-8") for part in parts] - payload = [f"*{len(encoded)}\r\n".encode("ascii")] - for part in encoded: - payload.append(f"${len(part)}\r\n".encode("ascii")) - payload.append(part) - payload.append(b"\r\n") - return b"".join(payload) - - -def clear_esignet_client_cache() -> None: - redis_host = os.environ.get("ESIGNET_REDIS_HOST") - if not redis_host: - return - redis_port = int(os.environ.get("ESIGNET_REDIS_PORT", "6379")) - command = redis_command("DEL", CLIENT_DETAIL_CACHE_KEY) - with socket.create_connection((redis_host, redis_port), timeout=10) as connection: - connection.sendall(command) - response = connection.recv(128) - if not response.startswith((b":0", b":1")): - raise RuntimeError(f"failed to clear eSignet client cache: {response!r}") - - -def main() -> int: - _, jwk, key_hash = ensure_private_key() - wait_for_table("mosip_esignet", "esignet.client_detail") - seed_esignet(jwk, key_hash) - clear_esignet_client_cache() - account_source = os.environ.get("ESIGNET_ACCOUNT_SOURCE_LABEL", "NIA population Relay profile solmara-nia-userinfo") - print(f"Seeded eSignet client {CLIENT_ID}.") - print(f"Relay-backed account source: {account_source}.") - print("Demo credentials are configured without being written to logs.") - if os.environ.get("ESIGNET_SEED_STAY_READY") == "true": - Path("/tmp/ready").write_text("ready\n", encoding="utf-8") - while True: - time.sleep(3600) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except Exception as exc: - print(f"seed-esignet.py: {exc}", file=sys.stderr) - raise diff --git a/scripts/smoke-esignet-login.mjs b/scripts/smoke-esignet-login.mjs deleted file mode 100644 index aec3fa4..0000000 --- a/scripts/smoke-esignet-login.mjs +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node - -// The login itself lives in the portal e2e helper so the smoke and the suite -// drive one flow. Node strips the helper's types on import. -import { chromium } from '../portal/node_modules/@playwright/test/index.mjs'; -import { signIn } from '../portal/e2e/support/auth.ts'; - -const portalUrl = (process.env.SOLMARA_PORTAL_PUBLIC_BASE_URL || 'http://127.0.0.1:4300').replace(/\/$/, ''); - -const browser = await chromium.launch({ headless: true }); -try { - const page = await browser.newPage({ baseURL: portalUrl }); - const mode = await signIn(page); - // A mock sign-in also reaches the catalog, so reaching it is not the check. - // This smoke passes only when eSignet is the provider that answered. - if (mode !== 'esignet') { - throw new Error('the portal signed in without eSignet'); - } - console.log('smoke-esignet-login: PASS'); -} finally { - await browser.close(); -} diff --git a/scripts/smoke-esignet.py b/scripts/smoke-esignet.py deleted file mode 100755 index dbb7431..0000000 --- a/scripts/smoke-esignet.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke checks for the public Solmara eSignet discovery surfaces.""" - -from __future__ import annotations - -import argparse -import json -import os -import shlex -import sys -import time -import urllib.error -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Any - - -ROOT = Path(__file__).resolve().parents[1] - - -@dataclass(frozen=True) -class SmokeTargets: - esignet_url: str - esignet_ui_url: str - - -class SmokeFailure(Exception): - """Stable operator-facing smoke failure.""" - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(sys.argv[1:] if argv is None else argv) - env = {**load_env_file(args.env_file), **os.environ} - targets = SmokeTargets( - esignet_url=normalize_url( - args.esignet_url - or env.get("SOLMARA_ESIGNET_PUBLIC_BASE_URL") - or "http://127.0.0.1:4308" - ), - esignet_ui_url=normalize_url( - args.esignet_ui_url - or env.get("SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL") - or "http://127.0.0.1:4309" - ), - ) - checks: list[tuple[str, Any]] = [ - ( - "eSignet service discovery", - lambda: check_esignet_discovery(targets, args.timeout), - ), - ] - for name, check in checks: - print(f"check: {name}", flush=True) - try: - check() - except SmokeFailure as exc: - print(f"FAILED: {name}: {exc}", file=sys.stderr) - return 1 - print("smoke-esignet: eSignet discovery smoke passed") - return 0 - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--env-file", - type=Path, - default=ROOT / ".env", - help="local env file, default .env", - ) - parser.add_argument( - "--esignet-url", help="eSignet service public base URL, default local port" - ) - parser.add_argument( - "--esignet-ui-url", help="eSignet UI public base URL, default local port" - ) - parser.add_argument( - "--timeout", type=float, default=60.0, help="seconds to wait for each endpoint" - ) - return parser.parse_args(argv[1:] if argv[:1] == ["--"] else argv) - - -def load_env_file(path: Path) -> dict[str, str]: - if not path.exists(): - return {} - values: dict[str, str] = {} - for raw in path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - try: - parsed = shlex.split(value, posix=True) - except ValueError: - parsed = [value] - values[key] = parsed[0] if parsed else "" - return values - - -def normalize_url(value: str) -> str: - return value.rstrip("/") - - -def check_esignet_discovery(targets: SmokeTargets, timeout: float) -> None: - service_doc = wait_for_json( - "GET", - f"{targets.esignet_url}/v1/esignet/oidc/.well-known/openid-configuration", - timeout=timeout, - ) - root_doc = wait_for_json( - "GET", - f"{targets.esignet_url}/.well-known/openid-configuration", - timeout=timeout, - ) - root_oauth_doc = wait_for_json( - "GET", - f"{targets.esignet_url}/.well-known/oauth-authorization-server", - timeout=timeout, - ) - ui_doc = wait_for_json( - "GET", - f"{targets.esignet_ui_url}/.well-known/openid-configuration", - timeout=timeout, - ) - service_issuer = service_doc.get("issuer") - root_issuer = root_doc.get("issuer") - root_oauth_issuer = root_oauth_doc.get("issuer") - ui_issuer = ui_doc.get("issuer") - expected_token_endpoint = f"{targets.esignet_url}/v1/esignet/oauth/v2/token" - if not isinstance(service_issuer, str) or not service_issuer: - raise SmokeFailure("service discovery omitted issuer") - if root_issuer != service_issuer: - raise SmokeFailure( - "root OpenID discovery issuer does not match service discovery issuer" - ) - if root_oauth_issuer != service_issuer: - raise SmokeFailure( - "root OAuth authorization-server issuer does not match service discovery issuer" - ) - if ui_issuer != service_issuer: - raise SmokeFailure( - "UI discovery issuer does not match service discovery issuer" - ) - for name, document in ( - ("service", service_doc), - ("root OpenID", root_doc), - ("root OAuth", root_oauth_doc), - ("UI", ui_doc), - ): - if document.get("token_endpoint") != expected_token_endpoint: - raise SmokeFailure(f"{name} discovery token endpoint is not the public endpoint") - - -def wait_for_json( - method: str, - url: str, - *, - timeout: float, - headers: dict[str, str] | None = None, - body: dict[str, Any] | None = None, -) -> dict[str, Any]: - deadline = time.time() + timeout - last_error = "" - while time.time() < deadline: - try: - return request_json(method, url, headers=headers, body=body) - except SmokeFailure as exc: - last_error = str(exc) - time.sleep(1) - raise SmokeFailure(f"{url}: {last_error or 'timed out'}") - - -def request_json( - method: str, - url: str, - *, - headers: dict[str, str] | None = None, - body: dict[str, Any] | None = None, -) -> dict[str, Any]: - payload = json.dumps(body).encode("utf-8") if body is not None else None - request = urllib.request.Request( - url, data=payload, method=method, headers=headers or {} - ) - try: - with urllib.request.urlopen(request, timeout=10) as response: - data = response.read() - value = json.loads(data.decode("utf-8")) - if not isinstance(value, dict): - raise SmokeFailure("response was not a JSON object") - return value - except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace")[:240] - raise SmokeFailure(f"HTTP {exc.code}: {detail}") from exc - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: - raise SmokeFailure(str(exc)) from exc - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/smoke-hosted-provisioner-image.py b/scripts/smoke-hosted-provisioner-image.py index c6fec17..1ce4abe 100755 --- a/scripts/smoke-hosted-provisioner-image.py +++ b/scripts/smoke-hosted-provisioner-image.py @@ -225,10 +225,6 @@ def smoke(image: str, state_root: Path) -> None: mint_input / f"{client}-public.jwk", helper.public_jwk(LOCAL / cell / "secrets" / f"{client}-client-key"), ) - _write_json( - mint_input / "nia-esignet-public.jwk", - helper.public_jwk(LOCAL / "mint" / "clients" / "nia-esignet-rsa-client-key"), - ) _write_json( mint_input / "solmara-demo-client-public.jwk", helper.public_jwk(LOCAL / "mint" / "clients" / "solmara-demo-client-key"), diff --git a/scripts/start-esignet-relay.sh b/scripts/start-esignet-relay.sh deleted file mode 100755 index d8d2926..0000000 --- a/scripts/start-esignet-relay.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -keystore_path="${REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PATH:?missing REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PATH}" -keystore_password="${REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD:?missing REGISTRY_ESIGNET_KYC_SIGNING_KEYSTORE_PASSWORD}" -key_alias="${REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS:?missing REGISTRY_ESIGNET_KYC_SIGNING_KEY_ALIAS}" -key_password="${REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD:?missing REGISTRY_ESIGNET_KYC_SIGNING_KEY_PASSWORD}" - -if [[ -n "${REGISTRY_TLS_CA_CERT:-}" ]]; then - truststore=/tmp/registry-esignet-truststore.p12 - rm -f "$truststore" - keytool -importcert -noprompt \ - -alias registry-runtime-ca \ - -file "$REGISTRY_TLS_CA_CERT" \ - -keystore "$truststore" \ - -storetype PKCS12 \ - -storepass changeit \ - >/dev/null - export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS:-} -Djavax.net.ssl.trustStore=$truststore -Djavax.net.ssl.trustStorePassword=changeit" -fi - -mkdir -p "$(dirname "$keystore_path")" - -if [[ -f "$keystore_path" ]] && ! keytool -list \ - -keystore "$keystore_path" \ - -storetype PKCS12 \ - -storepass "$keystore_password" \ - -alias "$key_alias" \ - >/dev/null 2>&1; then - rm -f "$keystore_path" -fi - -if [[ ! -f "$keystore_path" ]]; then - keytool -genkeypair \ - -alias "$key_alias" \ - -keyalg RSA \ - -keysize 2048 \ - -dname "CN=solmara-esignet-relay" \ - -validity 825 \ - -storetype PKCS12 \ - -keystore "$keystore_path" \ - -storepass "$keystore_password" \ - -keypass "$key_password" \ - -noprompt -fi - -exec /home/mosip/configure_start.sh "$@" diff --git a/scripts/test_gen_secrets.py b/scripts/test_gen_secrets.py index ae37f05..1ee72b1 100644 --- a/scripts/test_gen_secrets.py +++ b/scripts/test_gen_secrets.py @@ -23,12 +23,6 @@ def test_p256_key_is_private_and_thumbprint_bound(self) -> None: self.assertIn("d", jwk) self.assertEqual(len(jwk["kid"]), 43) - def test_rsa_key_is_private_and_thumbprint_bound(self) -> None: - jwk = json.loads(MODULE.rsa_jwk()) - self.assertEqual((jwk["kty"], jwk["alg"]), ("RSA", "RS256")) - self.assertEqual(set(jwk), {"kty", "alg", "n", "e", "d", "p", "q", "dp", "dq", "qi", "kid"}) - self.assertEqual(len(jwk["kid"]), 43) - def test_operator_material_is_create_only_and_per_cell(self) -> None: with tempfile.TemporaryDirectory() as temporary: previous = MODULE.LOCAL @@ -44,9 +38,10 @@ def test_operator_material_is_create_only_and_per_cell(self) -> None: client_id = MODULE.LOCAL / "cells/cra/secrets/cra-pension-evidence-client-id" self.assertEqual(client_id.read_bytes(), b"cra-pension-evidence") self.assertEqual(client_id.stat().st_mode & 0o777, 0o600) - esignet_key = MODULE.LOCAL / "cells/mint/clients/nia-esignet-rsa-client-key" - self.assertEqual(json.loads(esignet_key.read_text())["kty"], "RSA") - self.assertEqual(esignet_key.stat().st_mode & 0o777, 0o600) + demo_key = MODULE.LOCAL / "cells/mint/clients/solmara-demo-client-key" + self.assertEqual(json.loads(demo_key.read_text())["kty"], "EC") + self.assertEqual(demo_key.stat().st_mode & 0o777, 0o600) + self.assertFalse((MODULE.LOCAL / "cells/mint/clients/nia-esignet-rsa-client-key").exists()) finally: MODULE.LOCAL = previous @@ -67,23 +62,15 @@ def test_generated_environment_preserves_runtime_secrets(self) -> None: key: f"stable-{index}" for index, key in enumerate(MODULE.RANDOM_ENV_KEYS) } - existing["PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64"] = "stable-portal-key" operator = { - "NIA_ESIGNET_CLIENT_PRIVATE_JWK": "operator-jwk", "SOLMARA_EVIDENCE_CLIENT_KEY": "/operator/client-key", } - with ( - mock.patch.object(MODULE, "raw_key", side_effect=AssertionError("rotated")), - mock.patch.object( - MODULE, - "rsa_private_key_b64", - side_effect=AssertionError("rotated"), - ), - ): + with mock.patch.object(MODULE, "raw_key", side_effect=AssertionError("rotated")): values = MODULE.compose_environment_values(existing, operator) for key, value in existing.items(): self.assertEqual(values[key], value) - self.assertEqual(values["NIA_ESIGNET_CLIENT_PRIVATE_JWK"], "operator-jwk") + self.assertEqual(values["SOLMARA_EVIDENCE_CLIENT_KEY"], "/operator/client-key") + self.assertFalse([key for key in values if "ESIGNET" in key]) def test_generated_environment_rejects_duplicate_or_empty_values(self) -> None: with tempfile.TemporaryDirectory() as temporary: diff --git a/scripts/test_hosted_esignet_topology.py b/scripts/test_hosted_esignet_topology.py deleted file mode 100644 index 4a7e1d1..0000000 --- a/scripts/test_hosted_esignet_topology.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] -COMPOSE_PATH = ROOT / "compose.coolify.esignet.yaml" -CORE_COMPOSE_PATH = ROOT / "compose.coolify.yaml" -RENDERER = ROOT / "docker" / "esignet-ui" / "render-hosted-nginx.sh" -TEMPLATE = ROOT / "config" / "esignet" / "nginx-hosted.conf" -LOCAL_COMPOSE_PATH = ROOT / "compose.esignet.yaml" -LOCAL_TEMPLATE = ROOT / "config" / "esignet" / "nginx.conf" - - -class HostedEsignetTopologyTests(unittest.TestCase): - def test_mint_and_relay_dependency_origins_are_not_operator_overridable( - self, - ) -> None: - compose_text = "\n".join( - path.read_text(encoding="utf-8") - for path in (CORE_COMPOSE_PATH, COMPOSE_PATH) - ) - for variable in ( - "SOLMARA_MINT_PUBLIC_BASE_URL", - "SOLMARA_MINT_PUBLIC_HOST", - "SOLMARA_CRA_RELAY_PUBLIC_BASE_URL", - "SOLMARA_NIA_RELAY_PUBLIC_BASE_URL", - "SOLMARA_MOSD_RELAY_PUBLIC_BASE_URL", - "SOLMARA_SIPF_RELAY_PUBLIC_BASE_URL", - "SOLMARA_NAGDI_RELAY_PUBLIC_BASE_URL", - ): - self.assertNotIn(variable, compose_text) - - def test_compose_is_a_standalone_esignet_app(self) -> None: - compose = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8")) - services = compose["services"] - self.assertEqual( - set(services), - { - "esignet-database", - "esignet-redis", - "esignet", - "esignet_ui", - "esignet_edge", - "esignet-seed", - }, - ) - # The issuer origin is fronted by the proxy image, not by eSignet - # itself, so its discovery documents are reachable where the issuer - # says they are. - self.assertEqual(services["esignet_edge"]["image"], services["esignet_ui"]["image"]) - self.assertNotIn("solmara.lab.host", services["esignet"]["labels"]) - self.assertNotIn("portal", services) - self.assertEqual( - services["esignet"]["environment"]["REGISTRY_MINT_TOKEN_ENDPOINT"], - "https://mint-authority-cells.solmara.registrystack.org/token", - ) - self.assertEqual( - services["esignet"]["environment"]["REGISTRY_RELAY_BASE_URL"], - "https://nia-relay-authority-cells.solmara.registrystack.org", - ) - self.assertEqual( - services["esignet-seed"]["environment"][ - "ESIGNET_CLIENT_REDIRECT_URIS_JSON" - ], - '["${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback"]', - ) - - def test_renderer_accepts_only_dns_hosts_and_preserves_security_headers( - self, - ) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - output = Path(temporary_directory) / "nginx.conf" - environment = os.environ | { - "SOLMARA_ESIGNET_PUBLIC_HOST": "login-authority-cells.example.org", - "SOLMARA_ESIGNET_UI_PUBLIC_HOST": "login-ui-authority-cells.example.org", - } - subprocess.run( - [str(RENDERER), str(TEMPLATE), str(output)], - check=True, - env=environment, - capture_output=True, - text=True, - ) - rendered = output.read_text(encoding="utf-8") - self.assertNotIn("__ESIGNET_", rendered) - self.assertIn( - "proxy_set_header Host login-authority-cells.example.org;", rendered - ) - self.assertIn( - "proxy_set_header X-Forwarded-Host login-authority-cells.example.org;", - rendered, - ) - self.assertIn("default-src 'none'", rendered) - self.assertIn( - "connect-src 'self' https://login-authority-cells.example.org https://login-ui-authority-cells.example.org;", - rendered, - ) - - def test_renderer_rejects_directive_injection(self) -> None: - invalid_hosts = ( - "login.example.org;return 200", - "login.example.org/path", - "login.example.org example.net", - "UPPER.example.org", - "localhost", - ) - for invalid_host in invalid_hosts: - with ( - self.subTest(invalid_host=invalid_host), - tempfile.TemporaryDirectory() as directory, - ): - result = subprocess.run( - [str(RENDERER), str(TEMPLATE), str(Path(directory) / "nginx.conf")], - env=os.environ - | { - "SOLMARA_ESIGNET_PUBLIC_HOST": invalid_host, - "SOLMARA_ESIGNET_UI_PUBLIC_HOST": "ui.example.org", - }, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 78) - self.assertEqual( - result.stderr, - "eSignet hosted nginx host configuration is invalid\n", - ) - self.assertNotIn(invalid_host, result.stderr) - - def test_renderer_passes_through_a_template_without_host_placeholders(self) -> None: - environment = { - key: value - for key, value in os.environ.items() - if key - not in ("SOLMARA_ESIGNET_PUBLIC_HOST", "SOLMARA_ESIGNET_UI_PUBLIC_HOST") - } - with tempfile.TemporaryDirectory() as temporary_directory: - output = Path(temporary_directory) / "nginx.conf" - result = subprocess.run( - [str(RENDERER), str(LOCAL_TEMPLATE), str(output)], - env=environment, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - output.read_text(encoding="utf-8"), - LOCAL_TEMPLATE.read_text(encoding="utf-8"), - ) - - def test_renderer_rejects_absent_hosts_when_the_template_has_placeholders( - self, - ) -> None: - environment = { - key: value - for key, value in os.environ.items() - if key - not in ("SOLMARA_ESIGNET_PUBLIC_HOST", "SOLMARA_ESIGNET_UI_PUBLIC_HOST") - } - with tempfile.TemporaryDirectory() as temporary_directory: - result = subprocess.run( - [ - str(RENDERER), - str(TEMPLATE), - str(Path(temporary_directory) / "nginx.conf"), - ], - env=environment, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 78) - self.assertEqual( - result.stderr, - "eSignet hosted nginx host configuration is invalid\n", - ) - - def test_local_esignet_ui_services_build_a_placeholder_free_template(self) -> None: - compose = yaml.safe_load(LOCAL_COMPOSE_PATH.read_text(encoding="utf-8")) - services = compose["services"] - for name in ("esignet-ui", "esignet-edge"): - with self.subTest(service=name): - template = ROOT / services[name]["build"]["args"]["ESIGNET_NGINX_CONF"] - self.assertNotIn( - "__ESIGNET_", template.read_text(encoding="utf-8") - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_hosted_image_manifest.py b/scripts/test_hosted_image_manifest.py index 8da7b08..05ad1f9 100644 --- a/scripts/test_hosted_image_manifest.py +++ b/scripts/test_hosted_image_manifest.py @@ -23,10 +23,6 @@ "SOLMARA_SCENARIO_RUNNER_IMAGE": "7" * 64, "SOLMARA_HOME_IMAGE": "8" * 64, "SOLMARA_PORTAL_IMAGE": "9" * 64, - "SOLMARA_ESIGNET_RELAY_IMAGE": "a" * 64, - "SOLMARA_ESIGNET_POSTGRES_IMAGE": "b" * 64, - "SOLMARA_ESIGNET_UI_IMAGE": "c" * 64, - "SOLMARA_ESIGNET_SEED_IMAGE": "d" * 64, } @@ -49,29 +45,11 @@ def setUp(self) -> None: key: self.module.expected_reference(key, digest) for key, digest in DIGESTS.items() } - core = [ - key - for key in self.module.EXPECTED_KEYS - if not key.startswith("SOLMARA_ESIGNET_") - ] - esignet = [ - key - for key in self.module.EXPECTED_KEYS - if key.startswith("SOLMARA_ESIGNET_") - ] (self.root / "compose.hosted.yaml").write_text( "services:\n" + "".join( f" {index}:\n image: ${{" + key + ":?required}\n" - for index, key in enumerate(core) - ), - encoding="utf-8", - ) - (self.root / "compose.coolify.esignet.yaml").write_text( - "services:\n" - + "".join( - f" {index}:\n image: ${{" + key + ":?required}\n" - for index, key in enumerate(esignet) + for index, key in enumerate(self.module.EXPECTED_KEYS) ), encoding="utf-8", ) @@ -243,9 +221,7 @@ def test_release_workflow_generates_validates_and_uploads_the_manifest( upload_index = names.index("Upload Coolify image manifest") self.assertLess(provisioner_index, generate_index) self.assertLess(signer_index, generate_index) - self.assertGreater( - generate_index, names.index("Build and push eSignet seed image") - ) + self.assertGreater(generate_index, names.index("Build and push programme portal")) self.assertGreater(upload_index, generate_index) provisioner = steps[provisioner_index] diff --git a/scripts/test_hosted_provisioning_topology.py b/scripts/test_hosted_provisioning_topology.py index dabc851..63fd4c9 100644 --- a/scripts/test_hosted_provisioning_topology.py +++ b/scripts/test_hosted_provisioning_topology.py @@ -64,8 +64,7 @@ "nagdi": {"nagdi-voucher-evidence", "nagdi-livestock-evidence"}, } MINT_CLIENTS = { - *(client for clients in EVIDENCE_CLIENTS.values() for client in clients), - "nia-esignet", + client for clients in EVIDENCE_CLIENTS.values() for client in clients } TARGET_PROVISIONERS = { *(f"{authority}-relay-provisioner" for authority in RELAYS), @@ -252,10 +251,10 @@ def test_mint_client_public_keys_use_provisioner_contract_names(self) -> None: "sipf-survivor-evidence", "nagdi-voucher-evidence", "nagdi-livestock-evidence", - "nia-esignet", ) for client in clients: self.assertIn(f"/tmp/solmara-provisioning/{client}-public.jwk", targets) + self.assertNotIn("/tmp/solmara-provisioning/nia-esignet-public.jwk", targets) self.assertIn( "/tmp/solmara-provisioning/solmara-demo-client-public.jwk", targets ) diff --git a/scripts/test_image_pins.py b/scripts/test_image_pins.py index 109df3b..2c4a14d 100644 --- a/scripts/test_image_pins.py +++ b/scripts/test_image_pins.py @@ -46,8 +46,6 @@ def setUp(self) -> None: with (self.root / "versions.env").open("a", encoding="utf-8") as versions: for key in ( "PYTHON_STATIC_IMAGE", "NODE_BUILD_IMAGE", "UV_BUILD_IMAGE", - "ESIGNET_REDIS_IMAGE", "ESIGNET_BASE_IMAGE", - "ESIGNET_UI_IMAGE", "ESIGNET_POSTGRES_IMAGE", ): versions.write(f"{key}=example.invalid/image@sha256:{'6' * 64}\n") diff --git a/scripts/test_provision_hosted_runtime.py b/scripts/test_provision_hosted_runtime.py index a50c833..156d02e 100644 --- a/scripts/test_provision_hosted_runtime.py +++ b/scripts/test_provision_hosted_runtime.py @@ -621,7 +621,8 @@ def test_mint_writes_only_audit_secret_and_public_client_registrations( path.read_text() for path in runtime.rglob("*") if path.is_file() ) self.assertNotIn(private["d"], emitted) - self.assertEqual(len(list((runtime / "clients").glob("*.yaml"))), 9) + self.assertEqual(len(list((runtime / "clients").glob("*.yaml"))), 8) + self.assertFalse((runtime / "clients" / "nia-esignet.yaml").exists()) authored = yaml.safe_load( (assets / "mint" / "mint.yaml").read_text(encoding="utf-8") ) diff --git a/scripts/test_registry_stack_release_pin.py b/scripts/test_registry_stack_release_pin.py index 7aa2489..8dade4a 100644 --- a/scripts/test_registry_stack_release_pin.py +++ b/scripts/test_registry_stack_release_pin.py @@ -11,27 +11,15 @@ SPEC.loader.exec_module(MODULE) -def authenticator_values() -> dict[str, str]: - base = "https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/" - return { - "ESIGNET_AUTHENTICATOR_VERSION": "0.2.0", - "ESIGNET_AUTHENTICATOR_RELEASE_URL": "https://github.com/jeremi/esignet-relay-authenticator/releases/tag/v0.2.0", - "ESIGNET_AUTHENTICATOR_JAR_URL": base + "esignet-relay-authenticator-0.2.0.jar", - "ESIGNET_AUTHENTICATOR_JAR_SHA256": "e" * 64, - "ESIGNET_AUTHENTICATOR_CHECKSUM_URL": base + "esignet-relay-authenticator-0.2.0.jar.sha256", - } - - class ReleasePinTests(unittest.TestCase): def test_older_release_is_rejected_in_favour_of_coherent_release(self) -> None: - values = {"REGISTRY_STACK_REQUIRED_VERSION": "0.21.0", **authenticator_values()} + values = {"REGISTRY_STACK_REQUIRED_VERSION": "0.21.0"} self.assertIn("must be 0.23.0", MODULE.validate(values, require_public=False)[0]) def test_missing_public_digest_is_an_explicit_blocker(self) -> None: values = { "REGISTRY_STACK_REQUIRED_VERSION": "0.23.0", "REGISTRY_STACK_SOURCE_REF": "v0.23.0", - **authenticator_values(), } self.assertEqual(MODULE.validate(values, require_public=False), []) self.assertIn("promotion is blocked", MODULE.validate(values, require_public=True)[0]) @@ -44,7 +32,6 @@ def test_digest_must_be_exact(self) -> None: "REGISTRY_STACK_RELEASE_RELAY_DIGEST": "a" * 64, "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_URL": "https://github.com/registrystack/registry-stack/releases/download/v0.23.0/relayctl-v0.23.0-linux-amd64", "REGISTRY_STACK_RELEASE_RELAYCTL_ASSET_SHA256": "d" * 64, - **authenticator_values(), } self.assertEqual(MODULE.validate(values, require_public=True), []) values["REGISTRY_STACK_RELEASE_RELAY_DIGEST"] = "sha256:" + "a" * 64 @@ -54,7 +41,6 @@ def test_public_source_ref_must_bind_the_release_tag(self) -> None: values = { "REGISTRY_STACK_REQUIRED_VERSION": "0.23.0", "REGISTRY_STACK_SOURCE_REF": "main", - **authenticator_values(), } self.assertIn( "REGISTRY_STACK_SOURCE_REF must be v0.23.0", diff --git a/scripts/test_runtime_topology.py b/scripts/test_runtime_topology.py index 6f96a84..7a24eb2 100644 --- a/scripts/test_runtime_topology.py +++ b/scripts/test_runtime_topology.py @@ -22,7 +22,7 @@ class RuntimeTopologyTests(unittest.TestCase): def test_local_up_recreates_immutable_runtime_consumers(self) -> None: justfile = (SCRIPT.parents[1] / "justfile").read_text() - for recipe in ("up: prepare", "up-esignet: prepare"): + for recipe in ("up: prepare",): start = justfile.index(recipe) command = justfile[start:].splitlines()[1] self.assertIn("docker compose", command) @@ -739,104 +739,28 @@ def test_hosted_programme_services_receive_only_their_application_secrets( self.assertNotIn("SOLMARA_EVIDENCE_CLIENT_KEY", environment, service_name) self.assertNotIn("CHILD_BENEFIT_FEDERATOR_TOKEN", environment, service_name) - def test_hosted_esignet_is_standalone_and_core_portal_owns_login_config( - self, - ) -> None: - esignet_path = SCRIPT.parents[1] / "compose.coolify.esignet.yaml" - esignet = yaml.safe_load(esignet_path.read_text(encoding="utf-8")) - services = esignet["services"] - self.assertEqual( - set(services), - { - "esignet-database", - "esignet-redis", - "esignet", - "esignet_ui", - "esignet_edge", - "esignet-seed", - }, - ) - self.assertNotIn("portal", services) - - core = yaml.safe_load( - (SCRIPT.parents[1] / "compose.coolify.yaml").read_text(encoding="utf-8") - ) - portal = core["services"]["portal"]["environment"] - expected = { - "PORTAL_AUTH_PROVIDER": "${PORTAL_AUTH_PROVIDER:-mock}", - "PORTAL_SECURE_COOKIES": "true", - "PORTAL_ESIGNET_CLIENT_ID": "${PORTAL_ESIGNET_CLIENT_ID:-solmara-portal}", - "PORTAL_ESIGNET_CLIENT_KEY_ID": "${PORTAL_ESIGNET_CLIENT_KEY_ID:-solmara-portal-key-1}", - "PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64": "${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:-}", - "PORTAL_ESIGNET_ISSUER": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}", - "PORTAL_ESIGNET_AUTHORIZATION_ENDPOINT": "${SOLMARA_ESIGNET_UI_PUBLIC_BASE_URL:-https://esignet-ui.solmara.registrystack.org}/authorize", - "PORTAL_ESIGNET_TOKEN_ENDPOINT": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token", - "PORTAL_ESIGNET_CLIENT_ASSERTION_AUDIENCE": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oauth/v2/token", - "PORTAL_ESIGNET_USERINFO_ENDPOINT": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}/v1/esignet/oidc/userinfo", - "PORTAL_ESIGNET_REDIRECT_URI": "${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback", - "PORTAL_ESIGNET_SCOPE": "openid profile", - "PORTAL_ESIGNET_SUBJECT_CLAIM": "sub", - } - for key, value in expected.items(): - self.assertEqual(portal[key], value) + def test_portal_login_is_mock_only_and_no_esignet_profile_remains(self) -> None: + root = SCRIPT.parents[1] + for name in ("compose.yaml", "compose.coolify.yaml"): + core = yaml.safe_load((root / name).read_text(encoding="utf-8")) + portal = core["services"]["portal"]["environment"] + self.assertEqual( + [ + key + for key in portal + if key.startswith(("PORTAL_ESIGNET_", "PORTAL_AUTH_")) + ], + [], + name, + ) + for name in ("compose.esignet.yaml", "compose.coolify.esignet.yaml"): + self.assertFalse((root / name).exists(), name) + for path in sorted(root.glob("compose*.yaml")): + self.assertNotIn("esignet", path.read_text(encoding="utf-8").lower(), path.name) + justfile = (root / "justfile").read_text(encoding="utf-8") + self.assertNotIn("esignet", justfile.lower()) - private_key_value = "${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:-}" - self.assertEqual( - { - service_name - for service_name, service in core["services"].items() - if service.get("environment", {}).get( - "PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64" - ) - == private_key_value - }, - {"portal"}, - ) - self.assertEqual( - services["esignet-seed"]["environment"]["ESIGNET_CLIENT_PRIVATE_KEY_B64"], - "${PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64:?required}", - ) - self.assertNotIn("NIA_ESIGNET_CLIENT_PRIVATE_JWK", portal) - - def test_hosted_esignet_seeder_runs_once(self) -> None: - """Coolify gives a service that declares no restart policy - `unless-stopped`, so a one-shot that exits on success is started again - forever. Every other one-shot in the fleet says `no`; the seeder must - too.""" - esignet_path = SCRIPT.parents[1] / "compose.coolify.esignet.yaml" - esignet = yaml.safe_load(esignet_path.read_text(encoding="utf-8")) - self.assertEqual(esignet["services"]["esignet-seed"].get("restart"), "no") - - def test_hosted_esignet_publishes_discovery_at_its_issuer_root(self) -> None: - """eSignet declares its issuer as the bare public origin but the Spring - service answers only under `/v1/esignet`, so routing the public host - straight at it leaves `{issuer}/.well-known/openid-configuration` and the - RFC 8414 authorization-server document unserved. The UI image is a - host-agnostic reverse proxy that publishes both, so the public host - belongs to an edge instance of it and the service stays unrouted. - - Both proxies carry underscore names because Coolify accepts a routed - service only under its compose key but resolves it after rewriting "-" - to "_", so a hyphenated name is stored where routing never reads.""" - esignet_path = SCRIPT.parents[1] / "compose.coolify.esignet.yaml" - services = yaml.safe_load(esignet_path.read_text(encoding="utf-8"))["services"] - edge = services["esignet_edge"] - self.assertEqual(edge["image"], services["esignet_ui"]["image"]) - self.assertEqual( - edge["labels"]["solmara.lab.host"], - "${SOLMARA_ESIGNET_PUBLIC_HOST:-esignet.solmara.registrystack.org}", - ) - self.assertNotIn("solmara.lab.host", services["esignet"].get("labels") or {}) - routed = [ - name - for name, service in services.items() - if "solmara.lab.host" in (service.get("labels") or {}) - ] - self.assertEqual(sorted(routed), ["esignet_edge", "esignet_ui"]) - for name in routed: - self.assertNotIn("-", name) - - def test_bruno_workspace_covers_only_the_eight_governed_v2_lookups(self) -> None: + def test_bruno_workspace_covers_only_the_seven_governed_v2_lookups(self) -> None: relay_requests = SCRIPT.parents[1] / "requests/registry-lab/50 - Relay V2" rendered = "\n".join( path.read_text(encoding="utf-8") @@ -845,7 +769,6 @@ def test_bruno_workspace_covers_only_the_eight_governed_v2_lookups(self) -> None expected = { "/v2/resources/civil-person/lookups/death-by-uin", "/v2/resources/civil-person/lookups/citizen-link-by-uin", - "/v2/resources/population-person/lookups/esignet-userinfo", "/v2/resources/beneficiary-enrolment/lookups/by-uin", "/v2/resources/pension-payment/lookups/by-pensioner-uin", "/v2/resources/survivor-case/lookups/by-spouse-uin", @@ -855,6 +778,7 @@ def test_bruno_workspace_covers_only_the_eight_governed_v2_lookups(self) -> None self.assertEqual(len(list(relay_requests.glob("*.bru"))), len(expected)) for path in expected: self.assertIn(path, rendered) + self.assertNotIn("esignet", rendered.lower()) self.assertNotIn("/records", rendered) self.assertNotIn("/search", rendered) self.assertNotIn("Data-Purpose", rendered) diff --git a/scripts/test_smoke_esignet.py b/scripts/test_smoke_esignet.py deleted file mode 100644 index ea194a2..0000000 --- a/scripts/test_smoke_esignet.py +++ /dev/null @@ -1,221 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -import sys -import tempfile -import threading -import unittest -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any -from unittest import mock - -import yaml - - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "scripts" / "smoke-esignet.py" -SPEC = importlib.util.spec_from_file_location("smoke_esignet", SCRIPT) -smoke_esignet = importlib.util.module_from_spec(SPEC) -assert SPEC and SPEC.loader -sys.modules["smoke_esignet"] = smoke_esignet -SPEC.loader.exec_module(smoke_esignet) - - -class StubServer: - def __init__(self, routes: dict[tuple[str, str], Any]) -> None: - self.routes = routes - self.server: ThreadingHTTPServer | None = None - self.thread: threading.Thread | None = None - self.seen_headers: dict[str, str] = {} - self.seen_body: dict[str, Any] | None = None - - def __enter__(self) -> "StubServer": - outer = self - - class Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - self._handle("GET") - - def do_POST(self) -> None: - length = int(self.headers.get("content-length", "0")) - outer.seen_body = json.loads(self.rfile.read(length).decode("utf-8")) - outer.seen_headers = { - key.lower(): value for key, value in self.headers.items() - } - self._handle("POST") - - def _handle(self, method: str) -> None: - route = outer.routes.get((method, self.path)) - if route is None: - self.send_error(HTTPStatus.NOT_FOUND) - return - status, payload = route - data = json.dumps(payload).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def log_message(self, fmt: str, *args: object) -> None: - return - - self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() - return self - - def __exit__(self, *args: object) -> None: - assert self.server is not None - self.server.shutdown() - self.server.server_close() - assert self.thread is not None - self.thread.join(timeout=5) - - @property - def url(self) -> str: - assert self.server is not None - host, port = self.server.server_address - return f"http://{host}:{port}" - - -class SmokeEsignetTests(unittest.TestCase): - def test_local_and_hosted_profiles_use_the_v020_authenticator_contract(self) -> None: - local = yaml.safe_load((ROOT / "compose.esignet.yaml").read_text()) - hosted = yaml.safe_load((ROOT / "compose.coolify.esignet.yaml").read_text()) - local_env = local["services"]["esignet"]["environment"] - hosted_env = hosted["services"]["esignet"]["environment"] - self.assertEqual( - local["services"]["portal"]["environment"]["PORTAL_ESIGNET_SUBJECT_CLAIM"], - "sub", - ) - - for environment in (local_env, hosted_env): - self.assertIn("REGISTRY_MINT_PRIVATE_JWK", environment) - self.assertNotIn("REGISTRY_MINT_CLIENT_PRIVATE_JWK", environment) - self.assertEqual( - environment["MOSIP_ESIGNET_DATABASE_URL"], - "jdbc:postgresql://esignet-database:5432/mosip_esignet?currentSchema=esignet", - ) - self.assertEqual( - environment["MOSIP_ESIGNET_INTEGRATION_AUTHENTICATOR"], - "RelayAuthenticationService", - ) - self.assertIn("esignet-relay-authenticator.jar", environment["plugin_name_env"]) - self.assertEqual(environment["REGISTRY_ESIGNET_ACCOUNT_CHECK_CLAIMS"], "individualId") - scope_claims = environment["MOSIP_ESIGNET_OPENID_SCOPE_CLAIMS"] - self.assertNotIn("'name'", scope_claims) - for claim in ( - "'given_name'", - "'family_name'", - "'gender'", - "'birthdate'", - "'individual_id'", - ): - self.assertIn(claim, scope_claims) - self.assertEqual( - environment["REGISTRY_RELAY_DEFAULT_CLAIMS"], - "individualId,givenName,familyName,birthdate,gender", - ) - claim_map = json.loads(environment["SPRING_APPLICATION_JSON"]) - self.assertEqual( - claim_map["registry"]["esignet"]["claim-map"], - { - "sub": "$$psut", - "individual_id": "individualId", - "given_name": "givenName", - "family_name": "familyName", - "birthdate": "birthdate", - "gender": "gender", - }, - ) - - self.assertEqual( - local_env["REGISTRY_MINT_TOKEN_ENDPOINT"], - "https://mint.solmara.registrystack.org/token", - ) - self.assertEqual( - hosted_env["REGISTRY_MINT_TOKEN_ENDPOINT"], - "https://mint-authority-cells.solmara.registrystack.org/token", - ) - self.assertEqual( - hosted_env["REGISTRY_RELAY_BASE_URL"], - "https://nia-relay-authority-cells.solmara.registrystack.org", - ) - - mint = yaml.safe_load((ROOT / "evidence" / "mint.yaml").read_text()) - self.assertEqual(mint["clientAssertion"]["algorithms"], ["ES256", "RS256"]) - - def test_browser_smoke_has_only_fixed_sanitized_output(self) -> None: - source = (ROOT / "scripts" / "smoke-esignet-login.mjs").read_text() - self.assertEqual(source.count("console.log("), 1) - self.assertIn("console.log('smoke-esignet-login: PASS')", source) - self.assertNotIn("page.title()", source) - - # The smoke drives the portal e2e sign-in helper rather than its own copy - # of the login, so the guard follows the code: nothing the helper reads - # off eSignet may reach the smoke's output, whether printed or thrown. - helper = (ROOT / "portal" / "e2e" / "support" / "auth.ts").read_text() - self.assertNotIn("console.log(", helper) - self.assertNotIn("errorCode", helper) - self.assertNotIn("page.title()", helper) - self.assertIn("getByRole('checkbox', { name: 'voluntary_claims' })", helper) - self.assertIn("allClaims.check({ force: true })", helper) - # eSignet refuses authorization while it is still starting, so the helper - # keeps reissuing the request for as long as the smoke used to. - self.assertIn("PROVIDER_TIMEOUT_MS = 60_000", helper) - - seed_source = (ROOT / "scripts" / "seed-esignet.py").read_text() - self.assertNotIn("Local static OTP", seed_source) - self.assertNotIn("Demo subject available", seed_source) - - def test_load_env_file_handles_quoted_values(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / ".env" - path.write_text( - "TOKEN='secret value'\nEMPTY=\n# comment\n", encoding="utf-8" - ) - self.assertEqual( - smoke_esignet.load_env_file(path), - {"TOKEN": "secret value", "EMPTY": ""}, - ) - - def test_discovery_requires_root_and_mosip_paths_to_share_issuer(self) -> None: - with StubServer({}) as server: - issuer_doc = { - "issuer": server.url, - "token_endpoint": f"{server.url}/v1/esignet/oauth/v2/token", - } - server.routes.update( - { - ("GET", "/v1/esignet/oidc/.well-known/openid-configuration"): (200, issuer_doc), - ("GET", "/.well-known/openid-configuration"): (200, issuer_doc), - ("GET", "/.well-known/oauth-authorization-server"): (200, issuer_doc), - } - ) - targets = smoke_esignet.SmokeTargets(server.url, server.url) - smoke_esignet.check_esignet_discovery(targets, timeout=2) - - def test_main_does_not_require_a_static_relay_credential(self) -> None: - with mock.patch.object(smoke_esignet, "check_esignet_discovery") as discovery: - self.assertEqual( - smoke_esignet.main( - [ - "--env-file", - "/missing", - "--esignet-url", - "http://esignet.test", - "--esignet-ui-url", - "http://esignet-ui.test", - ] - ), - 0, - ) - discovery.assert_called_once() - - -if __name__ == "__main__": - unittest.main() diff --git a/versions.env b/versions.env index bc1e182..b8802bd 100644 --- a/versions.env +++ b/versions.env @@ -21,15 +21,6 @@ NODE_BUILD_IMAGE=node@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a UV_BUILD_IMAGE=ghcr.io/astral-sh/uv@sha256:440fd6477af86a2f1b38080c539f1672cd22acb1b1a47e321dba5158ab08864d VOLUME_INIT_IMAGE=busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662 LOCAL_EDGE_IMAGE=docker.io/library/caddy@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d -ESIGNET_REDIS_IMAGE=redis@sha256:b99ffd0554dc8d300230b9d1b9f2a129a6abf595bf8589883beb980ed1feae3d -ESIGNET_BASE_IMAGE=mosipid/esignet-with-plugins@sha256:47fffdb5a45198b29885a533841129877a7385a12bcb6020c0f6d4335477be39 -ESIGNET_UI_IMAGE=mosipid/oidc-ui@sha256:8a2a6839b4e22be6c967dabc6308190c165c54604a778c2d9b1aae8091db93e7 -ESIGNET_POSTGRES_IMAGE=postgres@sha256:9479eac93922431e8a512b016d7362ae264f4ba139f840891b51f02931e450db -ESIGNET_AUTHENTICATOR_VERSION=0.2.0 -ESIGNET_AUTHENTICATOR_RELEASE_URL=https://github.com/jeremi/esignet-relay-authenticator/releases/tag/v0.2.0 -ESIGNET_AUTHENTICATOR_JAR_URL=https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/esignet-relay-authenticator-0.2.0.jar -ESIGNET_AUTHENTICATOR_JAR_SHA256=2c36901acb990d3002b5ff7f691a3878f30ca1e21a350c12e54f71b8940da5a8 -ESIGNET_AUTHENTICATOR_CHECKSUM_URL=https://github.com/jeremi/esignet-relay-authenticator/releases/download/v0.2.0/esignet-relay-authenticator-0.2.0.jar.sha256 SOLMARA_SCENARIO_RUNNER_IMAGE=solmara-lab-scenario-runner:local SOLMARA_CHILD_BENEFIT_FEDERATOR_IMAGE=solmara-lab-scenario-runner:local From bd408d63da7be026804f8b7ed9482b1bb51cca4d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Nov 2026 00:37:02 +0700 Subject: [PATCH 2/2] chore: unpublish the retired eSignet Relay workload key The nia-esignet workload client no longer exists, so its public key should not stay in the published Relay workload JWKS. Signed-off-by: Jeremi Joslin --- metadata/public/.well-known/jwks.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/metadata/public/.well-known/jwks.json b/metadata/public/.well-known/jwks.json index 3c0658a..cd76f95 100644 --- a/metadata/public/.well-known/jwks.json +++ b/metadata/public/.well-known/jwks.json @@ -14,13 +14,6 @@ "kty": "OKP", "x": "zAgQ4qvveNcLrj-YGFKzMVp-KTQL47RFkHr0x1xR0e4" }, - { - "alg": "EdDSA", - "crv": "Ed25519", - "kid": "solmara-nia-esignet-relay-workload-key-1", - "kty": "OKP", - "x": "isAltfG5GvqFX5DZmj9NNFfiNDJVvhNtHpyQtQIWI-o" - }, { "alg": "EdDSA", "crv": "Ed25519",