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/.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", 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