From be2e481a579180e0e890bbd3ed50ce47c1ff0db0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 9 Sep 2026 17:15:26 +0700 Subject: [PATCH 1/3] Integrate native eSignet BREG candidate and verified Portal OIDC Signed-off-by: Jeremi Joslin --- .env.example | 4 +- .gitattributes | 1 + .github/workflows/release-candidate.yml | 23 +- README.md | 17 +- breg/.gitignore | 1 + breg/README.md | 138 +++++++ breg/dev.py | 180 +++++++++ breg/generate-seeds.py | 55 +++ breg/population/dev-clients.yaml | 175 +++++++++ breg/population/registry.yaml | 140 +++++++ breg/population/tests/journeys.yaml | 61 ++++ compose.coolify.esignet.yaml | 199 +++++----- compose.coolify.yaml | 9 +- compose.esignet-fixture.yaml | 181 +++++++++ compose.esignet.yaml | 243 ++++++------ config/esignet/init.sql | 125 +------ config/esignet/nginx-hosted.conf | 62 +--- config/esignet/nginx.conf | 61 +--- docker/esignet-relay/Dockerfile | 30 +- docker/esignet-ui/Dockerfile | 39 +- docker/esignet-ui/default-locale.patch | 12 + docker/esignet-ui/hosted-entrypoint.sh | 8 +- docs/changelog.md | 19 + docs/esignet.md | 164 +++++++-- justfile | 18 +- portal/e2e/support/auth.ts | 93 ++--- portal/package.json | 1 + portal/pnpm-lock.yaml | 21 ++ portal/src/lib/server/esignet.test.ts | 336 +++++++++++------ portal/src/lib/server/esignet.ts | 345 +++++++----------- portal/src/routes/auth/callback/+server.ts | 11 +- portal/src/routes/auth/login/+server.ts | 12 +- scripts/check-coolify-compose.sh | 5 +- scripts/check-image-pins.py | 2 +- scripts/check-registry-stack-release-pin.py | 31 +- scripts/esignet-protocol-proof.mjs | 385 ++++++++++++++++++++ scripts/gen-secrets.py | 10 +- scripts/seed-esignet.py | 204 ++++++----- scripts/smoke-esignet-login.mjs | 3 + scripts/smoke-esignet.py | 78 ++-- scripts/start-esignet-relay.sh | 55 +-- scripts/test-esignet-protocol-proof.mjs | 38 ++ scripts/test_hosted_esignet_topology.py | 49 ++- scripts/test_image_pins.py | 15 +- scripts/test_registry_stack_release_pin.py | 11 +- scripts/test_runtime_topology.py | 10 +- scripts/test_seed_esignet.py | 86 +++++ scripts/test_smoke_esignet.py | 143 +++----- versions.env | 18 +- 49 files changed, 2670 insertions(+), 1257 deletions(-) create mode 100644 .gitattributes create mode 100644 breg/.gitignore create mode 100644 breg/README.md create mode 100644 breg/dev.py create mode 100644 breg/generate-seeds.py create mode 100644 breg/population/dev-clients.yaml create mode 100644 breg/population/registry.yaml create mode 100644 breg/population/tests/journeys.yaml create mode 100644 compose.esignet-fixture.yaml create mode 100644 docker/esignet-ui/default-locale.patch create mode 100644 scripts/esignet-protocol-proof.mjs create mode 100644 scripts/test-esignet-protocol-proof.mjs create mode 100644 scripts/test_seed_esignet.py diff --git a/.env.example b/.env.example index 7e12e99..071e2ae 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,7 @@ 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= +SOLMARA_ESIGNET_V2_KEYSTORE_PASSWORD= PORTAL_ESIGNET_CLIENT_ID=solmara-portal PORTAL_ESIGNET_CLIENT_KEY_ID=solmara-portal-key-1 PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24e115f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.patch -whitespace diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index c14e754..3d7a03b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -7,6 +7,10 @@ on: description: Tag for Solmara-owned images. Defaults to the workflow commit SHA. required: false + esignet_native_image: + description: Verified native eSignet v2 provider image, repository@sha256 digest. + required: true + permissions: contents: read packages: write @@ -16,12 +20,16 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 env: + ESIGNET_NATIVE_IMAGE: ${{ inputs.esignet_native_image }} SOLMARA_IMAGE_REGISTRY: ghcr.io/registrystack SOLMARA_IMAGE_TAG: ${{ inputs.solmara_image_tag || github.sha }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false + - name: Require a digest-pinned native provider input + run: | + [[ "$ESIGNET_NATIVE_IMAGE" =~ ^[^[:space:]]+@sha256:[0-9a-f]{64}$ ]] - name: Read immutable Registry Stack source identity id: registry-stack run: | @@ -33,8 +41,8 @@ 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 \ + ESIGNET_POSTGRES_IMAGE ESIGNET_NGINX_IMAGE NODE_BUILD_IMAGE \ + ESIGNET_SOURCE_COMMIT ESIGNET_SOURCE_ARCHIVE_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" @@ -388,7 +396,7 @@ 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 + - name: Build and push native eSignet BREG provider image id: esignet_relay uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: @@ -398,9 +406,7 @@ jobs: 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 }} + ESIGNET_CANDIDATE_IMAGE=${{ env.ESIGNET_NATIVE_IMAGE }} - name: Build and push isolated eSignet database id: esignet_postgres uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 @@ -422,7 +428,10 @@ jobs: push: true tags: ${{ env.SOLMARA_IMAGE_REGISTRY }}/solmara-lab-esignet-ui:${{ env.SOLMARA_IMAGE_TAG }} build-args: | - ESIGNET_UI_IMAGE=${{ env.ESIGNET_UI_IMAGE }} + NODE_BUILD_IMAGE=${{ env.NODE_BUILD_IMAGE }} + ESIGNET_NGINX_IMAGE=${{ env.ESIGNET_NGINX_IMAGE }} + ESIGNET_SOURCE_COMMIT=${{ env.ESIGNET_SOURCE_COMMIT }} + ESIGNET_SOURCE_ARCHIVE_SHA256=${{ env.ESIGNET_SOURCE_ARCHIVE_SHA256 }} ESIGNET_NGINX_CONF=config/esignet/nginx-hosted.conf - name: Build and push eSignet seed image id: esignet_seed diff --git a/README.md b/README.md index 2a74eb8..faaed3f 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ 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. The optional eSignet profile +now reads a separate governed BREG population fixture; NIA Evidence continues +to read its own extract. ## Release prerequisite @@ -30,10 +31,12 @@ 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 -fallback is accepted. +The optional eSignet profile uses the native `0.3.0` BREG provider candidate +with pinned eSignet `2.0.0-beta.1` source and its matching UI. Build and run the +isolated identity journey using [the eSignet guide](docs/esignet.md). It needs +matching native BREG/Mint tools supporting `bregctl dev export-client`; the +existing v0.23.0 Evidence/Relay release pin does not provide that development +workflow. Hosted image references remain required immutable digests. ## Quick start @@ -72,7 +75,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 | retained `population-person/esignet-userinfo` fixture; native eSignet uses BREG | | 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` | diff --git a/breg/.gitignore b/breg/.gitignore new file mode 100644 index 0000000..b48792e --- /dev/null +++ b/breg/.gitignore @@ -0,0 +1 @@ +**/.breg/ diff --git a/breg/README.md b/breg/README.md new file mode 100644 index 0000000..b0dc256 --- /dev/null +++ b/breg/README.md @@ -0,0 +1,138 @@ +# Synthetic population registry for eSignet 2 + +This local fixture runs an authored Base Registry Engine project over its own +PostgreSQL database. Registry Mint issues OAuth access tokens to separate +operator and eSignet clients. It uses the native `bregctl dev` lifecycle and +installed matching `bregctl`, `breg` and `mint` binaries, without a Registry Stack +source checkout. The tools must support `bregctl dev export-client`. + +The fixture contains the first twelve canonical synthetic citizens from +`ministries/interior-population/fixtures/population_person.csv` and one explicit +inactive negative case. It is an isolated authentication test population, not a +replacement for the ministry's thousand-record scenario database. The model is +ordinary registry configuration; no Rust domain types or special runtime routes +are required. + +## Start and verify + +Install the matching native Registry Stack toolchain on `PATH`, start Docker, +and generate the lab's ignored secrets with `uv run scripts/gen-secrets.py`. +Then, from the repository root: + +```sh +uv run python breg/generate-seeds.py --check +bregctl check breg/population +uv run python breg/dev.py start +uv run python breg/dev.py verify +``` + +### Build a pinned native toolchain + +When an installed toolchain lacks `dev export-client`, build all three tools +together. The following uses Registry Stack source commit +`2710bf5163c08f9699e486a05e46603184229155`, whose native lifecycle includes the +required command, and its pinned Rust 1.95.0 toolchain and Cargo lockfile. Use +fresh directories outside the lab checkout: + +```sh +git clone https://github.com/registrystack/registry-stack.git registry-stack-breg-tools +cd registry-stack-breg-tools +git checkout --detach 2710bf5163c08f9699e486a05e46603184229155 +CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ + cargo build --locked -p registry-breg --features registry-breg/runtime \ + -p registry-bregctl -p registry-mint --bins +mkdir -p ../breg-tools/bin +install -m 755 target/debug/breg target/debug/bregctl target/debug/mint ../breg-tools/bin/ +export PATH="$(cd ../breg-tools/bin && pwd):$PATH" +bregctl dev export-client --help +``` + +This pins build inputs; it does not promise identical binary bytes across host +toolchains. Return to the lab and run the checks above against a fresh fixture +before relying on that build. This exact source pin was built on macOS arm64 +with Rust 1.95.0 and passed all thirteen live fixture checks. A coordinated +native stop/start preserved record identifiers, revisions, field values, +package revision and client credentials while switching to those binaries. +Normal startup uses the installed binaries only. Stop an existing fixture +normally before changing its binary set; never remove its data to change tools. + +The wrapper calls `bregctl dev breg/population --breg-port 18190 --mint-port +18191 --database-port 55449`. Native startup rehearses the authored journeys +against real PostgreSQL, activates the compiled package, starts the services, +and creates the explicit seeds through authenticated BREG HTTP requests. + +It exports only the `esignet` client's private ES256 JWK into the owner-only, +ignored `config/evidence/local/esignet-v2/` directory. The provider configuration +references `mint-private.jwk`, `psut-secret` and `static-otp` under its container +mount `/etc/registry-esignet`. Mint's token endpoint transport address is +`http://host.docker.internal:18191/token`; its client assertion audience remains +`http://127.0.0.1:18191/token`. The private key's `kid` comes from the JWK. +The lab's generated static OTP is explicitly enabled for this synthetic fixture. + +BREG listens on `http://127.0.0.1:18190`; containers use +`http://host.docker.internal:18190`. Docker must support reaching host loopback +services through that address, as OrbStack does. The wrapper's port options +apply only to first start; the native lifecycle preserves retained ports and +refuses conflicting authored inputs. It never resets another service. + +The live verification obtains fresh tokens without writing or printing them. +It checks the exact minimum account projection and a narrower consent +projection, concealed missing/inactive identities and unknown selectors, +withheld fields, denied source list/get/create, anonymous and invalid bearer +refusals, and rejection of a duplicate UIN by the separate operator role. + +```sh +uv run python breg/dev.py stop +``` + +Stop preserves this fixture's database, credentials, and seed checkpoints. +Restart reuses them. Do not remove retained state to apply model changes. Follow +the native governed package lifecycle or create a separate disposable project. +Regenerate committed seeds explicitly with `uv run python breg/generate-seeds.py` +before a fresh fixture's first start; changing seeds after startup does not +overwrite retained citizens. + +## Governed HTTP contract + +The eSignet client has only the `lookup` operation, the `by-uin` selector, and +six readable fields: `uin`, `status`, `givenName`, `familyName`, `birthdate`, and +`gender`. The registry enforces the unique UIN constraint. Its row boundary +compares `status` with Mint's fixed `registry_identity_status: active` claim. +The caller-provided UIN is a selector input, never an authorization grant. +The source has no list, get, create, or patch grant. `operatorNote` remains +operator-only. The operator's unrestricted row finding is intentional for +maintenance and seeding; its credential is never exported to eSignet. + +After the challenge has been verified, the provider checks only: + +```http +POST /v1/records/population:lookup?accessProfile=esignet-source&%24select=uin%2Cstatus +Authorization: Bearer +Content-Type: application/json + +{"selector":"by-uin","values":{"uin":"2300010248"}} +``` + +The result is a Registry Record envelope. Actual facts are exclusively in +`data.domainData`, here exactly `{"uin":"2300010248","status":"active"}`. +For consented claims the provider requests the intersection of requested claim +fields with its provisioned fields using `$select`. It never falls back to +enumerating records. `givenName` and `familyName` are HTTP names for authored +logical fields `given-name` and `family-name`. + +The portal requests `individual_id` explicitly as an essential consented claim. +It maps to the governed `uin` field for business-record correlation. The OIDC +`sub` remains the pairwise pseudonymous subject token and never substitutes for +that identifier. Display names use the separate `given_name` and `family_name` +claims; this fixture does not invent a stored full-name field. + +A missing identity, inactive identity, or unresolved selector produces +`404 lookup.unresolved`; denied profile or operation produces concealed +`404 resource.not_found`. A rejected presented token produces +`401 authentication.refused`. The provider collapses concealed subject failures +and does not expose upstream differences. UIN uniqueness prevents an ambiguous +stored match, and BREG also fails unresolved lookup closed. + +Native `tests/journeys.yaml` rehearses create, exact lookup, and missing lookup. +`breg/dev.py verify` exercises the wider HTTP refusal boundary, including routes +that cannot appear as authorized operations in a native journey fixture. diff --git a/breg/dev.py b/breg/dev.py new file mode 100644 index 0000000..7846f69 --- /dev/null +++ b/breg/dev.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Drive the native, retained synthetic BREG identity fixture without printing secrets.""" + +import argparse +import json +import os +from pathlib import Path +import subprocess +import urllib.error +import urllib.parse +import urllib.request +import uuid + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +PROJECT = ROOT / "breg/population" +SECRETS = ROOT / "config/evidence/local/esignet-v2" +FIELDS = ["uin", "status", "givenName", "familyName", "birthdate", "gender"] + + +def command(*args): + result = subprocess.run(args, text=True, capture_output=True, check=False) + if result.returncode: + # Native failures can include paths to private diagnostic files. Do not + # forward subprocess output, which may be a token on partial failure. + raise RuntimeError(f"{args[0]} {args[1]} failed; inspect native private diagnostics") + return result.stdout + + +def ready(args): + return json.loads(command( + args.bregctl, "dev", str(PROJECT), "--breg-port", str(args.breg_port), + "--mint-port", str(args.mint_port), "--database-port", str(args.database_port), + "--format", "json", + )) + + +def export_provider(args, report): + SECRETS.mkdir(parents=True, exist_ok=True, mode=0o700) + if SECRETS.is_symlink() or SECRETS.stat().st_mode & 0o077: + raise RuntimeError("provider secret directory must be an owner-only ordinary directory") + exported = json.loads(command( + args.bregctl, "dev", "export-client", str(PROJECT), "--client", "esignet", + "--client-id-file", str(SECRETS / "mint-client-id"), + "--assertion-key-file", str(SECRETS / "mint-private.jwk"), "--format", "json", + )) + if not exported.get("ok"): + raise RuntimeError("native client export failed") + container_root = "/etc/registry-esignet" + config = { + "subject_id_type": "uin", "psut_secret_file": f"{container_root}/psut-secret", + "breg": {"base_url": f"http://{args.provider_host}:{args.breg_port}", + "route": "population", "selector": "by-uin", "selector_field": "uin", + "access_profile": "esignet-source", "provisioned_fields": FIELDS, + "account_check_fields": ["uin", "status"]}, + "mint": {"token_endpoint": f"http://{args.provider_host}:{args.mint_port}/token", + "assertion_audience": report["tokenEndpoint"], + "client_id": (SECRETS / "mint-client-id").read_text().strip(), + "private_key_file": f"{container_root}/mint-private.jwk"}, + "claim_map": {"sub": "$psut", "individual_id": "uin", "given_name": "givenName", "family_name": "familyName", + "birthdate": "birthdate", "gender": "gender"}, + "http": {"allow_insecure_http": True, "timeout_seconds": 10, + "max_response_bytes": 1048576}, + "demo": {"static_otp_enabled": True, "static_otp_file": f"{container_root}/static-otp"}, + } + path = SECRETS / "registry.yaml" + if path.is_symlink(): + raise RuntimeError("provider config must be an ordinary file") + fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as output: + yaml.safe_dump(config, output, sort_keys=False) + print(f"Native registry ready; provider configuration: {path}") + + +def token(args, report, client): + identity = next(item for item in report["clients"] if item["id"] == client) + return command(args.mint, "token", "--url", report["tokenEndpoint"], "--client-id", + Path(identity["clientIdFile"]).read_text().strip(), "--key", + identity["assertionKeyFile"]).strip() + + +def request(base, credential, method, suffix, body=None): + headers = {"Accept": "application/json"} + if credential: + headers["Authorization"] = "Bearer " + credential + if body is not None: + headers["Content-Type"] = "application/json" + if method == "POST" and ":lookup" not in suffix: + headers["Idempotency-Key"] = str(uuid.uuid4()) + req = urllib.request.Request(base + suffix, method=method, headers=headers, + data=None if body is None else json.dumps(body).encode()) + # Match the native loop's direct, no-proxy HTTP behavior. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + response = opener.open(req, timeout=10) + except urllib.error.HTTPError as exc: + response = exc + with response: + return response.status, json.load(response) + + +def verify(args, report): + source = token(args, report, "esignet") + base = report["bregUrl"] + checks = 0 + + def lookup(uin, fields, credential=source, selector="by-uin"): + query = urllib.parse.urlencode({"accessProfile": "esignet-source", "$select": fields}) + return request(base, credential, "POST", "/v1/records/population:lookup?" + query, + {"selector": selector, "values": {"uin": uin}}) + + status, document = lookup("2300010248", "uin,status") + assert status == 200 and document["data"]["domainData"] == { + "uin": "2300010248", "status": "active"}, "minimal account projection failed" + record_id = document["data"]["recordIdentifier"] + checks += 1 + status, document = lookup("2300010248", "givenName,familyName") + assert status == 200 and document["data"]["domainData"] == { + "givenName": "Mateo", "familyName": "Santos"}, "consented projection failed" + checks += 1 + status, document = lookup("2300010248", "uin") + assert status == 200 and document["data"]["domainData"] == { + "uin": "2300010248"}, "consented business identifier projection failed" + checks += 1 + for identity, selector in [("2399999998", "by-uin"), ("2399999999", "by-uin"), + ("2300010248", "unknown-selector")]: + status, document = lookup(identity, "uin,status", selector=selector) + assert status == 404 and document["code"] == "lookup.unresolved", "concealment failed" + checks += 1 + status, document = lookup("2300010248", "operatorNote") + assert status == 404 and document["code"] == "resource.not_found", "hidden field disclosed" + checks += 1 + for method, suffix, body in [ + ("GET", "", None), ("GET", "/" + urllib.parse.quote(record_id, safe=""), None), + ("POST", "", {"uin": "2300010248"}), + ]: + status, document = request(base, source, method, + "/v1/records/population" + suffix + "?accessProfile=esignet-source", body) + assert status == 404 and document["code"] == "resource.not_found", "source grant exceeded" + checks += 1 + status, document = lookup("2300010248", "uin,status", credential="") + assert status == 404 and document["code"] == "resource.not_found", "anonymous lookup allowed" + checks += 1 + status, document = lookup("2300010248", "uin,status", credential="invalid") + assert status == 401 and document["code"] == "authentication.refused", "invalid bearer allowed" + checks += 1 + operator = token(args, report, "operator") + first = yaml.safe_load((PROJECT / "dev-clients.yaml").read_text())["seed"][0]["data"] + status, document = request(base, operator, "POST", + "/v1/records/population?accessProfile=operator", {"data": first}) + assert status == 409 and document["code"] == "mutation.conflict", "unique UIN not enforced" + checks += 1 + print(f"PASS: {checks} real Mint/BREG/PostgreSQL boundary checks; no subject data or tokens logged") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["start", "verify", "stop"]) + parser.add_argument("--bregctl", default="bregctl") + parser.add_argument("--mint", default="mint") + parser.add_argument("--breg-port", type=int, default=18190) + parser.add_argument("--mint-port", type=int, default=18191) + parser.add_argument("--database-port", type=int, default=55449) + parser.add_argument("--provider-host", default="host.docker.internal") + args = parser.parse_args() + if args.action == "stop": + command(args.bregctl, "dev", "stop", str(PROJECT), "--format", "json") + print("Stopped fixture services; retained database and credentials preserved") + return + report = ready(args) + if args.action == "start": + export_provider(args, report) + else: + verify(args, report) + + +if __name__ == "__main__": + main() diff --git a/breg/generate-seeds.py b/breg/generate-seeds.py new file mode 100644 index 0000000..e86bfdc --- /dev/null +++ b/breg/generate-seeds.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Regenerate the bounded eSignet identity fixture from canonical synthetic citizens.""" + +import argparse +import csv +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +CLIENTS = ROOT / "breg/population/dev-clients.yaml" + + +def generated_seeds(): + with (ROOT / "ministries/interior-population/fixtures/population_person.csv").open() as source: + rows = list(csv.DictReader(source))[:12] + seeds = [] + for index, row in enumerate(rows, 1): + seeds.append({ + "id": f"person-{index:02d}", "client": "operator", "entity": "person", + "accessProfile": "operator", "data": { + "uin": row["uin"], "status": row["identity_status"], + "givenName": row["given_name"], "familyName": row["family_name"], + "birthdate": row["birth_date"], "gender": row["sex"], + "operatorNote": "Synthetic seed; not disclosed to authentication provider.", + }, + }) + seeds.append({ + "id": "inactive-person", "client": "operator", "entity": "person", + "accessProfile": "operator", "data": { + "uin": "2399999999", "status": "inactive", "givenName": "Inactive", + "familyName": "Fixture", "birthdate": "1980-01-01", "gender": "unknown", + }, + }) + return seeds + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + clients = yaml.safe_load(CLIENTS.read_text()) + expected = generated_seeds() + if args.check: + if clients["seed"] != expected: + raise SystemExit("BREG seeds differ from canonical synthetic citizens; regenerate explicitly") + print("BREG seeds match canonical synthetic citizens") + return + clients["seed"] = expected + CLIENTS.write_text(yaml.safe_dump(clients, sort_keys=False)) + + +if __name__ == "__main__": + main() diff --git a/breg/population/dev-clients.yaml b/breg/population/dev-clients.yaml new file mode 100644 index 0000000..60859d5 --- /dev/null +++ b/breg/population/dev-clients.yaml @@ -0,0 +1,175 @@ +version: 1 +clients: +- id: operator + accessProfiles: + - operator + scopes: + - registry:population:operate + claims: + registry_principal: solmara-population-operator + registry_purpose: population-maintenance +- id: esignet + accessProfiles: + - esignet-source + scopes: + - registry:population:lookup + claims: + registry_principal: solmara-esignet-provider + registry_purpose: identity-authentication + registry_identity_status: active +seed: +- id: person-01 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300010248' + status: active + givenName: Mateo + familyName: Santos + birthdate: '2022-03-14' + gender: male + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-02 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300018263' + status: active + givenName: Elena + familyName: Dela Cruz + birthdate: '1992-11-02' + gender: female + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-03 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300027390' + status: active + givenName: Luis + familyName: Okafor + birthdate: '1989-08-20' + gender: male + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-04 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300036523' + status: active + givenName: Hana + familyName: Aquino + birthdate: '2021-01-29' + gender: female + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-05 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300045650' + status: active + givenName: Priya + familyName: Mensah + birthdate: '1985-07-08' + gender: female + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-06 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300054788' + status: active + givenName: Tomas + familyName: Bello + birthdate: '2020-06-17' + gender: male + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-07 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300063915' + status: active + givenName: Joana + familyName: Bello + birthdate: '1985-09-10' + gender: female + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-08 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300073046' + status: active + givenName: Karim + familyName: Kone + birthdate: '2020-05-18' + gender: male + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-09 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300082172' + status: active + givenName: Aisha + familyName: Kone + birthdate: '1981-04-23' + gender: female + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-10 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300091305' + status: deceased + givenName: Esteban + familyName: Cruz + birthdate: '2019-12-12' + gender: male + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-11 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300100431' + status: active + givenName: Miriam + familyName: Cruz + birthdate: '1988-12-12' + gender: female + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: person-12 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300109568' + status: deceased + givenName: Rafael + familyName: Nkomo + birthdate: '1944-02-01' + gender: male + operatorNote: Synthetic seed; not disclosed to authentication provider. +- id: inactive-person + client: operator + entity: person + accessProfile: operator + data: + uin: '2399999999' + status: inactive + givenName: Inactive + familyName: Fixture + birthdate: '1980-01-01' + gender: unknown diff --git a/breg/population/registry.yaml b/breg/population/registry.yaml new file mode 100644 index 0000000..6be5cd2 --- /dev/null +++ b/breg/population/registry.yaml @@ -0,0 +1,140 @@ +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: solmara-population + canonicalBaseIri: https://id.registrystack.org/solmara/nia + version: 0.1.0 + defaultLanguage: en +package: + environment: local + instanceId: solmara-esignet-v2 + sequence: 1 + sourceRevision: solmara-esignet-v2-fixture-v1 +manifestProjection: + accessProfile: operator + classificationCeiling: internal + catalog: + baseUrl: https://nia.gov.solmara.example + title: Solmara synthetic population registry + publisher: + id: nia + name: National Identity Authority + publicService: + id: registry-service + title: Solmara population registry + datasets: + - id: registry + title: Synthetic population + owner: National Identity Authority + status: active + dataServices: + - id: registry-data + title: Population identity lookup + endpointUrl: https://nia.gov.solmara.example + servesDatasets: + - registry + distributions: [] +entities: +- id: person + primaryDataset: registry + route: population + mutationMode: mutable + classification: internal + fields: + - id: uin + type: string + required: true + classification: internal + minLength: 1 + maxLength: 160 + - id: status + type: string + required: true + classification: internal + minLength: 1 + maxLength: 160 + - id: given-name + type: string + required: true + classification: internal + minLength: 1 + maxLength: 160 + apiName: givenName + - id: family-name + type: string + required: true + classification: internal + minLength: 1 + maxLength: 160 + apiName: familyName + - id: birthdate + type: date + required: true + classification: internal + - id: gender + type: string + required: true + classification: internal + minLength: 1 + maxLength: 160 + - id: operator-note + apiName: operatorNote + type: string + classification: internal + maxLength: 200 + constraints: + - kind: unique + fields: + - uin + selectorProfiles: + - id: by-uin + fields: + - uin +accessProfiles: +- id: operator + default: true + principalClaim: registry_principal + requiredScopes: + - registry:population:operate + requiredPurposes: + - population-maintenance + grants: + - entity: person + operations: + - create + - get + - patch + readableFields: &id001 + - uin + - status + - given-name + - family-name + - birthdate + - gender + - operator-note + writableFields: *id001 + rowBoundaries: [] +- id: esignet-source + principalClaim: registry_principal + requiredScopes: + - registry:population:lookup + requiredPurposes: + - identity-authentication + grants: + - entity: person + operations: + - lookup + readableFields: + - uin + - status + - given-name + - family-name + - birthdate + - gender + lookups: + - selector: by-uin + valueOrigin: request + rowBoundaries: + - field: status + claim: registry_identity_status + operator: equals diff --git a/breg/population/tests/journeys.yaml b/breg/population/tests/journeys.yaml new file mode 100644 index 0000000..07c6f62 --- /dev/null +++ b/breg/population/tests/journeys.yaml @@ -0,0 +1,61 @@ +apiVersion: registry.registrystack.org/breg-journeys/v1 +journeys: +- id: identity-lookup-boundary + steps: + - id: operator-creates-person + entity: person + accessProfile: operator + claims: + principal: solmara-population-operator + purpose: population-maintenance + scopes: + - registry:population:operate + request: + operation: create + data: + uin: '2300000000' + status: active + given-name: Synthetic + family-name: Journey + birthdate: '1990-01-01' + gender: unknown + operator-note: Private operator fact + expect: + outcome: success + status: 201 + fields: + uin: '2300000000' + - id: source-exact-lookup + entity: person + accessProfile: esignet-source + claims: &id001 + principal: solmara-esignet-provider + purpose: identity-authentication + scopes: + - registry:population:lookup + directClaims: + registry_identity_status: active + request: + operation: lookup + selector: by-uin + values: + uin: '2300000000' + expect: + outcome: success + status: 200 + fields: + uin: '2300000000' + status: active + - id: source-unresolved + entity: person + accessProfile: esignet-source + claims: *id001 + request: + operation: lookup + selector: by-uin + values: + uin: '2399999998' + expect: + outcome: refusal + status: 404 + problemCode: lookup.unresolved diff --git a/compose.coolify.esignet.yaml b/compose.coolify.esignet.yaml index d2eb6ec..aa8d81a 100644 --- a/compose.coolify.esignet.yaml +++ b/compose.coolify.esignet.yaml @@ -1,140 +1,127 @@ -name: solmara-esignet-authority-cells - services: esignet-database: - image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:?set digest-pinned eSignet database image} + image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:?set a verified digest-pinned native v2 image} environment: POSTGRES_USER: esignet - POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?required} - volumes: [esignet-db-data:/var/lib/postgresql] + POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} + volumes: + - esignet-v2-db-data:/var/lib/postgresql healthcheck: - test: [CMD-SHELL, pg_isready -U esignet -d esignet] + 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] + image: ${ESIGNET_REDIS_IMAGE:?versions.env must pin eSignet Redis} + command: + - redis-server + volumes: + - esignet-v2-redis-data:/data healthcheck: - test: [CMD, redis-cli, ping] + test: + - CMD-SHELL + - redis-cli --raw COMMAND INFO GETDEL | grep -qx getdel interval: 10s timeout: 5s retries: 30 - esignet: - image: ${SOLMARA_ESIGNET_RELAY_IMAGE:?set digest-pinned eSignet image} - restart: unless-stopped - user: root + image: ${SOLMARA_ESIGNET_RELAY_IMAGE:?set a verified digest-pinned native v2 image} 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 + DATA_DIR: /home/mosip/data + PORT: '8080' + MOSIP_ESIGNET_AUTHN_PROVIDER: breg + MOSIP_ESIGNET_AUTH_FLOW_ID: flow-breg-otp + MOSIP_ESIGNET_HOST: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org} + MOSIP_ESIGNET_OIDC_UI_SCHEME: https + MOSIP_ESIGNET_OIDC_UI_HOSTNAME: ${SOLMARA_ESIGNET_UI_PUBLIC_HOST:-esignet-ui.solmara.registrystack.org} + MOSIP_ESIGNET_OIDC_UI_PORT: '443' + MOSIP_ESIGNET_OIDC_UI_LOGIN_PATH: /signin + DATABASE_HOST: esignet-database + DATABASE_PORT: '5432' + DATABASE_NAME: mosip_esignet + DATABASE_USERNAME: esignet + DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} + REDIS_HOST: esignet-redis + KEYMANAGER_DB_SCHEMA: esignet + KEYMANAGER_KEYSTORE_TYPE: PKCS12 + KEYMANAGER_PKCS12_FILE_PATH: /var/lib/esignet-keys/host.p12 + KEYMANAGER_PKCS12_PASSWORD: ${SOLMARA_ESIGNET_V2_KEYSTORE_PASSWORD:?run just gen-secrets} + KEYMANAGER_PKCS12_ALLOW_INSECURE_SOFTWARE_KEYSTORE: 'true' + KEYMANAGER_CERT_CN: Solmara synthetic identity demo + KEYMANAGER_CERT_O: Solmara Lab + KEYMANAGER_CERT_C: XS + REGISTRY_ESIGNET_CONFIG_FILE: /etc/registry-esignet/registry.yaml volumes: - - esignet-keystore:/home/mosip/keystore - - esignet-kyc-signing:/home/mosip/kyc-signing + - esignet-v2-keys:/var/lib/esignet-keys + - esignet-v2-config:/etc/registry-esignet:ro depends_on: - esignet-database: {condition: service_healthy} - esignet-redis: {condition: service_healthy} + esignet-database: + condition: service_healthy + esignet-redis: + condition: service_healthy + esignet-key-init: + condition: service_completed_successfully labels: solmara.rollout.slot: authority-cells - + esignet-seed: + image: ${SOLMARA_ESIGNET_SEED_IMAGE:?set a verified digest-pinned native v2 image} + environment: + 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_ADMIN_URL: http://esignet:8080 + ESIGNET_CLIENT_REDIRECT_URIS_JSON: '["${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback"]' + ESIGNET_REQUIRE_HTTPS_REDIRECTS: 'true' + entrypoint: + - seed-esignet.py + depends_on: + esignet-database: + condition: service_healthy + esignet: + condition: service_started + restart: 'no' + volumes: + - esignet-v2-seed-state:/var/lib/esignet-seed/state + esignet-key-init: + image: ${VOLUME_INIT_IMAGE:?versions.env must pin volume initializer} + user: 0:0 + command: + - sh + - -c + - set -eu; cp /source/* /config/; chown -R 65532:65532 /keys /config; chmod 0700 /keys /config; chmod 0600 /config/* + volumes: + - esignet-v2-keys:/keys + - ./config/evidence/local/esignet-v2:/source:ro + - esignet-v2-config:/config + restart: 'no' esignet_ui: - image: ${SOLMARA_ESIGNET_UI_IMAGE:?set digest-pinned eSignet UI image} + image: ${SOLMARA_ESIGNET_UI_IMAGE:?set a verified digest-pinned native v2 image} + depends_on: + esignet: + condition: service_started 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} + image: ${SOLMARA_ESIGNET_UI_IMAGE:?set a verified digest-pinned native v2 image} + depends_on: + esignet: + condition: service_started 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: + esignet-v2-db-data: null + esignet-v2-redis-data: null + esignet-v2-keys: null + esignet-v2-config: null + esignet-v2-seed-state: null +name: solmara-esignet-v2-authority-cells diff --git a/compose.coolify.yaml b/compose.coolify.yaml index 6865cf4..0f36cef 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -138,19 +138,16 @@ services: environment: <<: *hosted-evidence PORTAL_PROVIDER: live + ORIGIN: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org} 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 + PORTAL_ESIGNET_SCOPE: ${PORTAL_ESIGNET_SCOPE:-openid} + PORTAL_ESIGNET_SUBJECT_CLAIM: individual_id 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-fixture.yaml b/compose.esignet-fixture.yaml new file mode 100644 index 0000000..f3f278e --- /dev/null +++ b/compose.esignet-fixture.yaml @@ -0,0 +1,181 @@ +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-v2-db:local} + environment: + POSTGRES_USER: esignet + POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} + volumes: + - esignet-v2-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-v2-redis-data:/data + networks: + - runtime + healthcheck: + test: + - CMD-SHELL + - redis-cli --raw COMMAND INFO GETDEL | grep -qx getdel + interval: 10s + timeout: 5s + retries: 30 + esignet: + image: ${SOLMARA_ESIGNET_CANDIDATE_IMAGE:?build the native v2 candidate image} + environment: + DATA_DIR: /home/mosip/data + PORT: '8080' + MOSIP_ESIGNET_AUTHN_PROVIDER: breg + MOSIP_ESIGNET_AUTH_FLOW_ID: flow-breg-otp + MOSIP_ESIGNET_HOST: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308} + MOSIP_ESIGNET_OIDC_UI_SCHEME: http + MOSIP_ESIGNET_OIDC_UI_HOSTNAME: 127.0.0.1 + MOSIP_ESIGNET_OIDC_UI_PORT: ${SOLMARA_ESIGNET_UI_PORT:-4309} + MOSIP_ESIGNET_OIDC_UI_LOGIN_PATH: /signin + DATABASE_HOST: esignet-database + DATABASE_PORT: '5432' + DATABASE_NAME: mosip_esignet + DATABASE_USERNAME: esignet + DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} + REDIS_HOST: esignet-redis + KEYMANAGER_DB_SCHEMA: esignet + KEYMANAGER_KEYSTORE_TYPE: PKCS12 + KEYMANAGER_PKCS12_FILE_PATH: /var/lib/esignet-keys/host.p12 + KEYMANAGER_PKCS12_PASSWORD: ${SOLMARA_ESIGNET_V2_KEYSTORE_PASSWORD:?run just gen-secrets} + KEYMANAGER_PKCS12_ALLOW_INSECURE_SOFTWARE_KEYSTORE: 'true' + KEYMANAGER_CERT_CN: Solmara synthetic identity demo + KEYMANAGER_CERT_O: Solmara Lab + KEYMANAGER_CERT_C: XS + REGISTRY_ESIGNET_CONFIG_FILE: /etc/registry-esignet/registry.yaml + volumes: + - esignet-v2-keys:/var/lib/esignet-keys + - esignet-v2-config:/etc/registry-esignet:ro + networks: + - runtime + depends_on: + esignet-database: + condition: service_healthy + esignet-redis: + condition: service_healthy + esignet-key-init: + condition: service_completed_successfully + esignet-ui: + build: + context: . + dockerfile: docker/esignet-ui/Dockerfile + args: + NODE_BUILD_IMAGE: ${NODE_BUILD_IMAGE:?versions.env must pin Node} + ESIGNET_NGINX_IMAGE: ${ESIGNET_NGINX_IMAGE:?versions.env must pin nginx} + ESIGNET_SOURCE_COMMIT: ${ESIGNET_SOURCE_COMMIT:?required} + ESIGNET_SOURCE_ARCHIVE_SHA256: ${ESIGNET_SOURCE_ARCHIVE_SHA256:?required} + ESIGNET_NGINX_CONF: config/esignet/nginx.conf + image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-v2-ui:local} + ports: + - 127.0.0.1:${SOLMARA_ESIGNET_UI_PORT:-4309}:3000 + networks: + - runtime + depends_on: + esignet: + condition: service_started + esignet-edge: + build: + context: . + dockerfile: docker/esignet-ui/Dockerfile + args: + NODE_BUILD_IMAGE: ${NODE_BUILD_IMAGE:?versions.env must pin Node} + ESIGNET_NGINX_IMAGE: ${ESIGNET_NGINX_IMAGE:?versions.env must pin nginx} + ESIGNET_SOURCE_COMMIT: ${ESIGNET_SOURCE_COMMIT:?required} + ESIGNET_SOURCE_ARCHIVE_SHA256: ${ESIGNET_SOURCE_ARCHIVE_SHA256:?required} + ESIGNET_NGINX_CONF: config/esignet/nginx.conf + image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-v2-ui:local} + ports: + - 127.0.0.1:${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-v2-seed:local} + environment: + 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_ADMIN_URL: http://esignet:8080 + ESIGNET_CLIENT_REDIRECT_URIS_JSON: '["${SOLMARA_PORTAL_PUBLIC_BASE_URL:-http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}}/auth/callback"]' + entrypoint: + - seed-esignet.py + networks: + - runtime + depends_on: + esignet-database: + condition: service_healthy + esignet: + condition: service_started + volumes: + - esignet-v2-seed-state:/var/lib/esignet-seed/state + portal: + build: + context: ./portal + image: ${SOLMARA_ESIGNET_PORTAL_IMAGE:-solmara-lab-portal-esignet-v2:local} + environment: + ORIGIN: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}} + 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_REDIRECT_URI: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}}/auth/callback + PORTAL_ESIGNET_SUBJECT_CLAIM: individual_id + PORTAL_ESIGNET_TRANSPORT_ORIGIN: http://esignet:8080 + PORTAL_ESIGNET_ALLOW_HTTP: 'true' + HOST: 0.0.0.0 + PORT: '4000' + PORTAL_PROVIDER: mock + PORTAL_SESSION_SECRET: ${PORTAL_SESSION_SECRET:?run just gen-secrets} + ports: + - 127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}:4000 + networks: + - runtime + esignet-key-init: + image: ${VOLUME_INIT_IMAGE:?versions.env must pin volume initializer} + user: 0:0 + command: + - sh + - -c + - set -eu; cp /source/* /config/; chown -R 65532:65532 /keys /config; chmod 0700 /keys /config; chmod 0600 /config/* + volumes: + - esignet-v2-keys:/keys + - ./config/evidence/local/esignet-v2:/source:ro + - esignet-v2-config:/config + networks: + - runtime +volumes: + esignet-v2-db-data: null + esignet-v2-redis-data: null + esignet-v2-keys: null + esignet-v2-config: null + esignet-v2-seed-state: null +name: solmara-esignet-v2-fixture +networks: + runtime: {} diff --git a/compose.esignet.yaml b/compose.esignet.yaml index dd43ad8..37b2321 100644 --- a/compose.esignet.yaml +++ b/compose.esignet.yaml @@ -3,140 +3,165 @@ services: 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] + args: + POSTGRES_IMAGE: ${ESIGNET_POSTGRES_IMAGE:?versions.env must pin eSignet database image} + image: ${SOLMARA_ESIGNET_POSTGRES_IMAGE:-solmara-lab-esignet-v2-db:local} + environment: + POSTGRES_USER: esignet + POSTGRES_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} + volumes: + - esignet-v2-db-data:/var/lib/postgresql + networks: + - runtime healthcheck: - test: ["CMD-SHELL", "pg_isready -U esignet -d esignet"] + 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] + command: + - redis-server + volumes: + - esignet-v2-redis-data:/data + networks: + - runtime healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: + - CMD-SHELL + - redis-cli --raw COMMAND INFO GETDEL | grep -qx getdel interval: 10s timeout: 5s retries: 30 - esignet: + image: ${SOLMARA_ESIGNET_CANDIDATE_IMAGE:?build the native v2 candidate image} + environment: + DATA_DIR: /home/mosip/data + PORT: '8080' + MOSIP_ESIGNET_AUTHN_PROVIDER: breg + MOSIP_ESIGNET_AUTH_FLOW_ID: flow-breg-otp + MOSIP_ESIGNET_HOST: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-http://127.0.0.1:4308} + MOSIP_ESIGNET_OIDC_UI_SCHEME: http + MOSIP_ESIGNET_OIDC_UI_HOSTNAME: 127.0.0.1 + MOSIP_ESIGNET_OIDC_UI_PORT: ${SOLMARA_ESIGNET_UI_PORT:-4309} + MOSIP_ESIGNET_OIDC_UI_LOGIN_PATH: /signin + DATABASE_HOST: esignet-database + DATABASE_PORT: '5432' + DATABASE_NAME: mosip_esignet + DATABASE_USERNAME: esignet + DATABASE_PASSWORD: ${SOLMARA_ESIGNET_POSTGRES_PASSWORD:?run just gen-secrets} + REDIS_HOST: esignet-redis + KEYMANAGER_DB_SCHEMA: esignet + KEYMANAGER_KEYSTORE_TYPE: PKCS12 + KEYMANAGER_PKCS12_FILE_PATH: /var/lib/esignet-keys/host.p12 + KEYMANAGER_PKCS12_PASSWORD: ${SOLMARA_ESIGNET_V2_KEYSTORE_PASSWORD:?run just gen-secrets} + KEYMANAGER_PKCS12_ALLOW_INSECURE_SOFTWARE_KEYSTORE: 'true' + KEYMANAGER_CERT_CN: Solmara synthetic identity demo + KEYMANAGER_CERT_O: Solmara Lab + KEYMANAGER_CERT_C: XS + REGISTRY_ESIGNET_CONFIG_FILE: /etc/registry-esignet/registry.yaml + volumes: + - esignet-v2-keys:/var/lib/esignet-keys + - esignet-v2-config:/etc/registry-esignet:ro + networks: + - runtime + depends_on: + esignet-database: + condition: service_healthy + esignet-redis: + condition: service_healthy + esignet-key-init: + condition: service_completed_successfully + esignet-ui: build: context: . - dockerfile: docker/esignet-relay/Dockerfile + dockerfile: docker/esignet-ui/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}} - + NODE_BUILD_IMAGE: ${NODE_BUILD_IMAGE:?versions.env must pin Node} + ESIGNET_NGINX_IMAGE: ${ESIGNET_NGINX_IMAGE:?versions.env must pin nginx} + ESIGNET_SOURCE_COMMIT: ${ESIGNET_SOURCE_COMMIT:?required} + ESIGNET_SOURCE_ARCHIVE_SHA256: ${ESIGNET_SOURCE_ARCHIVE_SHA256:?required} + ESIGNET_NGINX_CONF: config/esignet/nginx.conf + image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-v2-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}} - + build: + context: . + dockerfile: docker/esignet-ui/Dockerfile + args: + NODE_BUILD_IMAGE: ${NODE_BUILD_IMAGE:?versions.env must pin Node} + ESIGNET_NGINX_IMAGE: ${ESIGNET_NGINX_IMAGE:?versions.env must pin nginx} + ESIGNET_SOURCE_COMMIT: ${ESIGNET_SOURCE_COMMIT:?required} + ESIGNET_SOURCE_ARCHIVE_SHA256: ${ESIGNET_SOURCE_ARCHIVE_SHA256:?required} + ESIGNET_NGINX_CONF: config/esignet/nginx.conf + image: ${SOLMARA_ESIGNET_UI_IMAGE:-solmara-lab-esignet-v2-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} + 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-v2-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}} - + ESIGNET_ADMIN_URL: http://esignet:8080 + ESIGNET_CLIENT_REDIRECT_URIS_JSON: '["${SOLMARA_PORTAL_PUBLIC_BASE_URL:-http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}}/auth/callback"]' + entrypoint: + - seed-esignet.py + networks: + - runtime + depends_on: + esignet-database: + condition: service_healthy + esignet: + condition: service_started + volumes: + - esignet-v2-seed-state:/var/lib/esignet-seed/state portal: environment: + ORIGIN: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}} 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 - + PORTAL_ESIGNET_REDIRECT_URI: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-http://127.0.0.1:${SOLMARA_PORTAL_PORT:-4300}}/auth/callback + PORTAL_ESIGNET_SUBJECT_CLAIM: individual_id + PORTAL_ESIGNET_TRANSPORT_ORIGIN: http://esignet:8080 + PORTAL_ESIGNET_ALLOW_HTTP: 'true' + esignet-key-init: + image: ${VOLUME_INIT_IMAGE:?versions.env must pin volume initializer} + user: 0:0 + command: + - sh + - -c + - set -eu; cp /source/* /config/; chown -R 65532:65532 /keys /config; chmod 0700 /keys /config; chmod 0600 /config/* + volumes: + - esignet-v2-keys:/keys + - ./config/evidence/local/esignet-v2:/source:ro + - esignet-v2-config:/config + networks: + - runtime volumes: - esignet-db-data: - esignet-keystore: - esignet-kyc-signing: - esignet-redis-data: + esignet-v2-db-data: null + esignet-v2-redis-data: null + esignet-v2-keys: null + esignet-v2-config: null + esignet-v2-seed-state: null diff --git a/config/esignet/init.sql b/config/esignet/init.sql index eb91703..260f6b9 100644 --- a/config/esignet/init.sql +++ b/config/esignet/init.sql @@ -1,9 +1,10 @@ CREATE DATABASE mosip_esignet; -CREATE DATABASE mosip_mockidentitysystem; + \connect mosip_esignet CREATE SCHEMA IF NOT EXISTS esignet; +ALTER DATABASE mosip_esignet SET search_path TO esignet, pg_catalog, public; CREATE TABLE IF NOT EXISTS esignet.ca_cert_store ( cert_id varchar(36) PRIMARY KEY, @@ -28,7 +29,7 @@ CREATE TABLE IF NOT EXISTS esignet.ca_cert_store ( ); CREATE TABLE IF NOT EXISTS esignet.client_detail ( - id varchar(100) PRIMARY KEY, + id varchar(100) CONSTRAINT pk_clntdtl_id PRIMARY KEY, name varchar(600) NOT NULL, rp_id varchar(100) NOT NULL, logo_uri varchar(2048) NOT NULL, @@ -36,7 +37,7 @@ CREATE TABLE IF NOT EXISTS esignet.client_detail ( 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, + public_key_hash varchar(128) NOT NULL CONSTRAINT uk_clntdtl_public_key_hash UNIQUE, enc_public_key varchar(1024), enc_public_key_hash varchar(128), enc_public_key_cert varchar(4000), @@ -145,8 +146,7 @@ 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), + ('BASE', 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) @@ -160,117 +160,6 @@ INSERT INTO esignet.server_profile ( ('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; +ALTER TABLE esignet.consent_detail ADD CONSTRAINT unique_client_token UNIQUE (client_id, psu_token); +CREATE UNIQUE INDEX key_alias_uni_ident ON esignet.key_alias (uni_ident); diff --git a/config/esignet/nginx-hosted.conf b/config/esignet/nginx-hosted.conf index b0fd9c0..ed2ecb7 100644 --- a/config/esignet/nginx-hosted.conf +++ b/config/esignet/nginx-hosted.conf @@ -6,8 +6,10 @@ events { } http { - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; + log_format identity_safe '$request_method $uri $status'; + access_log /dev/stdout identity_safe; + # Error-level proxy failures include query strings and Referer; safe access logs retain status. + error_log /dev/stderr crit; client_body_temp_path /tmp/nginx-client-body; proxy_temp_path /tmp/nginx-proxy; fastcgi_temp_path /tmp/nginx-fastcgi; @@ -18,6 +20,8 @@ http { listen 3000; server_name __ESIGNET_UI_PUBLIC_HOST__; server_tokens off; + resolver 127.0.0.11 valid=5s ipv6=off; + set $esignet_backend esignet:8080; root /usr/share/nginx/html; index index.html index.htm; @@ -28,64 +32,22 @@ http { 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; + # Routes from eSignet v2 df0d0e7 and its pinned Thunder engine. + # Client management, key administration and generic resource APIs stay private. + location ~ ^/(?:oauth2/(?:authorize|auth/callback|token|jwks|userinfo|par)|\.well-known/(?:openid-configuration|oauth-authorization-server)|flow/(?:execute|meta)|csrf/token|design/resolve|i18n/languages|i18n/translations)(?:/|$) { + proxy_pass http://$esignet_backend; 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 ~ ^/(?:client-mgmt|system-info|v1/esignet)(?:/|$) { + return 404; } location / { diff --git a/config/esignet/nginx.conf b/config/esignet/nginx.conf index e27cc60..7cdc77a 100644 --- a/config/esignet/nginx.conf +++ b/config/esignet/nginx.conf @@ -6,8 +6,10 @@ events { } http { - access_log /dev/stdout; - error_log /dev/stderr; + log_format identity_safe '$request_method $uri $status'; + access_log /dev/stdout identity_safe; + # Error-level proxy failures include query strings and Referer; safe access logs retain status. + error_log /dev/stderr crit; client_body_temp_path /tmp/nginx-client-body; proxy_temp_path /tmp/nginx-proxy; fastcgi_temp_path /tmp/nginx-fastcgi; @@ -18,6 +20,8 @@ http { listen 3000; server_name localhost; server_tokens off; + resolver 127.0.0.11 valid=5s ipv6=off; + set $esignet_backend esignet:8080; root /usr/share/nginx/html; index index.html index.htm; @@ -28,59 +32,22 @@ http { 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; + # Routes from eSignet v2 df0d0e7 and its pinned Thunder engine. + # Client management, key administration and generic resource APIs stay private. + location ~ ^/(?:oauth2/(?:authorize|auth/callback|token|jwks|userinfo|par)|\.well-known/(?:openid-configuration|oauth-authorization-server)|flow/(?:execute|meta)|csrf/token|design/resolve|i18n/languages|i18n/translations)(?:/|$) { + proxy_pass http://$esignet_backend; proxy_redirect off; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; 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 ~ ^/(?:client-mgmt|system-info|v1/esignet)(?:/|$) { + return 404; } location / { diff --git a/docker/esignet-relay/Dockerfile b/docker/esignet-relay/Dockerfile index f383312..d72b38a 100644 --- a/docker/esignet-relay/Dockerfile +++ b/docker/esignet-relay/Dockerfile @@ -1,26 +1,4 @@ -# 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"] +# Native provider image is built in the owning authenticator repository. +# Local candidate tags are explicit inputs; hosted publication requires a digest. +ARG ESIGNET_CANDIDATE_IMAGE +FROM ${ESIGNET_CANDIDATE_IMAGE} diff --git a/docker/esignet-ui/Dockerfile b/docker/esignet-ui/Dockerfile index 6d6b955..08e5aab 100644 --- a/docker/esignet-ui/Dockerfile +++ b/docker/esignet-ui/Dockerfile @@ -1,18 +1,27 @@ -ARG ESIGNET_UI_IMAGE=mosipid/oidc-ui@sha256:8a2a6839b4e22be6c967dabc6308190c165c54604a778c2d9b1aae8091db93e7 -FROM ${ESIGNET_UI_IMAGE} +# syntax=docker/dockerfile:1 +ARG NODE_BUILD_IMAGE +ARG ESIGNET_NGINX_IMAGE +FROM ${NODE_BUILD_IMAGE} AS ui-build +ARG ESIGNET_SOURCE_COMMIT +ARG ESIGNET_SOURCE_ARCHIVE_SHA256 +WORKDIR /src +ADD --checksum=sha256:${ESIGNET_SOURCE_ARCHIVE_SHA256} https://api.github.com/repos/mosip/esignet/tarball/${ESIGNET_SOURCE_COMMIT} /tmp/esignet.tar.gz +RUN tar -xzf /tmp/esignet.tar.gz --strip-components=1 && rm /tmp/esignet.tar.gz +COPY docker/esignet-ui/default-locale.patch /tmp/default-locale.patch +RUN apk add --no-cache git && git apply --check /tmp/default-locale.patch && git apply /tmp/default-locale.patch +WORKDIR /src/oidc-ui +RUN npm ci && VITE_API_URL= npm run build +FROM ${ESIGNET_NGINX_IMAGE} +ARG ESIGNET_SOURCE_COMMIT +LABEL org.opencontainers.image.revision=${ESIGNET_SOURCE_COMMIT} \ + org.opencontainers.image.version="2.0.0-beta.1" 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"] +COPY --from=ui-build /src/oidc-ui/dist /usr/share/nginx/html +COPY ${ESIGNET_NGINX_CONF} /etc/nginx/solmara.conf.template +COPY docker/esignet-ui/render-hosted-nginx.sh /usr/local/bin/render-hosted-nginx.sh +COPY docker/esignet-ui/hosted-entrypoint.sh /usr/local/bin/hosted-entrypoint.sh +RUN chmod 0555 /usr/local/bin/*nginx.sh /usr/local/bin/hosted-entrypoint.sh +USER 101:101 +ENTRYPOINT ["/usr/local/bin/hosted-entrypoint.sh"] CMD ["nginx", "-c", "/tmp/solmara-nginx.conf", "-g", "daemon off;"] diff --git a/docker/esignet-ui/default-locale.patch b/docker/esignet-ui/default-locale.patch new file mode 100644 index 0000000..42d40d9 --- /dev/null +++ b/docker/esignet-ui/default-locale.patch @@ -0,0 +1,12 @@ +diff --git a/oidc-ui/src/main.tsx b/oidc-ui/src/main.tsx +--- a/oidc-ui/src/main.tsx ++++ b/oidc-ui/src/main.tsx +@@ -16,7 +16,7 @@ const applicationId = searchParams.get("applicationId"); + // ui_locales (OIDC) is a space-separated, preference-ordered locale list; take + // the most preferred one and let the backend fall back to English if unsupported. + const uiLocales = searchParams.get("ui_locales"); +-const initialLanguage = uiLocales?.trim().split(/\s+/)[0] || undefined; ++const initialLanguage = uiLocales?.trim().split(/\s+/)[0] || "en"; + + const baseUrlRaw = import.meta.env.DEV + ? import.meta.env.VITE_API_URL diff --git a/docker/esignet-ui/hosted-entrypoint.sh b/docker/esignet-ui/hosted-entrypoint.sh index 4d6b2dd..aa260cb 100755 --- a/docker/esignet-ui/hosted-entrypoint.sh +++ b/docker/esignet-ui/hosted-entrypoint.sh @@ -1,8 +1,4 @@ #!/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 "$@" +/usr/local/bin/render-hosted-nginx.sh /etc/nginx/solmara.conf.template /tmp/solmara-nginx.conf +exec "$@" diff --git a/docs/changelog.md b/docs/changelog.md index a085554..e7e9d5d 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-09-09 Native eSignet 2 BREG candidate + +The optional identity profile now builds on eSignet `2.0.0-beta.1` and the +native `0.3.0` BREG provider candidate. Its isolated population registry uses +real PostgreSQL, a lookup-only Mint client and explicit field projections. +The OTP-only synthetic flow requests fresh consent on every login. Relay +integration is deferred. + +The Portal uses `openid-client` for private-key JWT authentication, PKCE and +validated ID-token/UserInfo handling. A separately consented business identifier +continues to bind programme requests; OIDC subjects remain pairwise. Service, +UI and bootstrap configuration use the pinned Go contracts, with separate new +state volumes and private administrative routes. Historical volumes and +published artifacts are preserved. + +See [the native eSignet journey](esignet.md) for a reproducible local candidate +build and startup. This preparation does not publish artifacts or change the +hosted deployment. + ## 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 index e8258cd..4eba8dc 100644 --- a/docs/esignet.md +++ b/docs/esignet.md @@ -1,24 +1,140 @@ -# 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. +# Native eSignet 2 with BREG + +The optional identity profile runs eSignet `v2.0.0-beta.1` at commit +`df0d0e771dae16eb2597b8e5b5dc65e70baa7f86`, its matching UI, and the native +`0.3.0` BREG provider candidate. BREG is the population system of record and +Mint authenticates the provider. Relay integration is deferred. + +The provider verifies the OTP before contacting Mint or BREG. It checks only +`uin,status`, then asks for fresh consent on every login. Only approved, mapped, +provisioned fields are read from BREG's `data.domainData`. The pairwise OIDC +`sub` is derived with an independent HMAC secret. The Portal separately requests +`individual_id` as an essential, consented business identifier so programme +requests continue to use the synthetic citizen's UIN. Refusing that claim does +not create a Portal session. + +## Build and start locally + +Prerequisites: Docker with Buildx, Go 1.26, Node with pnpm, uv, just, and matching +native `bregctl`, `breg` and `mint` tools supporting `bregctl dev export-client`. +The fixture uses the native toolchain's own PostgreSQL lifecycle. Put the +matching candidate binaries on `PATH` if the installed release lacks this +command. See [the BREG fixture](../breg/README.md) for its bounded seed population +and tool requirements. + +From the `esignet-relay-authenticator` checkout: + +```sh +GOTOOLCHAIN=go1.26.0 go test -race ./... +./integration/test.sh +./integration/build.sh +# Load an image for this machine, without publishing. +./integration/build.sh --load +``` + +The multiarchitecture archive is `dist/esignet-breg-candidate.oci.tar`. +The `--load` command loads the matching local architecture into Docker. Set +`SOLMARA_ESIGNET_CANDIDATE_IMAGE=esignet-relay-authenticator:0.3.0-candidate` in +the Lab's ignored `.env` after generating secrets. No registry publication is +needed for this journey. + +From this Lab checkout: + +```sh +just setup +uv run scripts/gen-secrets.py +uv run python breg/generate-seeds.py --check +just esignet-fixture-start +uv run python breg/dev.py verify +uv run scripts/smoke-esignet.py +node scripts/smoke-esignet-login.mjs +just esignet-protocol-proof +``` + +Open `http://127.0.0.1:4300`, select **Sign in with SolmaraID**, enter one of the +synthetic fixture identities (for example `2300018263`), and use the synthetic +OTP `111111` (the generated value is stored in ignored +`config/evidence/local/esignet-v2/static-otp`; `ESIGNET_DEMO_OTP` overrides the +browser test input if you changed it). Accept the +required identity claim and the optional given/family names you want to share. +The default Portal journey requests only these fields. The UI defaults to English +and preserves an explicitly supplied language preference. +The Portal validates the signed ID token and signed UserInfo before opening the +service catalogue. This isolated profile uses the Portal's mock programme +backend while its identity journey uses real eSignet, Mint, BREG and PostgreSQL. + +`just esignet-fixture-stop` stops only this profile and preserves its state. +Do not use a volume reset to upgrade it. The new database, Redis, provider-config +and host-key volumes use `esignet-v2-*` names; existing Java-era volumes stay +untouched. + +## Trust and deployment + +The provider's strict YAML is selected by `REGISTRY_ESIGNET_CONFIG_FILE`. +`breg/dev.py start` exports only the dedicated source client's credential and +writes ignored local configuration. Private files are mounted into a dedicated +configuration volume readable by the nonroot eSignet process. Portal client +assertion keys, provider-to-Mint keys, PSUT secrets, and eSignet signing keys +remain independent. Static OTP and the software host keystore are explicitly +synthetic-demo choices. The client registers only `mosip:idp:acr:static-code`; +it does not claim generated-code assurance. Production OTP delivery is outside +this candidate. + +The root discovery URL is `/.well-known/openid-configuration`; authorization, +token, keys and UserInfo endpoints are under `/oauth2`. The matching UI serves +`/signin`. Administrative client-management and bootstrap routes are accessible +only inside the container network. Public proxies exclude those routes and +avoid logging OAuth query strings. + +`compose.esignet.yaml` integrates the candidate with the full local Lab. +`compose.coolify.esignet.yaml` describes the matching hosted overlay, with +required immutable image references. Hosted configuration validation does not +publish artifacts or apply a cutover. Its operator must provision the BREG +endpoint and dedicated Mint client, use reachable service addresses, and supply +the provider's mounted files before any separately authorized deployment. + +## Troubleshooting and verification + +- If startup reports `configuration invalid` or `configuration YAML invalid`, + check the YAML field names, + mounted paths, source field inventory, and explicit demo verifier setting. + A missing production verifier fails startup. +- If client registration fails, verify that service and UI use the same pinned + source plus the BREG patch, and rerun the one-time private seeder. The pinned + client API requires UTC timestamps with milliseconds. It is not the old SQL + client schema. If existing registration evidence is missing or its key or relying + party differs, configure a fresh client ID **and a fresh signing key**, then + update both matching Portal values before seeding. The native host requires + each client's public key to be unique; changing only the ID is insufficient. + Preserve the private `esignet-v2-seed-state` volume with the database so routine + restarts can verify the existing registration. +- After upgrading the authored authentication flow, invalidate only its definition + cache and recreate the eSignet host. Redis retains the definition for up to a + day, and the host also caches its graph in memory; replacing the image alone + can continue to run the old flow. In this isolated profile the exact Redis key + is `esignet::runtime:esignet:flow:definition:flow-breg-otp`. Delete only that key + with the fixture's `redis-cli DEL`, then run Compose `up -d --no-deps + --force-recreate esignet`. Keep every other Redis key and database volume; + do not use `FLUSHDB`, `FLUSHALL`, or a volume reset. Pause active proof flows + before this upgrade and start a fresh login afterwards. +- For dependency errors, verify native BREG/Mint are running and that Docker can + reach host loopback via `host.docker.internal`. Mint assertion audience remains + the configured public token endpoint even when transport uses another address. +- Keep the same PSUT secret to preserve pairwise subjects across restart. Check + clocks and restart the login if its five-minute attribute context expires. +- For callback failure, verify exact issuer and redirect URI, Portal key/JWK + registration, and consent to the required business claim. Do not bypass JWT + validation or substitute an unverified identifier. +- Set adapter-node's `ORIGIN` to the canonical public Portal URL. The Compose + configurations derive it and the registered callback from the same + `SOLMARA_PORTAL_PUBLIC_BASE_URL`, with the configured local port as fallback. + Local HTTP must be explicit: without `ORIGIN`, adapter-node can infer HTTPS + and correctly fail the strict callback URI check for an HTTP fixture. Keep + the canonical origin configuration instead of trusting forwarded headers. + +Run `just lint`, `just test`, `just compose`, and the two eSignet smoke commands +for changed Lab boundaries. The provider's race and adapter suites cover nil +pre-consent probes, empty consent, state restoration, bounded Mint caching, +source refusal, cancellation and redaction. `just esignet-protocol-proof` exercises fresh +consent and the host's signed/encrypted UserInfo implementations with separate +synthetic clients. Full programme acceptance requires the full Lab services. diff --git a/justfile b/justfile index 280417d..275eb0b 100644 --- a/justfile +++ b/justfile @@ -54,7 +54,8 @@ 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_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_seed_esignet.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 + node --experimental-strip-types --test scripts/test-esignet-protocol-proof.mjs cd portal && pnpm test cd home && pnpm test @@ -63,6 +64,7 @@ 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 + docker compose -p solmara-esignet-v2-fixture --env-file versions.env --env-file .env -f compose.esignet-fixture.yaml config >/dev/null @scripts/check-hosted-compose.sh @scripts/check-coolify-compose.sh @@ -127,3 +129,17 @@ portal-live-e2e: home-live-e2e: cd home && SOLMARA_HOME_E2E_MODE=live PLAYWRIGHT_BASE_URL="http://127.0.0.1:${SOLMARA_HOME_PORT:-4301}" pnpm e2e + +# Native BREG/Mint use the owned local dev lifecycle; all eSignet state is separate. +esignet-fixture-start: + uv run scripts/gen-secrets.py + uv run breg/dev.py start + docker compose -p solmara-esignet-v2-fixture --env-file versions.env --env-file .env -f compose.esignet-fixture.yaml up -d --build + +esignet-fixture-stop: + docker compose -p solmara-esignet-v2-fixture --env-file versions.env --env-file .env -f compose.esignet-fixture.yaml down + uv run breg/dev.py stop + +# Real native host consent, signed UserInfo and encrypted UserInfo checks. +esignet-protocol-proof: + node --experimental-strip-types scripts/esignet-protocol-proof.mjs diff --git a/portal/e2e/support/auth.ts b/portal/e2e/support/auth.ts index da2a208..9179c11 100644 --- a/portal/e2e/support/auth.ts +++ b/portal/e2e/support/auth.ts @@ -20,7 +20,7 @@ const DEMO_SUBJECT = process.env.ESIGNET_DEMO_SUBJECT || '2300018263'; const DEMO_OTP = process.env.ESIGNET_DEMO_OTP || '111111'; const CATALOG_PATH = '/services'; -const ESIGNET_LOGIN_PATH = '/login'; +const ESIGNET_LOGIN_PATH = '/signin'; // 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 @@ -67,72 +67,79 @@ export async function beginSignIn(page: Page): Promise { 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 requestEsignetOtp(page); + await requireNoErrors(await verifyEsignetOtp(page), 'authenticate the demo identity'); + + // The engine renders consent on the same /signin route as the OTP form. + await grantEsignetConsent(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. +/** Reach the OTP challenge through the real engine flow. */ +export async function requestEsignetOtp(page: Page, subject = DEMO_SUBJECT): Promise { + await page.locator('input[name="username"]').fill(subject); + const [sendOtp] = await Promise.all([ + page.waitForResponse(isFlowResponse), + page.getByRole('button', { name: 'Send code', exact: true }).click() + ]); + await requireNoErrors(sendOtp, 'send the demo OTP'); +} + +/** Submit a challenge and return its response for positive and refusal proofs. */ +export async function verifyEsignetOtp(page: Page, otp = DEMO_OTP): Promise { + await enterOtp(page, otp); + const [authenticate] = await Promise.all([ + page.waitForResponse(isFlowResponse), + page.getByRole('button', { name: 'Verify', exact: true }).click() + ]); + return authenticate; +} + +function isFlowResponse(response: Response): boolean { + return new URL(response.url()).pathname.endsWith('/flow/execute') && response.request().method() === 'POST'; +} + +// The flow engine can return an application failure in a successful HTTP +// response. Keep identity data and provider error details out of smoke output. async function requireNoErrors(response: Response, step: string): Promise { - const body = (await response.json()) as { errors?: unknown[] }; - if (body.errors?.length) { + const body: unknown = await response.json(); + if (!response.ok() || !body || typeof body !== 'object' || + ('flowStatus' in body && body.flowStatus === 'ERROR') || + ('error' in body && body.error) || + ('errors' in body && Array.isArray(body.errors) && body.errors.length > 0)) { 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"]'); +// OTP_INPUT uses one input per digit. Fill each field directly when segmented. +async function enterOtp(page: Page, otp: string): Promise { + const digits = page.getByRole('textbox', { name: /^Verification code digit \d+$/ }); await digits.first().waitFor({ timeout: ATTEMPT_TIMEOUT_MS }); if ((await digits.count()) === 1) { - await digits.fill(DEMO_OTP); + await digits.fill(otp); return; } - for (const digit of DEMO_OTP) { - await digits.first().press(digit); - await page.waitForTimeout(100); + for (let index = 0; index < otp.length; index += 1) { + await digits.nth(index).fill(otp[index]); } } -// 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; +// The native lab contract requires a fresh consent decision on every sign-in. +// Reaching the catalog without this prompt must fail the live browser proof. +async function grantEsignetConsent(page: Page): Promise { + const consent = page.getByRole('button', { name: 'Continue', exact: true }).first(); + await consent.waitFor({ timeout: LOGIN_TIMEOUT_MS }); // 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 }); + if (!(await allClaims.isChecked())) await allClaims.locator('xpath=..').click(); } 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 }); + if (!(await claim.isChecked()) && !(await claim.isDisabled())) await claim.locator('xpath=..').click(); } } await consent.click(); diff --git a/portal/package.json b/portal/package.json index 09970af..7437f14 100644 --- a/portal/package.json +++ b/portal/package.json @@ -31,6 +31,7 @@ "dependencies": { "@fontsource/ibm-plex-mono": "^5.1.1", "@fontsource/public-sans": "^5.1.1", + "openid-client": "^6.8.8", "qrcode-generator": "^2.0.4" } } diff --git a/portal/pnpm-lock.yaml b/portal/pnpm-lock.yaml index e5bccae..82752a1 100644 --- a/portal/pnpm-lock.yaml +++ b/portal/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@fontsource/public-sans': specifier: ^5.1.1 version: 5.2.7 + openid-client: + specifier: ^6.8.8 + version: 6.8.8 qrcode-generator: specifier: ^2.0.4 version: 2.0.4 @@ -859,6 +862,9 @@ packages: is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -929,6 +935,12 @@ packages: nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + oauth4webapi@3.8.8: + resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} + + openid-client@6.8.8: + resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -1893,6 +1905,8 @@ snapshots: dependencies: '@types/estree': 1.0.9 + jose@6.2.12: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -1959,6 +1973,13 @@ snapshots: nwsapi@2.2.24: {} + oauth4webapi@3.8.8: {} + + openid-client@6.8.8: + dependencies: + jose: 6.2.12 + oauth4webapi: 3.8.8 + parse5@7.3.0: dependencies: entities: 6.0.1 diff --git a/portal/src/lib/server/esignet.test.ts b/portal/src/lib/server/esignet.test.ts index d4de4e2..ea7426b 100644 --- a/portal/src/lib/server/esignet.test.ts +++ b/portal/src/lib/server/esignet.test.ts @@ -1,149 +1,251 @@ +// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Cookies } from '@sveltejs/kit'; -import { generateKeyPairSync } from 'node:crypto'; +import { constants, createHash, generateKeyPairSync, sign, verify } from 'node:crypto'; import { - completeEsignetLogin, - createEsignetLogin, - esignetConfigFor, - ESIGNET_LOGIN_COOKIE, - resetEsignetLoginStates, - type EsignetConfig + completeEsignetLogin, createEsignetLogin, esignetConfigFor, ESIGNET_LOGIN_COOKIE, + resetEsignetLoginStates, reclaimExpiredLogins, MAX_PENDING_ESIGNET_LOGINS, type EsignetConfig } from './esignet'; +import { GET as loginRoute } from '../../routes/auth/login/+server'; +import { GET as callbackRoute } from '../../routes/auth/callback/+server'; +import { getSession } from './session'; 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); + get(name: string) { return this.values.get(name); } + set(name: string, value: string) { this.values.set(name, value); } + delete(name: string) { this.values.delete(name); } + asCookies(): Cookies { + // The routes only use get, set and delete on SvelteKit's cookie interface. + return this as unknown as Cookies; } } -function cookiesForTest(jar: MemoryCookies): Cookies { - return jar as unknown as Cookies; +const clientKey = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const issuerKey = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const cfg: EsignetConfig = { + issuer: 'https://esignet.example.test', allowHttp: false, + clientId: 'solmara-portal', clientKeyId: 'solmara-portal-key-1', + clientPrivateKeyPem: clientKey.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + redirectUri: 'https://portal.example.test/auth/callback', scope: 'openid', + subjectClaim: 'individual_id', secureCookies: true +}; +const metadata = { + issuer: cfg.issuer, authorization_endpoint: `${cfg.issuer}/oauth2/authorize`, + token_endpoint: `${cfg.issuer}/oauth2/token`, userinfo_endpoint: `${cfg.issuer}/oauth2/userinfo`, + jwks_uri: `${cfg.issuer}/oauth2/jwks`, response_types_supported: ['code'], + subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['PS256'], + userinfo_signing_alg_values_supported: ['PS256'], token_endpoint_auth_methods_supported: ['private_key_jwt'] +}; + +function jwt(claims: Record, tampered = false): string { + const header = Buffer.from(JSON.stringify({ alg: 'PS256', kid: 'issuer-key' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + const signature = sign('RSA-SHA256', Buffer.from(`${header}.${payload}`), { key: issuerKey.privateKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url'); + return `${header}.${payload}.${tampered ? (signature[0] === 'A' ? 'B' : 'A') + signature.slice(1) : signature}`; } - -function privateKeyPem(): string { - const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); - return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } }); } -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 - }; +type Options = { + id?: Record; userinfo?: Record; + tamperId?: boolean; tamperUserinfo?: boolean; unsignedUserinfo?: boolean; + omitId?: boolean; tokenError?: boolean; metadata?: Record; + jwksAlgorithm?: string; +}; +function provider(options: Options = {}) { + let nonce = ''; + let tokenBody = new URLSearchParams(); + const fetchFn = vi.fn(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.pathname === '/.well-known/openid-configuration') return json({ ...metadata, ...options.metadata }); + if (url.pathname === '/oauth2/jwks') { + return json({ keys: [{ ...issuerKey.publicKey.export({ format: 'jwk' }), kid: 'issuer-key', alg: options.jwksAlgorithm ?? 'PS256', use: 'sig' }] }); + } + const now = Math.floor(Date.now() / 1000); + const common = { iss: cfg.issuer, aud: cfg.clientId, sub: 'pairwise-subject', iat: now, exp: now + 300 }; + if (url.pathname === '/oauth2/token') { + tokenBody = new URLSearchParams(init?.body?.toString()); + if (options.tokenError) return json({ error: 'invalid_grant' }, 400); + return json({ access_token: 'access-token', token_type: 'Bearer', + ...(options.omitId ? {} : { id_token: jwt({ ...common, nonce, ...options.id }, options.tamperId) }) }); + } + if (url.pathname === '/oauth2/userinfo') { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer access-token'); + const claims = { ...common, individual_id: '2300018263', name: 'Elena Dela Cruz', ...options.userinfo }; + return options.unsignedUserinfo ? json(claims) : new Response(jwt(claims, options.tamperUserinfo), { + headers: { 'content-type': 'application/jwt' } + }); + } + throw new Error('Unexpected OIDC request'); + }); + return { fetchFn, tokenBody: () => tokenBody, setNonce: (value: string) => { nonce = value; } }; } - -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}`); +async function begin(options: Options = {}, config = cfg) { + const jar = new MemoryCookies(); + const server = provider(options); + const authorize = await createEsignetLogin(jar.asCookies(), config, server.fetchFn); + server.setNonce(authorize.searchParams.get('nonce') ?? ''); + const callback = new URL(config.redirectUri); + callback.searchParams.set('code', 'one-use-code'); + callback.searchParams.set('state', authorize.searchParams.get('state') ?? ''); + return { jar, server, authorize, callback, + complete: () => completeEsignetLogin(jar.asCookies(), callback, config, server.fetchFn) }; } describe('eSignet portal login', () => { - beforeEach(() => { - resetEsignetLoginStates(); - vi.useRealTimers(); + beforeEach(() => { resetEsignetLoginStates(); }); + afterEach(() => { resetEsignetLoginStates(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + + it('verifies a signed login and sends PKCE plus a valid private_key_jwt assertion', async () => { + const flow = await begin(); + expect(flow.authorize.pathname).toBe('/oauth2/authorize'); + expect(flow.authorize.searchParams.get('scope')).toBe('openid'); + expect(flow.authorize.searchParams.get('ui_locales')).toBe('en'); + expect(JSON.parse(flow.authorize.searchParams.get('claims') ?? '{}')).toEqual({ + userinfo: { given_name: null, family_name: null, individual_id: { essential: true } } + }); + expect(flow.jar.get(ESIGNET_LOGIN_COOKIE)).toBe(flow.authorize.searchParams.get('state')); + expect(await flow.complete()).toEqual({ subject: '2300018263', displayName: 'Elena Dela Cruz' }); + const body = flow.server.tokenBody(); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('redirect_uri')).toBe(cfg.redirectUri); + expect(body.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + expect(createHash('sha256').update(body.get('code_verifier') ?? '').digest('base64url')) + .toBe(flow.authorize.searchParams.get('code_challenge')); + const [header, payload, signature] = (body.get('client_assertion') ?? '').split('.'); + expect(verify('RSA-SHA256', Buffer.from(`${header}.${payload}`), clientKey.publicKey, Buffer.from(signature, 'base64url'))).toBe(true); + expect(JSON.parse(Buffer.from(header, 'base64url').toString())).toMatchObject({ alg: 'RS256', kid: cfg.clientKeyId }); + expect(JSON.parse(Buffer.from(payload, 'base64url').toString())).toMatchObject({ iss: cfg.clientId, sub: cfg.clientId }); + expect(flow.jar.get(ESIGNET_LOGIN_COOKIE)).toBeUndefined(); + await expect(flow.complete()).rejects.toThrow('callback state'); + expect(flow.server.fetchFn.mock.calls.filter(([url]) => url.toString().endsWith('/token'))).toHaveLength(1); }); - afterEach(() => { - resetEsignetLoginStates(); - vi.useRealTimers(); + it.each<[string, Options]>([ + ['ID token signature', { tamperId: true }], + ['verification key algorithm mismatch', { jwksAlgorithm: 'RS256' }], + ['ID token issuer', { id: { iss: 'https://attacker.example.test' } }], + ['ID token audience', { id: { aud: 'another-client' } }], + ['ID token nonce', { id: { nonce: 'another-login' } }], + ['ID token expiry', { id: { exp: 1 } }], + ['missing ID token', { omitId: true }], + ['UserInfo signature', { tamperUserinfo: true }], + ['UserInfo issuer', { userinfo: { iss: 'https://attacker.example.test' } }], + ['UserInfo audience', { userinfo: { aud: 'another-client' } }], + ['UserInfo subject correlation', { userinfo: { sub: 'different-subject' } }], + ['unsigned UserInfo', { unsignedUserinfo: true }], + ['missing identity claim', { userinfo: { individual_id: null } }], + ['token endpoint refusal', { tokenError: true }] + ])('rejects %s and consumes the login even on failure', async (_name, options) => { + const flow = await begin(options); + await expect(flow.complete()).rejects.toThrow(); + await expect(flow.complete()).rejects.toThrow('callback state'); + expect(flow.server.fetchFn.mock.calls.filter(([url]) => url.toString().endsWith('/token'))).toHaveLength(1); }); - 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('rejects wrong, duplicate, expired and cross-client state before token exchange', async () => { + for (const scenario of ['wrong', 'duplicate', 'expired', 'client', 'uri']) { + const flow = await begin(); + if (scenario === 'wrong') flow.callback.searchParams.set('state', 'wrong'); + if (scenario === 'duplicate') flow.callback.searchParams.append('state', 'second'); + if (scenario === 'expired') reclaimExpiredLogins(Date.now() + 601_000); + if (scenario === 'uri') flow.callback.pathname = '/wrong'; + await expect(completeEsignetLogin(flow.jar.asCookies(), flow.callback, + scenario === 'client' ? { ...cfg, clientId: 'changed' } : cfg, flow.server.fetchFn)).rejects.toThrow(); + expect(flow.server.fetchFn.mock.calls.some(([url]) => url.toString().endsWith('/token'))).toBe(false); + } }); - 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 }); - }); + it('consumes a refused authorization callback without exchanging a code', async () => { + const flow = await begin(); + flow.callback.searchParams.delete('code'); + flow.callback.searchParams.set('error', 'access_denied'); + await expect(flow.complete()).rejects.toThrow(); + await expect(flow.complete()).rejects.toThrow('callback state'); + expect(flow.server.fetchFn.mock.calls.some(([url]) => url.toString().endsWith('/token'))).toBe(false); + }); - const session = await completeEsignetLogin(cookiesForTest(jar), callbackUrl(authorize), cfg, fetchFn); - const body = new URLSearchParams(tokenRequestBody); + it('rejects an inferred callback scheme that differs from the registered public origin', async () => { + const flow = await begin(); + flow.callback.protocol = 'http:'; + await expect(flow.complete()).rejects.toMatchObject({ stage: 'callback-uri' }); + expect(flow.server.fetchFn.mock.calls.some(([url]) => url.toString().endsWith('/token'))).toBe(false); + await expect(flow.complete()).rejects.toMatchObject({ stage: 'state' }); + }); - 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('requires an exact trusted discovery issuer, including trailing slash', async () => { + for (const issuer of ['https://attacker.example.test', `${cfg.issuer}/`]) { + const server = provider({ metadata: { issuer } }); + await expect(createEsignetLogin(new MemoryCookies().asCookies(), cfg, server.fetchFn)).rejects.toThrow(); + } }); - 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' } - }); - }); + it('maps only the configured issuer origin to local transport and preserves validation', async () => { + const flow = await begin({}, { ...cfg, transportOrigin: 'http://esignet:8080', allowHttp: true }); + await expect(flow.complete()).resolves.toMatchObject({ subject: '2300018263' }); + expect(flow.server.fetchFn.mock.calls.every(([url]) => new URL(url.toString()).origin === 'http://esignet:8080')).toBe(true); + expect(flow.authorize.origin).toBe(cfg.issuer); + const untrusted = await begin({ metadata: { token_endpoint: 'https://attacker.example.test/token' } }); + await expect(untrusted.complete()).rejects.toThrow(); + expect(untrusted.server.fetchFn.mock.calls.some(([url]) => url.toString().includes('attacker'))).toBe(false); + }); + + it('bounds pending logins and reclaims expired entries', async () => { + const server = provider(); + await Promise.all(Array.from({ length: MAX_PENDING_ESIGNET_LOGINS + 1 }, async () => { + try { await createEsignetLogin(new MemoryCookies().asCookies(), cfg, server.fetchFn); } catch { /* Capacity is expected. */ } + })); + await expect(createEsignetLogin(new MemoryCookies().asCookies(), cfg, server.fetchFn)).rejects.toThrow('capacity'); + expect(reclaimExpiredLogins(Date.now() + 601_000)).toBe(MAX_PENDING_ESIGNET_LOGINS); + await expect(createEsignetLogin(new MemoryCookies().asCookies(), cfg, server.fetchFn)).resolves.toBeInstanceOf(URL); + }); - await expect(completeEsignetLogin(cookiesForTest(jar), callbackUrl(authorize), cfg, fetchFn)).rejects.toThrow( - 'configured subject claim individual_id' - ); + it('defaults to openid with only necessary identity and optional display claims while preserving scope overrides', () => { + const env = { + PORTAL_AUTH_PROVIDER: 'esignet', PORTAL_ESIGNET_ISSUER: cfg.issuer, + PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64: Buffer.from(cfg.clientPrivateKeyPem).toString('base64') + }; + expect(esignetConfigFor(new URL(cfg.redirectUri), env)?.scope).toBe('openid'); + expect(esignetConfigFor(new URL(cfg.redirectUri), { ...env, PORTAL_ESIGNET_SCOPE: 'openid profile' })?.scope) + .toBe('openid profile'); }); - 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(); + it('requires explicit HTTP opt-in and keeps eSignet disabled by default', () => { + expect(esignetConfigFor(new URL(cfg.redirectUri), {})).toBeNull(); + expect(() => esignetConfigFor(new URL(cfg.redirectUri), { + PORTAL_AUTH_PROVIDER: 'esignet', PORTAL_ESIGNET_ISSUER: 'http://127.0.0.1:4308' + })).toThrow('HTTPS'); + }); + + it('uses native server fetch to route a verified callback into a session and reject tampering', async () => { + const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubEnv('PORTAL_AUTH_PROVIDER', 'esignet'); + vi.stubEnv('PORTAL_ESIGNET_ISSUER', cfg.issuer); + vi.stubEnv('PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64', Buffer.from(cfg.clientPrivateKeyPem).toString('base64')); + vi.stubEnv('PORTAL_ESIGNET_REDIRECT_URI', cfg.redirectUri); + for (const tamperId of [false, true]) { + const jar = new MemoryCookies(); + const server = provider({ tamperId }); + vi.stubGlobal('fetch', server.fetchFn); + const eventFetch = vi.fn(() => { throw new Error('browser request context must not reach OIDC'); }); + // These routes consume only cookies and url, not SvelteKit's contextual fetch. + const event = { cookies: jar.asCookies(), url: new URL('https://portal.example.test/auth/login'), fetch: eventFetch }; + let location = ''; + try { await loginRoute(event as unknown as Parameters[0]); } catch (error) { + if (typeof error === 'object' && error && 'location' in error && typeof error.location === 'string') location = error.location; + } + const authorize = new URL(location); + server.setNonce(authorize.searchParams.get('nonce') ?? ''); + event.url = new URL(`${cfg.redirectUri}?code=route-code&state=${authorize.searchParams.get('state')}`); + // The callback consumes the same two event properties. + await expect(callbackRoute(event as unknown as Parameters[0])).rejects.toMatchObject(tamperId ? { status: 401 } : { status: 302, location: '/services' }); + expect(getSession(jar.asCookies())).toEqual(tamperId ? null : { subject: '2300018263', displayName: 'Elena Dela Cruz' }); + expect(eventFetch).not.toHaveBeenCalled(); + if (tamperId) expect(diagnostic).toHaveBeenLastCalledWith('eSignet login rejected', { + stage: 'token', code: expect.stringMatching(/^OAUTH_[A-Z_]+$/) + }); + } }); }); diff --git a/portal/src/lib/server/esignet.ts b/portal/src/lib/server/esignet.ts index a1e3317..eebf76a 100644 --- a/portal/src/lib/server/esignet.ts +++ b/portal/src/lib/server/esignet.ts @@ -1,26 +1,26 @@ import type { Cookies } from '@sveltejs/kit'; -import { createHash, createPrivateKey, createSign, randomBytes, randomUUID } from 'node:crypto'; +import { createPrivateKey, webcrypto } from 'node:crypto'; +import * as oidc from 'openid-client'; 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'; +export const MAX_PENDING_ESIGNET_LOGINS = 1000; type StoredLogin = { verifier: string; nonce: string; redirectUri: string; + issuer: string; + clientId: string; expiresAt: number; }; export type EsignetConfig = { issuer: string; - authorizationEndpoint: string; - tokenEndpoint: string; - clientAssertionAudience: string; - userinfoEndpoint: string; + transportOrigin?: string; + allowHttp: boolean; clientId: string; clientKeyId: string; clientPrivateKeyPem: string; @@ -38,12 +38,13 @@ export class EsignetConfigError extends Error { } export class EsignetAuthError extends Error { - constructor(message: string) { + constructor(message: string, readonly stage = 'validation', readonly code?: string) { super(message); this.name = 'EsignetAuthError'; } } +// This lab runs one portal process. Pending logins expire and have a hard cap. const loginStates = new Map(); export function esignetConfigFor(url: URL, env: NodeJS.ProcessEnv = process.env): EsignetConfig | null { @@ -52,65 +53,127 @@ export function esignetConfigFor(url: URL, env: NodeJS.ProcessEnv = process.env) 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(); + const allowHttp = env.PORTAL_ESIGNET_ALLOW_HTTP === 'true'; + const issuer = trustedUrl(env.PORTAL_ESIGNET_ISSUER, 'PORTAL_ESIGNET_ISSUER', allowHttp); + const transport = env.PORTAL_ESIGNET_TRANSPORT_ORIGIN + ? trustedUrl(env.PORTAL_ESIGNET_TRANSPORT_ORIGIN, 'PORTAL_ESIGNET_TRANSPORT_ORIGIN', allowHttp) + : undefined; + if (transport && new URL(transport).pathname !== '/') { + throw new EsignetConfigError('PORTAL_ESIGNET_TRANSPORT_ORIGIN must be an origin without a path'); + } 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'), + issuer, + transportOrigin: transport, + allowHttp, 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', + redirectUri: trustedUrl(env.PORTAL_ESIGNET_REDIRECT_URI || new URL('/auth/callback', url.origin).href, + 'PORTAL_ESIGNET_REDIRECT_URI', allowHttp), + scope: env.PORTAL_ESIGNET_SCOPE || 'openid', subjectClaim: env.PORTAL_ESIGNET_SUBJECT_CLAIM || 'individual_id', secureCookies: env.PORTAL_SECURE_COOKIES === 'true' }; } -export function createEsignetLogin(cookies: Cookies, config: EsignetConfig): URL { +export async function createEsignetLogin( + cookies: Cookies, config: EsignetConfig, fetchFn: typeof fetch = fetch +): Promise { reclaimExpiredLogins(); - const state = randomToken(); - const verifier = randomToken(); - const nonce = randomToken(); + const previous = cookies.get(ESIGNET_LOGIN_COOKIE); + if (previous) loginStates.delete(previous); + if (loginStates.size >= MAX_PENDING_ESIGNET_LOGINS) { + throw new EsignetAuthError('eSignet login capacity reached'); + } + const client = await discover(config, fetchFn); + const state = oidc.randomState(); + const verifier = oidc.randomPKCECodeVerifier(); + const nonce = oidc.randomNonce(); + const authorize = oidc.buildAuthorizationUrl(client, { + response_type: 'code', redirect_uri: config.redirectUri, scope: config.scope, ui_locales: 'en', + claims: JSON.stringify({ userinfo: { + given_name: null, family_name: null, [config.subjectClaim]: { essential: true } + } }), + state, nonce, code_challenge: await oidc.calculatePKCECodeChallenge(verifier), + code_challenge_method: 'S256' + }); + // Discovery is asynchronous, so enforce the bound again immediately before insertion. + if (loginStates.size >= MAX_PENDING_ESIGNET_LOGINS) { + throw new EsignetAuthError('eSignet login capacity reached'); + } loginStates.set(state, { - verifier, - nonce, - redirectUri: config.redirectUri, - expiresAt: Date.now() + LOGIN_MAX_AGE_MS + verifier, nonce, redirectUri: config.redirectUri, issuer: config.issuer, + clientId: config.clientId, expiresAt: Date.now() + LOGIN_MAX_AGE_SECONDS * 1000 }); cookies.set(ESIGNET_LOGIN_COOKIE, state, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: config.secureCookies, + 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 + cookies: Cookies, url: URL, config: EsignetConfig, fetchFn: typeof fetch = 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); + const { login, state } = consumeLogin(cookies, url, config); + let stage = 'discovery'; + try { + const client = await discover(config, fetchFn); + stage = 'token'; + const token = await oidc.authorizationCodeGrant(client, url, { + pkceCodeVerifier: login.verifier, expectedState: state, + expectedNonce: login.nonce, idTokenExpected: true + }); + const identity = token.claims(); + if (!identity) throw new EsignetAuthError('eSignet token response omitted ID token'); + stage = 'userinfo'; + const claims = await oidc.fetchUserInfo(client, token.access_token, identity.sub); + return sessionFromClaims(claims, config); + } catch (err) { + if (err instanceof EsignetAuthError || err instanceof EsignetConfigError) throw err; + // Protocol errors can contain tokens and provider response bodies. Keep them server-private. + const code = err instanceof Error && 'code' in err && typeof err.code === 'string' && + /^OAUTH_[A-Z_]{1,60}$/.test(err.code) ? err.code : undefined; + throw new EsignetAuthError('eSignet authentication validation failed', stage, code); + } +} + +async function discover(config: EsignetConfig, fetchFn: typeof fetch): Promise { + try { + const key = createPrivateKey(config.clientPrivateKeyPem); + const privateKey = await webcrypto.subtle.importKey('pkcs8', + key.export({ type: 'pkcs8', format: 'der' }), + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']); + const client = await oidc.discovery(new URL(config.issuer), config.clientId, { + token_endpoint_auth_method: 'private_key_jwt', + id_token_signed_response_alg: 'PS256', userinfo_signed_response_alg: 'PS256' + }, oidc.PrivateKeyJwt({ key: privateKey, kid: config.clientKeyId }), { + timeout: 15, + // Signature verification is required even on the explicitly configured local HTTP transport. + execute: [oidc.enableNonRepudiationChecks, ...(config.allowHttp ? [oidc.allowInsecureRequests] : [])], + [oidc.customFetch]: async (input, init) => { + const endpoint = new URL(input); + if (endpoint.origin !== new URL(config.issuer).origin) { + throw new EsignetConfigError('eSignet server endpoint must use the trusted issuer origin'); + } + if (config.transportOrigin) { + const transport = new URL(config.transportOrigin); + endpoint.protocol = transport.protocol; + endpoint.host = transport.host; + } + const body = init.body instanceof Uint8Array ? new Uint8Array(init.body) : init.body; + return fetchFn(endpoint, { ...init, body, redirect: 'error' }); + } + }); + // Keep the configured issuer string exact, including path and trailing slash. + if (client.serverMetadata().issuer !== config.issuer) { + throw new EsignetConfigError('eSignet discovery issuer mismatch'); + } + return client; + } catch (err) { + if (err instanceof EsignetConfigError) throw err; + throw new EsignetAuthError('eSignet discovery failed', 'discovery'); + } } export function resetEsignetLoginStates(): void { @@ -128,130 +191,56 @@ export function reclaimExpiredLogins(now = Date.now()): number { return reclaimed; } -function requiredUrl(env: NodeJS.ProcessEnv, name: string): string { - const value = env[name]; +function trustedUrl(value: string | undefined, name: string, allowHttp: boolean): string { if (!value) throw new EsignetConfigError(`${name} is required when ${AUTH_PROVIDER_ENV}=esignet`); try { - return new URL(value).toString(); + const url = new URL(value); + if ((url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:')) || + url.username || url.password || url.search || url.hash) throw new Error('invalid URL'); + return value; } catch { - throw new EsignetConfigError(`${name} must be an absolute URL`); + throw new EsignetConfigError(`${name} must be an absolute HTTPS URL (local HTTP requires PORTAL_ESIGNET_ALLOW_HTTP=true)`); } } -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` - ); - } + if (!encoded) throw new EsignetConfigError('PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64 is required'); try { const pem = Buffer.from(encoded, 'base64').toString('utf8'); - createPrivateKey(pem); + const key = createPrivateKey(pem); + if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { + throw new Error('expected RSA key'); + } return pem; } catch { - throw new EsignetConfigError('PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64 must contain a base64-encoded PEM key'); + throw new EsignetConfigError('PORTAL_ESIGNET_CLIENT_PRIVATE_KEY_B64 must contain a base64-encoded RSA PEM key of at least 2048 bits'); } } -function consumeLogin(cookies: Cookies, url: URL, config: EsignetConfig): StoredLogin { +function consumeLogin(cookies: Cookies, url: URL, config: EsignetConfig): { login: StoredLogin; state: string } { reclaimExpiredLogins(); const state = url.searchParams.get('state'); - const code = url.searchParams.get('code'); const cookieState = cookies.get(ESIGNET_LOGIN_COOKIE); + const login = cookieState ? loginStates.get(cookieState) : undefined; cookies.delete(ESIGNET_LOGIN_COOKIE, { path: '/' }); - - if (!code || !state || !cookieState || state !== cookieState) { - throw new EsignetAuthError('invalid eSignet callback state'); + if (cookieState) loginStates.delete(cookieState); + if (!state || state !== cookieState || url.searchParams.getAll('state').length !== 1) { + throw new EsignetAuthError('invalid eSignet callback state', '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'); + if (!login || login.redirectUri !== config.redirectUri || login.issuer !== config.issuer || + login.clientId !== config.clientId) throw new EsignetAuthError('expired eSignet login state', 'pending-login'); + const expected = new URL(login.redirectUri); + if (url.origin !== expected.origin || url.pathname !== expected.pathname) { + throw new EsignetAuthError('invalid eSignet callback URI', 'callback-uri'); } - 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; + return { login, state }; } 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}`); + throw new EsignetAuthError(`eSignet UserInfo omitted configured subject claim ${config.subjectClaim}`, 'session'); } return { subject, @@ -262,74 +251,8 @@ function sessionFromClaims(claims: Record, config: EsignetConfi }; } -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/routes/auth/callback/+server.ts b/portal/src/routes/auth/callback/+server.ts index 5d034d2..4211198 100644 --- a/portal/src/routes/auth/callback/+server.ts +++ b/portal/src/routes/auth/callback/+server.ts @@ -12,17 +12,22 @@ import { completeEsignetLogin, esignetConfigFor, EsignetAuthError, EsignetConfig import { setMockSession, setPortalSession } from '$lib/server/session'; import { resolvePersona } from '$lib/server/personas'; -export const GET: RequestHandler = async ({ cookies, fetch, url }) => { +export const GET: RequestHandler = async ({ cookies, url }) => { try { const esignet = esignetConfigFor(url); if (esignet) { - const session = await completeEsignetLogin(cookies, url, esignet, fetch); + // OIDC backchannel requests use native fetch without the browser request context. + const session = await completeEsignetLogin(cookies, url, esignet); 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'); + if (err instanceof EsignetAuthError) { + // Fixed stages and SDK codes are useful operational diagnostics without protocol values. + console.error('eSignet login rejected', { stage: err.stage, code: err.code }); + throw error(401, 'eSignet login failed'); + } throw err; } diff --git a/portal/src/routes/auth/login/+server.ts b/portal/src/routes/auth/login/+server.ts index 30610c9..3a34525 100644 --- a/portal/src/routes/auth/login/+server.ts +++ b/portal/src/routes/auth/login/+server.ts @@ -1,21 +1,25 @@ // 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 +// discovered eSignet authorize endpoint. The default request asks for the +// required business identifier and optional given/family names for display. +// In mock mode it 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 type { RequestHandler } from './$types'; -import { createEsignetLogin, esignetConfigFor, EsignetConfigError } from '$lib/server/esignet'; +import { createEsignetLogin, esignetConfigFor, EsignetConfigError, EsignetAuthError } from '$lib/server/esignet'; -export const GET: RequestHandler = ({ cookies, url }) => { +export const GET: RequestHandler = async ({ cookies, url }) => { try { const esignet = esignetConfigFor(url); if (esignet) { - throw redirect(302, createEsignetLogin(cookies, esignet).toString()); + // OIDC backchannel requests use native fetch without the browser request context. + throw redirect(302, (await createEsignetLogin(cookies, esignet)).toString()); } } catch (err) { + if (err instanceof EsignetAuthError) throw error(503, 'eSignet login is unavailable'); if (err instanceof EsignetConfigError) throw error(500, 'eSignet login is not configured'); throw err; } diff --git a/scripts/check-coolify-compose.sh b/scripts/check-coolify-compose.sh index c8515bc..593f19b 100755 --- a/scripts/check-coolify-compose.sh +++ b/scripts/check-coolify-compose.sh @@ -24,12 +24,11 @@ check_compose() { SOLMARA_ESIGNET_SEED_IMAGE="$test_image" \ SOLMARA_ESIGNET_POSTGRES_IMAGE="$test_image" \ SOLMARA_ESIGNET_UI_IMAGE="$test_image" \ + VOLUME_INIT_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 \ + SOLMARA_ESIGNET_V2_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" \ diff --git a/scripts/check-image-pins.py b/scripts/check-image-pins.py index f3ec540..51ec75a 100755 --- a/scripts/check-image-pins.py +++ b/scripts/check-image-pins.py @@ -19,7 +19,7 @@ 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_REDIS_IMAGE", "ESIGNET_NGINX_IMAGE", "ESIGNET_POSTGRES_IMAGE", } diff --git a/scripts/check-registry-stack-release-pin.py b/scripts/check-registry-stack-release-pin.py index 9b4dfcd..c75cbb3 100755 --- a/scripts/check-registry-stack-release-pin.py +++ b/scripts/check-registry-stack-release-pin.py @@ -58,24 +58,19 @@ 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") + expected = { + "ESIGNET_AUTHENTICATOR_VERSION": "0.3.0", + "ESIGNET_SOURCE_VERSION": "2.0.0-beta.1", + "ESIGNET_SOURCE_COMMIT": "df0d0e771dae16eb2597b8e5b5dc65e70baa7f86", + } + for key, value in expected.items(): + if values.get(key) != value: + failures.append(f"{key} must be {value}") + archive_digest = values.get("ESIGNET_SOURCE_ARCHIVE_SHA256", "") + if not DIGEST.fullmatch(archive_digest): + failures.append("ESIGNET_SOURCE_ARCHIVE_SHA256 must be 64 lowercase hex characters") + # Native provider candidates are local build inputs, not published release assets. + # Hosted image publication verifies the final image digests in its manifest gate. return failures diff --git a/scripts/esignet-protocol-proof.mjs b/scripts/esignet-protocol-proof.mjs new file mode 100644 index 0000000..ee158fb --- /dev/null +++ b/scripts/esignet-protocol-proof.mjs @@ -0,0 +1,385 @@ +#!/usr/bin/env node + +// Exercise the candidate's real browser, provider, token endpoint and UserInfo. +// Ephemeral private keys and credentials stay in memory. Output is fixed labels. +// Run: node --experimental-strip-types scripts/esignet-protocol-proof.mjs +// Requires the running local fixture and installed portal dependencies/browser. +// ESIGNET_PROOF_ISSUER, ESIGNET_PROOF_PORT and ESIGNET_PROOF_CWD override local +// defaults. ESIGNET_PROOF_COMPOSE_ARGS_JSON is an array of docker compose options, +// including any --env-file paths. ESIGNET_DEMO_SUBJECT/OTP select the seeded login. +// Test clients use fresh IDs on each run; their private keys are never persisted. +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; +import * as oidc from '../portal/node_modules/openid-client/build/index.js'; +import { chromium } from '../portal/node_modules/@playwright/test/index.mjs'; +import { requestEsignetOtp, verifyEsignetOtp } from '../portal/e2e/support/auth.ts'; + +const requireOidc = createRequire(import.meta.resolve('../portal/node_modules/openid-client/build/index.js')); +const jose = await import(pathToFileURL(requireOidc.resolve('jose')).href); +const root = fileURLToPath(new URL('..', import.meta.url)); +const claimNames = ['individual_id', 'name', 'given_name', 'family_name', 'gender', 'birthdate']; +const proofTrace = []; +const resultPath = '/tmp/solmara-esignet-protocol-proof.json'; +const protocolClaims = new Set(['sub', 'iss', 'aud', 'iat', 'exp', 'nbf', 'jti']); +const grantFailureDescriptions = new Set(['Invalid authorization code', 'code_verifier is required', + 'Invalid code verifier', 'Invalid redirect URI', 'Expired authorization code']); + +export function assertDisclosure(claims, expected, subject) { + assert.ok(claims && typeof claims === 'object' && !Array.isArray(claims)); + assert.equal(typeof claims.sub, 'string'); + assert.ok(claims.sub.length > 0); + if (subject) assert.equal(claims.sub, subject); + assert.deepEqual(Object.keys(claims).filter((name) => !protocolClaims.has(name)).sort(), [...expected].sort()); + for (const name of expected) assert.ok(claims[name] !== null && claims[name] !== undefined); +} + +export function validateCallback(url, pending) { + assert.equal(url.origin, new URL(pending.redirectUri).origin); + assert.equal(url.pathname, '/callback'); + assert.equal(url.searchParams.getAll('state').length, 1); + assert.equal(url.searchParams.get('state'), pending.state); + assert.equal(url.searchParams.has('code') !== url.searchParams.has('error'), true); + if (url.searchParams.has('code')) assert.equal(url.searchParams.getAll('code').length, 1); + return url; +} + +export function registration(clientId, signingJwk, encryptionJwk, redirectUri) { + return { + clientId, clientName: 'Solmara Protocol Proof', clientNameLangMap: { eng: 'Solmara Protocol Proof' }, relyingPartyId: 'solmara', + logoUri: 'https://id.registrystack.org/solmara/logo.svg', redirectUris: [redirectUri], + userClaims: claimNames, authContextRefs: ['mosip:idp:acr:static-code'], + publicKey: signingJwk, ...(encryptionJwk ? { encPublicKey: encryptionJwk } : {}), + grantTypes: ['authorization_code'], clientAuthMethods: ['private_key_jwt'], + additionalConfig: { userinfo_response_type: encryptionJwk ? 'JWE' : 'JWS', + require_pkce: true, pkce_required: true } + }; +} + +async function registerClient(payload) { + // Explicit argument arrays avoid shell interpolation. Only public registration + // material crosses stdin into the private Docker network. + const composeArgs = JSON.parse(process.env.ESIGNET_PROOF_COMPOSE_ARGS_JSON || JSON.stringify([ + '-p', 'solmara-esignet-v2-fixture', '--env-file', 'versions.env', '--env-file', '.env', + '-f', 'compose.esignet-fixture.yaml' + ])); + assert.ok(Array.isArray(composeArgs) && composeArgs.every((arg) => typeof arg === 'string')); + const python = [ + 'import json,sys,urllib.request,datetime', + 'payload=json.load(sys.stdin)', + 'body=json.dumps({"requestTime":datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00","Z"),"request":payload}).encode()', + 'request=urllib.request.Request("http://esignet:8080/client-mgmt/client",data=body,headers={"Content-Type":"application/json"})', + 'result=json.load(urllib.request.urlopen(request,timeout=20))', + 'sys.exit(0 if not result.get("errors") and result.get("response",{}).get("clientId")==payload["clientId"] else 1)' + ].join('\n'); + await new Promise((resolveRun, rejectRun) => { + const child = spawn('docker', ['compose', ...composeArgs, 'run', '--rm', '-T', '--no-deps', + '--entrypoint', 'python', 'esignet-seed', '-c', python], { + cwd: process.env.ESIGNET_PROOF_CWD || root, stdio: ['pipe', 'ignore', 'ignore'] + }); + const timeout = setTimeout(() => { child.kill(); rejectRun(new Error('registration timed out')); }, 60_000); + child.on('error', () => { clearTimeout(timeout); rejectRun(new Error('registration failed')); }); + child.on('close', (code) => { clearTimeout(timeout); code === 0 ? resolveRun() : rejectRun(new Error('registration failed')); }); + child.stdin.end(JSON.stringify(payload)); + }); +} + +async function createClient(issuer, redirectUri, encrypted, onStage, acr = 'mosip:idp:acr:static-code') { + const signing = await jose.generateKeyPair('RS256', { extractable: true }); + const signingJwk = { ...await jose.exportJWK(signing.publicKey), kid: randomUUID(), use: 'sig', alg: 'RS256' }; + const encryption = encrypted ? await jose.generateKeyPair('RSA-OAEP-256') : undefined; + const encryptionJwk = encryption ? { ...await jose.exportJWK(encryption.publicKey), kid: randomUUID(), use: 'enc', alg: 'RSA-OAEP-256' } : undefined; + const clientId = `solmara-proof-${encrypted ? 'jwe' : 'jws'}-${randomUUID().slice(0, 8)}`; + onStage(encrypted ? 'encrypted-client-registration' : 'signed-client-registration'); + const payload = registration(clientId, signingJwk, encryptionJwk, redirectUri); + payload.authContextRefs = [acr]; + await registerClient(payload); + onStage(encrypted ? 'encrypted-client-discovery' : 'signed-client-discovery'); + const config = await oidc.discovery(new URL(issuer), clientId, { + token_endpoint_auth_method: 'private_key_jwt', id_token_signed_response_alg: 'PS256', + ...(encrypted ? {} : { userinfo_signed_response_alg: 'PS256' }) + }, oidc.PrivateKeyJwt({ key: signing.privateKey, kid: signingJwk.kid }), { + execute: [oidc.enableNonRepudiationChecks, ...(issuer.startsWith('http:') ? [oidc.allowInsecureRequests] : [])], + timeout: 15 + }); + assert.equal(config.serverMetadata().issuer, issuer); + return { config, encryption, encryptionJwk, redirectUri, clientId, acr }; +} + +async function beginLogin(browser, client, claims, callbacks) { + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(20_000); + page.on('response', async (response) => { + const path = new URL(response.url()).pathname; + if (!path.endsWith('/flow/execute')) return; + try { + const body = await response.json(); + proofTrace.push({ path, status: response.status(), flowStatus: body.flowStatus, + errorCode: body.error?.code, fieldErrorCount: body.data?.fieldErrors?.length || 0 }); + } catch { /* Diagnostic metadata must not change proof behavior. */ } + }); + const state = oidc.randomState(); + const nonce = oidc.randomNonce(); + const verifier = oidc.randomPKCECodeVerifier(); + const pending = { state, nonce, verifier, redirectUri: client.redirectUri }; + let receive; + const callback = new Promise((resolveCallback) => { receive = resolveCallback; }); + callbacks.set(state, receive); + const authorize = oidc.buildAuthorizationUrl(client.config, { + redirect_uri: client.redirectUri, scope: 'openid', ui_locales: 'en', acr_values: client.acr, state, nonce, + claims: JSON.stringify({ userinfo: claims }), + code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256' + }); + await page.goto(authorize.href); + await page.waitForURL((url) => url.pathname === '/signin'); + return { page, context, pending, callback, + close: async () => { callbacks.delete(state); await context.close(); } }; +} + +async function authenticate(flow, invalidOtp = false) { + await requestEsignetOtp(flow.page); + const otp = process.env.ESIGNET_DEMO_OTP || '111111'; + const response = await verifyEsignetOtp(flow.page, invalidOtp ? (otp === '000000' ? '999999' : '000000') : otp); + const body = await response.json(); + if (invalidOtp) { + assert.equal(response.status(), 200); + assert.equal(body.flowStatus, 'INCOMPLETE'); + assert.equal(body.error?.code, 'FET-1005'); + assert.equal(await flow.page.getByRole('button', { name: 'Continue', exact: true }).count(), 0); + assert.equal(new URL(flow.page.url()).pathname, '/signin'); + return; + } + assert.ok(response.ok() && body.flowStatus !== 'ERROR' && !body.error); + await flow.page.getByRole('button', { name: 'Continue', exact: true }).waitFor(); +} + +async function chooseConsent(flow, allowed) { + const toggles = flow.page.locator('input[type="checkbox"]'); + assert.ok(await toggles.count() > 0); + for (let index = 0; index < await toggles.count(); index += 1) { + const toggle = toggles.nth(index); + const id = await toggle.getAttribute('id'); + if (id === 'consent_opt__all') continue; + const matches = claimNames.filter((name) => id?.endsWith(`__${name}`)); + assert.equal(matches.length, 1); + const label = matches[0]; + if (await toggle.isDisabled()) { + assert.ok(allowed.includes(label)); + assert.ok(await toggle.isChecked()); + } else { + const desired = allowed.includes(label); + if (await toggle.isChecked() !== desired) await toggle.locator('xpath=..').click(); + assert.equal(await toggle.isChecked(), desired); + } + } +} + +async function callbackFor(flow) { + let timer; + try { + const url = await Promise.race([flow.callback, new Promise((_, rejectTimeout) => { + timer = setTimeout(() => rejectTimeout(new Error('callback timeout')), 20_000); + })]); + return validateCallback(url, flow.pending); + } finally { clearTimeout(timer); } +} + +async function continueConsent(flow, allowed) { + await chooseConsent(flow, allowed); + const [response] = await Promise.all([ + flow.page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith('/flow/execute') && response.request().method() === 'POST'), + flow.page.getByRole('button', { name: 'Continue', exact: true }).click() + ]); + const body = await response.json(); + assert.equal(response.status(), 200); + return body; +} + +async function finishLogin(flow, client, allowed) { + const body = await continueConsent(flow, allowed); + assert.ok(body.flowStatus !== 'ERROR' && !body.error); + const callback = await callbackFor(flow); + assert.equal(callback.searchParams.has('error'), false); + const token = await oidc.authorizationCodeGrant(client.config, callback, { + expectedState: flow.pending.state, expectedNonce: flow.pending.nonce, + pkceCodeVerifier: flow.pending.verifier, idTokenExpected: true + }); + assert.ok(token.claims()?.sub); + return { token, callback }; +} + +async function signedClaims(client, token) { + const first = await oidc.fetchUserInfo(client.config, token.access_token, token.claims().sub); + const second = await oidc.fetchUserInfo(client.config, token.access_token, token.claims().sub); + assertDisclosure(second, Object.keys(first).filter((name) => !protocolClaims.has(name)), first.sub); + for (const name of Object.keys(first).filter((key) => !protocolClaims.has(key))) assert.deepEqual(second[name], first[name]); + return first; +} + +async function encryptedClaims(client, token) { + const response = await fetch(client.config.serverMetadata().userinfo_endpoint, { + headers: { authorization: `Bearer ${token.access_token}` }, redirect: 'error' + }); + assert.equal(response.status, 200); + assert.ok(response.headers.get('content-type')?.startsWith('application/jwt')); + const compact = await response.text(); + assert.equal(compact.split('.').length, 5); + const { plaintext, protectedHeader } = await jose.compactDecrypt(compact, client.encryption.privateKey, { + keyManagementAlgorithms: ['RSA-OAEP-256'], contentEncryptionAlgorithms: ['A256GCM'] + }); + assert.equal(protectedHeader.kid, client.encryptionJwk.kid); + const claims = JSON.parse(new TextDecoder().decode(plaintext)); + assert.equal(claims.sub, token.claims().sub); + const wrongRecipient = await jose.generateKeyPair('RSA-OAEP-256'); + await assert.rejects(() => jose.compactDecrypt(compact, wrongRecipient.privateKey)); + const parts = compact.split('.'); + parts[3] = (parts[3][0] === 'A' ? 'B' : 'A') + parts[3].slice(1); + await assert.rejects(() => jose.compactDecrypt(parts.join('.'), client.encryption.privateKey)); + return claims; +} + +export async function main() { + const issuer = process.env.ESIGNET_PROOF_ISSUER || 'http://127.0.0.1:4308'; + const port = Number(process.env.ESIGNET_PROOF_PORT || 4315); + const redirectUri = `http://127.0.0.1:${port}/callback`; + const callbacks = new Map(); + const server = createServer((request, response) => { + const url = new URL(request.url, redirectUri); + const receive = callbacks.get(url.searchParams.get('state')); + if (url.pathname === '/callback' && receive) { + callbacks.delete(url.searchParams.get('state')); + receive(url); + response.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }); + response.end('Protocol proof callback received.'); + } else { response.writeHead(404); response.end(); } + }); + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(port, '127.0.0.1', resolveListen); + }); + let browser; + let stage = 'registration'; + try { + const signed = await createClient(issuer, redirectUri, false, (value) => { stage = value; }); + const encrypted = await createClient(issuer, redirectUri, true, (value) => { stage = value; }); + console.log('esignet-protocol-proof: client-registration PASS'); + browser = await chromium.launch({ headless: true }); + const requested = { individual_id: { essential: true }, given_name: null, name: null, gender: null }; + stage = 'invalid-otp'; + const invalid = await beginLogin(browser, signed, requested, callbacks); + try { await authenticate(invalid, true); } finally { await invalid.close(); } + console.log('esignet-protocol-proof: invalid-otp PASS'); + + stage = 'essential-denial'; + const denied = await beginLogin(browser, signed, requested, callbacks); + try { + await authenticate(denied); + const [response] = await Promise.all([ + denied.page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith('/flow/execute') && response.request().method() === 'POST'), + denied.page.getByRole('button', { name: 'Cancel', exact: true }).click() + ]); + const body = await response.json(); + assert.equal(response.status(), 200); + assert.equal(body.flowStatus, 'ERROR'); + assert.equal(body.error?.code, 'FET-1066'); + assert.ok(callbacks.has(denied.pending.state)); + assert.equal(new URL(denied.page.url()).pathname, '/signin'); + } finally { await denied.close(); } + console.log('esignet-protocol-proof: essential-denial PASS'); + + stage = 'stronger-acr-refusal'; + const stronger = await createClient(issuer, redirectUri, false, () => {}, 'mosip:idp:acr:generated-code'); + const strongerFlow = await beginLogin(browser, stronger, requested, callbacks); + try { + await authenticate(strongerFlow); + const body = await continueConsent(strongerFlow, ['individual_id', 'given_name']); + assert.equal(body.flowStatus, 'ERROR'); + assert.equal(body.error?.code, 'FET-1082'); + assert.ok(callbacks.has(strongerFlow.pending.state)); + assert.equal(new URL(strongerFlow.page.url()).pathname, '/signin'); + } finally { await strongerFlow.close(); } + console.log('esignet-protocol-proof: stronger-acr-refusal PASS'); + + stage = 'signed-userinfo-optional-denial'; + const first = await beginLogin(browser, signed, requested, callbacks); + let subject; + try { + await authenticate(first); + const { token, callback } = await finishLogin(first, signed, ['individual_id', 'given_name', 'name']); + const claims = await signedClaims(signed, token); + assertDisclosure(claims, ['individual_id', 'given_name']); + assert.equal(claims.individual_id, process.env.ESIGNET_DEMO_SUBJECT || '2300018263'); + subject = claims.sub; + await assert.rejects(() => oidc.authorizationCodeGrant(signed.config, callback, { + expectedState: first.pending.state, expectedNonce: first.pending.nonce, + pkceCodeVerifier: first.pending.verifier, idTokenExpected: true + }), (error) => error instanceof oidc.ResponseBodyError && error.error === 'invalid_grant'); + } finally { await first.close(); } + console.log('esignet-protocol-proof: signed-userinfo-optional-denial PASS'); + + stage = 'fresh-consent-no-expansion'; + const repeat = await beginLogin(browser, signed, requested, callbacks); + try { + await authenticate(repeat); + const { token } = await finishLogin(repeat, signed, ['individual_id', 'gender', 'name']); + assertDisclosure(await signedClaims(signed, token), ['individual_id', 'gender'], subject); + } finally { await repeat.close(); } + console.log('esignet-protocol-proof: fresh-consent-no-expansion PASS'); + + stage = 'empty-selection'; + const empty = await beginLogin(browser, signed, { individual_id: null, given_name: null, name: null, gender: null }, callbacks); + try { + await authenticate(empty); + const { token } = await finishLogin(empty, signed, []); + assertDisclosure(await signedClaims(signed, token), [], subject); + } finally { await empty.close(); } + console.log('esignet-protocol-proof: empty-selection PASS'); + + stage = 'encrypted-userinfo'; + const encryptedFlow = await beginLogin(browser, encrypted, requested, callbacks); + try { + await authenticate(encryptedFlow); + const { token } = await finishLogin(encryptedFlow, encrypted, ['individual_id', 'given_name', 'name']); + const claims = await encryptedClaims(encrypted, token); + assertDisclosure(claims, ['individual_id', 'given_name']); + assert.equal(claims.individual_id, process.env.ESIGNET_DEMO_SUBJECT || '2300018263'); + const repeatClaims = await encryptedClaims(encrypted, token); + assertDisclosure(repeatClaims, ['individual_id', 'given_name'], claims.sub); + } finally { await encryptedFlow.close(); } + console.log('esignet-protocol-proof: encrypted-userinfo PASS'); + + await writeFile(resultPath, JSON.stringify({ status: 'PASS', checks: 8, completedAt: new Date().toISOString() }), { mode: 0o600 }); + } catch (error) { + // Diagnostic metadata is private and excludes messages, actual/expected + // assertion values, browser URLs, claims, tokens, and key material. + const sourceLine = error.stack?.match(/esignet-protocol-proof\.mjs:(\d+):/)?.[1]; + await writeFile(resultPath, JSON.stringify({ + status: 'FAIL', stage, errorName: error.name, errorCode: error.code, + oauthError: error instanceof oidc.ResponseBodyError ? error.error : undefined, + httpStatus: error instanceof oidc.ResponseBodyError ? error.status : undefined, + grantFailure: grantFailureDescriptions.has(error.error_description) ? error.error_description : undefined, + sourceLine, flowTrace: proofTrace + }), { mode: 0o600 }); + console.error(`esignet-protocol-proof: ${stage} FAIL`); + process.exitCode = 1; + } finally { + await browser?.close(); + server.closeAllConnections(); + await new Promise((resolveClose) => server.close(resolveClose)); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { await main(); } catch { + console.error('esignet-protocol-proof: startup FAIL'); + process.exitCode = 1; + } +} diff --git a/scripts/gen-secrets.py b/scripts/gen-secrets.py index c4ab270..0c0bdef 100755 --- a/scripts/gen-secrets.py +++ b/scripts/gen-secrets.py @@ -29,9 +29,7 @@ "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", + "SOLMARA_ESIGNET_V2_KEYSTORE_PASSWORD", ) @@ -178,6 +176,12 @@ def ensure_operator_material() -> dict[str, str]: create_once(secret_root / f"{client}-client-key", p256_jwk) ensure_client_identifier(secret_root / f"{client}-client-id", client) + esignet = LOCAL / "esignet-v2" + esignet.mkdir(parents=True, exist_ok=True, mode=0o700) + esignet.chmod(0o700) + create_once(esignet / "psut-secret", raw_key) + create_once(esignet / "static-otp", lambda: "111111") + mint = LOCAL / "cells" / "mint" for directory in (mint / "secrets", mint / "clients", mint / "transit"): directory.mkdir(parents=True, exist_ok=True) diff --git a/scripts/seed-esignet.py b/scripts/seed-esignet.py index ae185a5..813bc46 100755 --- a/scripts/seed-esignet.py +++ b/scripts/seed-esignet.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Seed MOSIP eSignet for the Solmara portal client.""" +"""Register the portal through the native eSignet v2 private client API.""" from __future__ import annotations @@ -7,24 +7,25 @@ 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 +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError +from datetime import datetime, timezone 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", + "family_name", "name", "given_name", - "family_name", "gender", "birthdate", ] @@ -41,23 +42,6 @@ def run(args: list[str], *, input_text: str | None = None, capture: bool = False 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 @@ -127,12 +111,6 @@ def ensure_private_key() -> tuple[Path, str, str]: 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", @@ -158,86 +136,112 @@ def redirect_uris() -> list[str]: 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, +def client_request(jwk: str) -> dict: + return { + "clientId": CLIENT_ID, + "clientName": "Solmara Portal", + "clientNameLangMap": {"eng": "Solmara Portal"}, + "relyingPartyId": RELYING_PARTY_ID, + "logoUri": "https://id.registrystack.org/solmara/logo.svg", + "redirectUris": redirect_uris(), + "userClaims": DEFAULT_CLIENT_CLAIMS, + "authContextRefs": ["mosip:idp:acr:static-code"], + "publicKey": json.loads(jwk), + "grantTypes": ["authorization_code"], + "clientAuthMethods": ["private_key_jwt"], + "additionalConfig": { + "userinfo_response_type": "JWS", + "require_pkce": True, + "pkce_required": True, + + }, } - 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 admin_request(method: str, path: str, payload: dict | None = None) -> dict: + origin = os.environ.get("ESIGNET_ADMIN_URL", "http://esignet:8080").rstrip("/") + data = None if payload is None else json.dumps({ + "requestTime": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), "request": payload, + }).encode() + request = Request(origin + path, data=data, method=method, + headers={"Content-Type": "application/json"}) + with urlopen(request, timeout=10) as response: + return json.load(response) + + +def registration_binding(jwk: str) -> dict[str, str]: + public = json.loads(jwk) + canonical = json.dumps(public, sort_keys=True, separators=(",", ":")) + return {"client_id": CLIENT_ID, "relying_party_id": RELYING_PARTY_ID, + "public_key_sha256": hashlib.sha256(canonical.encode()).hexdigest()} + + +def binding_path() -> Path: + directory = Path(os.environ.get("ESIGNET_SEED_STATE_DIR", "/var/lib/esignet-seed/state")) + identifier = hashlib.sha256(CLIENT_ID.encode()).hexdigest() + return directory / f"{identifier}.json" + + +def require_registered_binding(jwk: str) -> None: + path = binding_path() + if not path.exists() or json.loads(path.read_text()) != registration_binding(jwk): + raise RuntimeError("existing eSignet registration key or relying party cannot be verified; " + "configure a fresh ESIGNET_CLIENT_ID and a fresh signing key, updating both matching portal values") + + +def save_registered_binding(jwk: str) -> None: + path = binding_path() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + path.write_text(json.dumps(registration_binding(jwk), sort_keys=True) + "\n") + path.chmod(0o600) + + +def seed_esignet(jwk: str, key_hash: str = "") -> None: + payload = client_request(jwk) + deadline = time.monotonic() + 180 + while True: + try: + current = admin_request("GET", "/client-mgmt/client/" + CLIENT_ID) + if (current.get("response") or {}).get("clientId") == CLIENT_ID: + require_registered_binding(jwk) + payload["status"] = "ACTIVE" + result = admin_request("PUT", "/client-mgmt/client/" + CLIENT_ID, payload) + else: + current_errors = current.get("errors") or [] + if not current_errors or any(e.get("errorCode") != "invalid_client_id" for e in current_errors): + raise RuntimeError("private eSignet client lookup failed") + result = admin_request("POST", "/client-mgmt/client", payload) + break + except HTTPError as exc: + if exc.code < 500: + raise RuntimeError(f"private eSignet client API rejected registration (HTTP {exc.code})") from None + if time.monotonic() >= deadline: + raise RuntimeError("timed out waiting for private eSignet client API") from None + time.sleep(2) + except (URLError, OSError): + if time.monotonic() >= deadline: + raise RuntimeError("timed out waiting for private eSignet client API") from None + time.sleep(2) + errors = result.get("errors") or [] + if any(error.get("errorCode") == "duplicate_client_id" for error in errors): + # Go's update API intentionally does not rotate the registered signing key. + # A changed key needs an explicit new client registration. + require_registered_binding(jwk) + payload["status"] = "ACTIVE" + result = admin_request("PUT", "/client-mgmt/client/" + CLIENT_ID, payload) + errors = result.get("errors") or [] + if errors or (result.get("response") or {}).get("clientId") != CLIENT_ID: + codes = ", ".join(str(error.get("errorCode", "unknown")) for error in errors) + raise RuntimeError("eSignet client registration failed: " + codes) + save_registered_binding(jwk) 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") + account_source = os.environ.get("ESIGNET_ACCOUNT_SOURCE_LABEL", "BREG population access profile esignet-source") print(f"Seeded eSignet client {CLIENT_ID}.") - print(f"Relay-backed account source: {account_source}.") + print(f"BREG-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") diff --git a/scripts/smoke-esignet-login.mjs b/scripts/smoke-esignet-login.mjs index aec3fa4..1c5a703 100644 --- a/scripts/smoke-esignet-login.mjs +++ b/scripts/smoke-esignet-login.mjs @@ -17,6 +17,9 @@ try { throw new Error('the portal signed in without eSignet'); } console.log('smoke-esignet-login: PASS'); +} catch { + console.error('smoke-esignet-login: FAIL'); + process.exitCode = 1; } finally { await browser.close(); } diff --git a/scripts/smoke-esignet.py b/scripts/smoke-esignet.py index dbb7431..66b4cfd 100755 --- a/scripts/smoke-esignet.py +++ b/scripts/smoke-esignet.py @@ -103,53 +103,30 @@ def normalize_url(value: str) -> str: 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, + oidc = wait_for_json( + "GET", f"{targets.esignet_url}/.well-known/openid-configuration", timeout=timeout ) - root_doc = wait_for_json( - "GET", - f"{targets.esignet_url}/.well-known/openid-configuration", - timeout=timeout, + oauth = wait_for_json( + "GET", f"{targets.esignet_url}/.well-known/oauth-authorization-server", timeout=timeout ) - root_oauth_doc = wait_for_json( - "GET", - f"{targets.esignet_url}/.well-known/oauth-authorization-server", - timeout=timeout, + ui = wait_for_json( + "GET", f"{targets.esignet_ui_url}/.well-known/openid-configuration", 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") + expected = { + "issuer": targets.esignet_url, + "authorization_endpoint": f"{targets.esignet_url}/oauth2/authorize", + "token_endpoint": f"{targets.esignet_url}/oauth2/token", + "jwks_uri": f"{targets.esignet_url}/oauth2/jwks", + } + for document in (oidc, oauth, ui): + for key, value in expected.items(): + if document.get(key) != value: + raise SmokeFailure(f"discovery {key} does not match the public eSignet endpoint") + if "private_key_jwt" not in document.get("token_endpoint_auth_methods_supported", []): + raise SmokeFailure("discovery does not support private_key_jwt") + for document in (oidc, ui): + if document.get("userinfo_endpoint") != f"{targets.esignet_url}/oauth2/userinfo": + raise SmokeFailure("discovery userinfo_endpoint does not match the public eSignet endpoint") def wait_for_json( @@ -168,7 +145,7 @@ def wait_for_json( except SmokeFailure as exc: last_error = str(exc) time.sleep(1) - raise SmokeFailure(f"{url}: {last_error or 'timed out'}") + raise SmokeFailure(last_error or "endpoint timed out") def request_json( @@ -184,16 +161,17 @@ def request_json( ) try: with urllib.request.urlopen(request, timeout=10) as response: - data = response.read() + data = response.read(1024 * 1024 + 1) + if len(data) > 1024 * 1024: + raise SmokeFailure("discovery response exceeded limit") 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 + raise SmokeFailure(f"discovery HTTP {exc.code}") from None + except (urllib.error.URLError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError): + raise SmokeFailure("discovery response unavailable or invalid") from None if __name__ == "__main__": diff --git a/scripts/start-esignet-relay.sh b/scripts/start-esignet-relay.sh index d8d2926..94aac47 100755 --- a/scripts/start-esignet-relay.sh +++ b/scripts/start-esignet-relay.sh @@ -1,49 +1,6 @@ -#!/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 "$@" +#!/bin/sh +# Compatibility launcher for operators of the native eSignet v2 image. +set -eu +: "${REGISTRY_ESIGNET_CONFIG_FILE:?set the native provider YAML path}" +: "${KEYMANAGER_PKCS12_PASSWORD:?set the eSignet host keystore password}" +exec /home/mosip/esignet "$@" diff --git a/scripts/test-esignet-protocol-proof.mjs b/scripts/test-esignet-protocol-proof.mjs new file mode 100644 index 0000000..3ccec56 --- /dev/null +++ b/scripts/test-esignet-protocol-proof.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { assertDisclosure, validateCallback, registration } from './esignet-protocol-proof.mjs'; + +test('disclosure proof catches extra cached attributes and changed subjects', () => { + const released = { sub: 'pairwise', name: 'Synthetic citizen', iss: 'https://issuer.test', aud: 'client', exp: 200 }; + assert.doesNotThrow(() => assertDisclosure(released, ['name'], 'pairwise')); + assert.throws(() => assertDisclosure({ ...released, gender: 'extra' }, ['name'], 'pairwise')); + assert.throws(() => assertDisclosure({ ...released, sub: 'another' }, ['name'], 'pairwise')); + assert.throws(() => assertDisclosure({ ...released, name: null }, ['name'], 'pairwise')); + assert.doesNotThrow(() => assertDisclosure({ sub: 'pairwise', iat: 100 }, [], 'pairwise')); + assert.throws(() => assertDisclosure(released, [], 'pairwise')); +}); + +test('callback proof rejects state confusion and code/error ambiguity', () => { + const pending = { redirectUri: 'http://127.0.0.1:4315/callback', state: 'expected' }; + assert.doesNotThrow(() => validateCallback(new URL(`${pending.redirectUri}?state=expected&code=one`), pending)); + assert.doesNotThrow(() => validateCallback(new URL(`${pending.redirectUri}?state=expected&error=access_denied`), pending)); + for (const query of ['state=wrong&code=one', 'state=expected&state=expected&code=one', + 'state=expected&code=one&code=two', 'state=expected&error=access_denied&code=one', 'state=expected']) { + assert.throws(() => validateCallback(new URL(`${pending.redirectUri}?${query}`), pending)); + } + assert.throws(() => validateCallback(new URL('http://127.0.0.1:4316/callback?state=expected&code=one'), pending)); +}); + +test('separate clients register JWS or JWE with only public verification and recipient keys', () => { + const sig = { kty: 'RSA', use: 'sig', alg: 'RS256', n: 'public-modulus', e: 'AQAB' }; + const enc = { kty: 'RSA', use: 'enc', alg: 'RSA-OAEP-256', n: 'different-public-modulus', e: 'AQAB' }; + const signed = registration('signed', sig, undefined, 'http://127.0.0.1:4315/callback'); + const encrypted = registration('encrypted', sig, enc, 'http://127.0.0.1:4315/callback'); + assert.equal(signed.additionalConfig.userinfo_response_type, 'JWS'); + assert.equal(signed.encPublicKey, undefined); + assert.equal(encrypted.additionalConfig.userinfo_response_type, 'JWE'); + assert.deepEqual(encrypted.encPublicKey, enc); + assert.deepEqual(encrypted.clientAuthMethods, ['private_key_jwt']); + assert.deepEqual(encrypted.authContextRefs, ['mosip:idp:acr:static-code']); + assert.equal(encrypted.additionalConfig.pkce_required, true); +}); diff --git a/scripts/test_hosted_esignet_topology.py b/scripts/test_hosted_esignet_topology.py index 4a7e1d1..6de2bdd 100644 --- a/scripts/test_hosted_esignet_topology.py +++ b/scripts/test_hosted_esignet_topology.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re import subprocess import tempfile import unittest @@ -19,6 +20,33 @@ class HostedEsignetTopologyTests(unittest.TestCase): + def test_portal_origin_matches_its_registered_callback(self) -> None: + for path in (CORE_COMPOSE_PATH, LOCAL_COMPOSE_PATH, ROOT / "compose.esignet-fixture.yaml"): + compose = yaml.safe_load(path.read_text(encoding="utf-8")) + portal = compose["services"]["portal"]["environment"] + self.assertTrue(portal["ORIGIN"].startswith("${SOLMARA_PORTAL_PUBLIC_BASE_URL:-")) + self.assertEqual(portal["PORTAL_ESIGNET_REDIRECT_URI"], portal["ORIGIN"] + "/auth/callback") + if path != CORE_COMPOSE_PATH: + seed = compose["services"]["esignet-seed"]["environment"] + self.assertEqual(seed["ESIGNET_CLIENT_REDIRECT_URIS_JSON"], '["' + portal["ORIGIN"] + '/auth/callback"]') + + def test_redis_health_requires_atomic_one_use_grant_consumption(self) -> None: + for path in (COMPOSE_PATH, LOCAL_COMPOSE_PATH, ROOT / "compose.esignet-fixture.yaml"): + compose = yaml.safe_load(path.read_text(encoding="utf-8")) + self.assertEqual(compose["services"]["esignet-redis"]["healthcheck"]["test"], [ + "CMD-SHELL", "redis-cli --raw COMMAND INFO GETDEL | grep -qx getdel", + ]) + + def test_native_flow_callback_is_proxied_without_exposing_administration(self) -> None: + for path in (TEMPLATE, LOCAL_TEMPLATE): + config = path.read_text(encoding="utf-8") + expression = re.search(r"location ~ (\S+) \{\n proxy_pass", config) + self.assertIsNotNone(expression) + route = re.compile(expression.group(1)) + self.assertIsNotNone(route.match("/oauth2/auth/callback")) + for private_path in ("/client-mgmt/client", "/system-info", "/oauth2/auth/admin", "/oauth2/auth/callback-admin"): + self.assertIsNone(route.match(private_path)) + def test_mint_and_relay_dependency_origins_are_not_operator_overridable( self, ) -> None: @@ -49,6 +77,7 @@ def test_compose_is_a_standalone_esignet_app(self) -> None: "esignet_ui", "esignet_edge", "esignet-seed", + "esignet-key-init", }, ) # The issuer origin is fronted by the proxy image, not by eSignet @@ -57,14 +86,10 @@ def test_compose_is_a_standalone_esignet_app(self) -> None: 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"]["environment"]["MOSIP_ESIGNET_AUTHN_PROVIDER"], "breg") + self.assertEqual(services["esignet"]["environment"]["REGISTRY_ESIGNET_CONFIG_FILE"], "/etc/registry-esignet/registry.yaml") + self.assertNotIn("ports", services["esignet"]) + self.assertTrue(all(name.startswith("esignet-v2-") for name in compose["volumes"])) self.assertEqual( services["esignet-seed"]["environment"][ "ESIGNET_CLIENT_REDIRECT_URIS_JSON" @@ -103,6 +128,14 @@ def test_renderer_accepts_only_dns_hosts_and_preserves_security_headers( rendered, ) + def test_identity_proxy_logs_exclude_query_and_error_request_context(self) -> None: + for path in (TEMPLATE, LOCAL_TEMPLATE): + config = path.read_text(encoding="utf-8") + self.assertIn("log_format identity_safe '$request_method $uri $status';", config) + self.assertIn("error_log /dev/stderr crit;", config) + self.assertNotIn("$request_uri", config) + self.assertNotIn("$http_referer", config) + def test_renderer_rejects_directive_injection(self) -> None: invalid_hosts = ( "login.example.org;return 200", diff --git a/scripts/test_image_pins.py b/scripts/test_image_pins.py index 109df3b..8ec6ee9 100644 --- a/scripts/test_image_pins.py +++ b/scripts/test_image_pins.py @@ -46,8 +46,8 @@ 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", + "ESIGNET_REDIS_IMAGE", "ESIGNET_NGINX_IMAGE", + "ESIGNET_POSTGRES_IMAGE", ): versions.write(f"{key}=example.invalid/image@sha256:{'6' * 64}\n") @@ -87,6 +87,17 @@ def test_local_edge_must_be_digest_pinned(self) -> None: self.assertEqual(result, 1) self.assertIn("LOCAL_EDGE_IMAGE must use image@sha256", stderr) + def test_esignet_redis_must_be_digest_pinned(self) -> None: + path = self.root / "versions.env" + lines = path.read_text().splitlines() + path.write_text("\n".join( + "ESIGNET_REDIS_IMAGE=redis:8" if line.startswith("ESIGNET_REDIS_IMAGE=") else line + for line in lines + ) + "\n") + result, stderr = self.run_check() + self.assertEqual(result, 1) + self.assertIn("ESIGNET_REDIS_IMAGE must use image@sha256", stderr) + def test_runtime_images_must_use_their_exact_official_repository(self) -> None: versions = (self.root / "versions.env").read_text().replace( EVIDENCE, diff --git a/scripts/test_registry_stack_release_pin.py b/scripts/test_registry_stack_release_pin.py index 7aa2489..5e33f5b 100644 --- a/scripts/test_registry_stack_release_pin.py +++ b/scripts/test_registry_stack_release_pin.py @@ -12,16 +12,15 @@ 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", + "ESIGNET_AUTHENTICATOR_VERSION": "0.3.0", + "ESIGNET_SOURCE_VERSION": "2.0.0-beta.1", + "ESIGNET_SOURCE_COMMIT": "df0d0e771dae16eb2597b8e5b5dc65e70baa7f86", + "ESIGNET_SOURCE_ARCHIVE_SHA256": "e" * 64, } + 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()} diff --git a/scripts/test_runtime_topology.py b/scripts/test_runtime_topology.py index 6f96a84..d54df72 100644 --- a/scripts/test_runtime_topology.py +++ b/scripts/test_runtime_topology.py @@ -754,6 +754,7 @@ def test_hosted_esignet_is_standalone_and_core_portal_owns_login_config( "esignet_ui", "esignet_edge", "esignet-seed", + "esignet-key-init", }, ) self.assertNotIn("portal", services) @@ -769,13 +770,9 @@ def test_hosted_esignet_is_standalone_and_core_portal_owns_login_config( "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", + "PORTAL_ESIGNET_SCOPE": "${PORTAL_ESIGNET_SCOPE:-openid}", + "PORTAL_ESIGNET_SUBJECT_CLAIM": "individual_id", } for key, value in expected.items(): self.assertEqual(portal[key], value) @@ -798,6 +795,7 @@ def test_hosted_esignet_is_standalone_and_core_portal_owns_login_config( ) 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 diff --git a/scripts/test_seed_esignet.py b/scripts/test_seed_esignet.py new file mode 100644 index 0000000..583fb10 --- /dev/null +++ b/scripts/test_seed_esignet.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib.util +import json +import io +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('seed_esignet', Path(__file__).with_name('seed-esignet.py')) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class SeedEsignetTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + environment = patch.dict(MODULE.os.environ, {'ESIGNET_SEED_STATE_DIR': self.directory.name}) + environment.start() + self.addCleanup(environment.stop) + + def test_changed_key_or_rp_cannot_update_existing_registration(self): + MODULE.save_registered_binding('{"kty":"RSA", "n":"original"}') + with patch.object(MODULE, 'admin_request', return_value={'response': {'clientId': MODULE.CLIENT_ID}}) as request: + with self.assertRaisesRegex(RuntimeError, 'fresh ESIGNET_CLIENT_ID'): + MODULE.seed_esignet('{"kty":"RSA", "n":"changed"}') + self.assertEqual(request.call_count, 1) + with patch.object(MODULE, 'RELYING_PARTY_ID', 'another-rp'): + with self.assertRaisesRegex(RuntimeError, 'fresh ESIGNET_CLIENT_ID'): + MODULE.require_registered_binding('{"kty":"RSA", "n":"original"}') + + def test_existing_registration_without_evidence_fails_closed(self): + with patch.object(MODULE, 'admin_request', return_value={'response': {'clientId': MODULE.CLIENT_ID}}) as request: + with self.assertRaisesRegex(RuntimeError, 'cannot be verified'): + MODULE.seed_esignet('{}') + self.assertEqual(request.call_count, 1) + + def test_registration_is_otp_only_and_requires_pkce(self): + payload = MODULE.client_request(json.dumps({'kty': 'RSA', 'e': 'AQAB', 'n': 'public-test-key'})) + self.assertEqual(payload['authContextRefs'], ['mosip:idp:acr:static-code']) + self.assertEqual(payload['clientAuthMethods'], ['private_key_jwt']) + self.assertTrue(payload['additionalConfig']['require_pkce']) + self.assertNotIn('consent_expire_in_mins', payload['additionalConfig']) + self.assertIsInstance(payload['publicKey'], dict) + + def test_admin_timestamp_matches_native_millisecond_contract(self): + with patch.object(MODULE, 'urlopen', return_value=io.BytesIO(b'{"response": {}}')) as transport: + MODULE.admin_request('POST', '/client-mgmt/client', {}) + request = transport.call_args.args[0] + timestamp = json.loads(request.data)['requestTime'] + self.assertRegex(timestamp, r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$') + + def test_duplicate_registration_uses_native_update_api(self): + MODULE.save_registered_binding("{}") + with patch.object(MODULE, 'admin_request', side_effect=[ + {'response': {'clientId': MODULE.CLIENT_ID}}, + {'response': {'clientId': MODULE.CLIENT_ID}}, + ]) as request: + MODULE.seed_esignet('{}') + self.assertEqual(request.call_args_list[0].args[:2], ('GET', '/client-mgmt/client/' + MODULE.CLIENT_ID)) + self.assertEqual(request.call_args_list[1].args[:2], ('PUT', '/client-mgmt/client/' + MODULE.CLIENT_ID)) + + def test_api_errors_fail_closed_without_response_detail(self): + with patch.object(MODULE, 'admin_request', side_effect=[ + {'errors': [{'errorCode': 'invalid_client_id'}]}, + {'errors': [{'errorCode': 'invalid_public_key', 'errorMessage': 'sensitive-value'}]}, + ]): + with self.assertRaisesRegex(RuntimeError, 'invalid_public_key') as raised: + MODULE.seed_esignet('{}') + self.assertNotIn('sensitive-value', str(raised.exception)) + + def test_hosted_callback_rejects_loopback_and_http(self): + for callback in ['http://portal.example/auth/callback', 'https://127.0.0.1/auth/callback']: + with self.subTest(callback=callback), patch.dict(MODULE.os.environ, { + 'ESIGNET_REQUIRE_HTTPS_REDIRECTS': 'true', + 'ESIGNET_CLIENT_REDIRECT_URIS_JSON': json.dumps([callback]), + }): + with self.assertRaises(ValueError): + MODULE.redirect_uris() + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_smoke_esignet.py b/scripts/test_smoke_esignet.py index ea194a2..3a38ae2 100644 --- a/scripts/test_smoke_esignet.py +++ b/scripts/test_smoke_esignet.py @@ -83,94 +83,16 @@ def url(self) -> str: 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_local_and_hosted_profiles_select_native_breg_provider(self) -> None: + for filename in ("compose.esignet.yaml", "compose.coolify.esignet.yaml", "compose.esignet-fixture.yaml"): + compose = yaml.safe_load((ROOT / filename).read_text()) + environment = compose["services"]["esignet"]["environment"] + self.assertEqual(environment["MOSIP_ESIGNET_AUTHN_PROVIDER"], "breg") + self.assertEqual(environment["MOSIP_ESIGNET_AUTH_FLOW_ID"], "flow-breg-otp") + self.assertIn("REGISTRY_ESIGNET_CONFIG_FILE", environment) + self.assertNotIn("REGISTRY_MINT_PRIVATE_JWK", environment) + self.assertNotIn("plugin_name_env", environment) + self.assertNotIn("SPRING_APPLICATION_JSON", environment) def test_load_env_file_handles_quoted_values(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -183,21 +105,42 @@ def test_load_env_file_handles_quoted_values(self) -> None: {"TOKEN": "secret value", "EMPTY": ""}, ) - def test_discovery_requires_root_and_mosip_paths_to_share_issuer(self) -> None: + def discovery_document(self, url: str) -> dict[str, Any]: + return { + "issuer": url, + "authorization_endpoint": f"{url}/oauth2/authorize", + "token_endpoint": f"{url}/oauth2/token", + "userinfo_endpoint": f"{url}/oauth2/userinfo", + "jwks_uri": f"{url}/oauth2/jwks", + "token_endpoint_auth_methods_supported": ["private_key_jwt"], + } + + def test_native_discovery_requires_public_endpoints_and_client_authentication(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), - } - ) + document = self.discovery_document(server.url) + server.routes.update({ + ("GET", "/.well-known/openid-configuration"): (200, document), + ("GET", "/.well-known/oauth-authorization-server"): (200, document), + }) targets = smoke_esignet.SmokeTargets(server.url, server.url) smoke_esignet.check_esignet_discovery(targets, timeout=2) + for key in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri", "userinfo_endpoint"): + with self.subTest(key=key): + saved = document[key] + document[key] = "http://private-service.invalid/endpoint" + with self.assertRaises(smoke_esignet.SmokeFailure): + smoke_esignet.check_esignet_discovery(targets, timeout=2) + document[key] = saved + document["token_endpoint_auth_methods_supported"] = ["client_secret_post"] + with self.assertRaises(smoke_esignet.SmokeFailure): + smoke_esignet.check_esignet_discovery(targets, timeout=2) + + def test_dependency_response_body_is_not_a_diagnostic(self) -> None: + with StubServer({("GET", "/broken"): (500, {"secret": "sensitive-canary"})}) as server: + with self.assertRaises(smoke_esignet.SmokeFailure) as caught: + smoke_esignet.request_json("GET", f"{server.url}/broken") + self.assertEqual(str(caught.exception), "discovery HTTP 500") + self.assertIsNone(caught.exception.__cause__) def test_main_does_not_require_a_static_relay_credential(self) -> None: with mock.patch.object(smoke_esignet, "check_esignet_discovery") as discovery: diff --git a/versions.env b/versions.env index bc1e182..e376cad 100644 --- a/versions.env +++ b/versions.env @@ -21,15 +21,17 @@ 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 +# Redis 8.10.1, official multiarch index; native one-use grants require GETDEL (Redis >=6.2). +ESIGNET_REDIS_IMAGE=redis@sha256:298e5b3bc566bade82f46ad5511777a4a07a294097ce16ada2f6a42be5239df5 +# Native eSignet host and UI share this exact upstream source. +ESIGNET_SOURCE_VERSION=2.0.0-beta.1 +ESIGNET_SOURCE_COMMIT=df0d0e771dae16eb2597b8e5b5dc65e70baa7f86 +ESIGNET_SOURCE_ARCHIVE_SHA256=6fee27d46e4da6080bb43f1ba6593f2fc1e3188808f8f3a19e5e9fcf86428e32 +ESIGNET_NGINX_IMAGE=nginx@sha256:ce2bd4775ed6859d35f47d65401ee9f35f1dd00b32ed05f0ce38b68aa1830195 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 +ESIGNET_AUTHENTICATOR_VERSION=0.3.0 +# Explicit local build input. Hosted manifests must replace this with a verified digest. +SOLMARA_ESIGNET_CANDIDATE_IMAGE=esignet-relay-authenticator:0.3.0-candidate SOLMARA_SCENARIO_RUNNER_IMAGE=solmara-lab-scenario-runner:local SOLMARA_CHILD_BENEFIT_FEDERATOR_IMAGE=solmara-lab-scenario-runner:local From 4af57303bd320d7dfe2e484413d03b90f4b6e851 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 9 Sep 2026 17:27:09 +0700 Subject: [PATCH 2/3] Close SQLite and HTTP resources in lab validation Signed-off-by: Jeremi Joslin --- generator/solmara_lab/publisher.py | 9 ++--- generator/tests/test_publisher.py | 37 ++++++++++++++++++-- scripts/lifecycle_proof.py | 5 +-- scripts/live-lifecycle-proof.py | 5 +-- scripts/local-relay-source-publisher.py | 12 ++++--- scripts/provision-hosted-runtime.py | 3 +- scripts/smoke-esignet.py | 1 + scripts/test_live_lifecycle_proof.py | 3 +- scripts/test_local_relay_source_publisher.py | 20 ++++++++--- scripts/test_provision_hosted_runtime.py | 5 +-- scripts/test_publish_runtime_extracts.py | 7 ++-- scripts/test_smoke_esignet.py | 11 ++++++ 12 files changed, 92 insertions(+), 26 deletions(-) diff --git a/generator/solmara_lab/publisher.py b/generator/solmara_lab/publisher.py index bfc06cb..2c07311 100644 --- a/generator/solmara_lab/publisher.py +++ b/generator/solmara_lab/publisher.py @@ -8,6 +8,7 @@ import sqlite3 import stat import tempfile +from contextlib import closing from collections.abc import Callable, Iterable, Sequence from datetime import UTC, datetime, timedelta from pathlib import Path @@ -175,7 +176,7 @@ def _replace_database( if temporary.exists(): temporary.unlink() try: - with sqlite3.connect(temporary) as connection: + with closing(sqlite3.connect(temporary)) as connection, connection: _configure(connection) populate(connection) _finish(connection) @@ -199,7 +200,7 @@ def _create_immutable_database( os.close(descriptor) temporary = Path(temporary_name) try: - with sqlite3.connect(temporary) as connection: + with closing(sqlite3.connect(temporary)) as connection, connection: _configure(connection) populate(connection) _finish(connection) @@ -799,7 +800,7 @@ def validate_extract( raise ExtractValidationError("extract filename does not match its binding") try: - with sqlite3.connect(_database_uri(path), uri=True) as connection: + with closing(sqlite3.connect(_database_uri(path), uri=True)) as connection, connection: if connection.execute("PRAGMA quick_check").fetchall() != [("ok",)]: raise ExtractValidationError("extract integrity check failed") metadata_columns = tuple( @@ -910,7 +911,7 @@ def mutate_mosd_state( ) -> None: database = database.resolve() before = database.stat() - with sqlite3.connect(database) as connection: + with closing(sqlite3.connect(database)) as connection, connection: _configure(connection) current = connection.execute( "SELECT record_id, lifecycle_state FROM beneficiary_enrolment_source WHERE uin = ?", diff --git a/generator/tests/test_publisher.py b/generator/tests/test_publisher.py index 54f84b5..13b821d 100644 --- a/generator/tests/test_publisher.py +++ b/generator/tests/test_publisher.py @@ -7,8 +7,11 @@ import sys import tempfile import unittest +from contextlib import closing from pathlib import Path +from unittest.mock import patch +from solmara_lab import publisher from solmara_lab.generate import OBSERVED_AT from solmara_lab.publisher import ( DEFAULT_EXTRACTS, @@ -33,7 +36,7 @@ def digest(path: Path) -> str: def query(path: Path, statement: str, parameters: tuple[object, ...] = ()) -> list[tuple]: - with sqlite3.connect(path) as connection: + with closing(sqlite3.connect(path)) as connection, connection: return connection.execute(statement, parameters).fetchall() @@ -54,6 +57,36 @@ def setUp(self) -> None: def tearDown(self) -> None: self.temporary.cleanup() + def test_publication_closes_connections_on_success_and_failure(self) -> None: + original_connect = sqlite3.connect + opened = [] + + def connect(*args, **kwargs): + connection = original_connect(*args, **kwargs) + opened.append(connection) + return connection + + def populate(connection): + connection.execute("CREATE TABLE fixture (value TEXT)") + connection.execute("INSERT INTO fixture VALUES ('retained')") + + def fail(connection): + populate(connection) + raise RuntimeError("fixture population failed") + + for publish in (publisher._replace_database, publisher._create_immutable_database): + with self.subTest(publish=publish.__name__): + target = self.root / f"{publish.__name__}.sqlite" + with patch.object(publisher.sqlite3, "connect", side_effect=connect): + publish(target, populate) + with self.assertRaisesRegex(RuntimeError, "fixture population failed"): + publish(target.with_name("failed-" + target.name), fail) + self.assertEqual(query(target, "SELECT value FROM fixture"), [("retained",)]) + self.assertEqual(len(opened), 4) + for connection in opened: + with self.assertRaises(sqlite3.ProgrammingError): + connection.execute("SELECT 1") + def test_repeatable_schema_content_and_bytes(self) -> None: relay_paths = sorted((self.root / RELAY_DIRECTORY).glob("*.sqlite")) before_republish = {path: digest(path) for path in relay_paths} @@ -417,7 +450,7 @@ def test_extract_validation_refuses_metadata_mismatch_and_extra_columns(self) -> self.root, "sro", "2026-08-12T10:00:00Z", extract_id ) path.chmod(0o644) - with sqlite3.connect(path) as connection: + with closing(sqlite3.connect(path)) as connection, connection: if mismatch == "publisher": connection.execute( "UPDATE evidence_extract SET publisher = ?", diff --git a/scripts/lifecycle_proof.py b/scripts/lifecycle_proof.py index 0b8d2db..778e6d6 100755 --- a/scripts/lifecycle_proof.py +++ b/scripts/lifecycle_proof.py @@ -11,6 +11,7 @@ import stat import sys import tempfile +from contextlib import closing from datetime import datetime, timedelta, timezone from pathlib import Path @@ -87,7 +88,7 @@ def _validate_sro_extract( if stat.S_IMODE(path.stat().st_mode) & 0o222: raise LifecycleProofError("extract has a writable mode") - with sqlite3.connect(_database_uri(path), uri=True) as connection: + with closing(sqlite3.connect(_database_uri(path), uri=True)) as connection, connection: if connection.execute("PRAGMA quick_check").fetchone() != ("ok",): raise LifecycleProofError("extract integrity check failed") metadata = connection.execute( @@ -182,7 +183,7 @@ def _publish_changed_sro_extract( staging_root = root / ".lifecycle-staging" / extract_id staged = publish_extract(staging_root, "sro", published_at, extract_id) staged.chmod(0o600) - with sqlite3.connect(staged) as connection: + with closing(sqlite3.connect(staged)) as connection, connection: current = connection.execute( """ SELECT record_id, lifecycle_state, recorded_at diff --git a/scripts/live-lifecycle-proof.py b/scripts/live-lifecycle-proof.py index c567c89..9daba50 100755 --- a/scripts/live-lifecycle-proof.py +++ b/scripts/live-lifecycle-proof.py @@ -20,6 +20,7 @@ import sys import tempfile import time +from contextlib import closing from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -295,7 +296,7 @@ def _publish_changed_sro(root: Path) -> ExtractPublication: original_mode = stat.S_IMODE(staged.stat().st_mode) try: staged.chmod(original_mode | stat.S_IWUSR) - with sqlite3.connect(staged) as connection: + with closing(sqlite3.connect(staged)) as connection, connection: row = connection.execute( """ SELECT record_id, lifecycle_state, recorded_at @@ -367,7 +368,7 @@ def _publish_invalid_sro(root: Path, *, stale: bool) -> Path: original_mode = stat.S_IMODE(path.stat().st_mode) path.chmod(original_mode | stat.S_IWUSR) try: - with sqlite3.connect(path) as connection: + with closing(sqlite3.connect(path)) as connection, connection: connection.execute( "UPDATE evidence_extract SET published_at = ?", ("invalid",) ) diff --git a/scripts/local-relay-source-publisher.py b/scripts/local-relay-source-publisher.py index ddc3974..44676c4 100644 --- a/scripts/local-relay-source-publisher.py +++ b/scripts/local-relay-source-publisher.py @@ -17,6 +17,7 @@ import stat import sys import tempfile +from contextlib import closing from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -103,7 +104,7 @@ def _structural_schema(path: Path, authority: str) -> tuple[Any, ...]: try: if not path.is_file() or path.is_symlink(): raise _fail() - with _connect_read_only(path) as connection: + with closing(_connect_read_only(path)) as connection, connection: integrity = connection.execute("PRAGMA quick_check").fetchall() if integrity != [("ok",)]: raise _fail() @@ -285,6 +286,7 @@ def _validate_mosd(authority: str, database: Path, seed: Path) -> None: def _open_mutable(database: Path) -> sqlite3.Connection: + connection = None try: connection = sqlite3.connect(database) connection.execute("PRAGMA journal_mode = DELETE") @@ -293,6 +295,8 @@ def _open_mutable(database: Path) -> sqlite3.Connection: connection.execute("PRAGMA secure_delete = ON") return connection except sqlite3.Error: + if connection is not None: + connection.close() raise _fail() from None @@ -320,7 +324,7 @@ def begin_proof(authority: str, database: Path, seed: Path) -> None: _read_backup(database) return try: - with _open_mutable(database) as connection: + with closing(_open_mutable(database)) as connection, connection: row = _control_row(connection) _write_backup(database, _backup_envelope(database, row)) except PublisherError: @@ -354,7 +358,7 @@ def set_proof_state(authority: str, database: Path, seed: Path) -> None: payload = _read_backup(database) before = database.stat() try: - with _open_mutable(database) as connection: + with closing(_open_mutable(database)) as connection, connection: connection.execute("BEGIN IMMEDIATE") row = _control_row(connection) if row[0] != payload["row"][0] or row[4] != payload["row"][4]: @@ -394,7 +398,7 @@ def restore_proof(authority: str, database: Path, seed: Path) -> None: before = database.stat() row = payload["row"] try: - with _open_mutable(database) as connection: + with closing(_open_mutable(database)) as connection, connection: connection.execute("BEGIN IMMEDIATE") current = _control_row(connection) if current[0] != row[0] or current[4] != row[4]: diff --git a/scripts/provision-hosted-runtime.py b/scripts/provision-hosted-runtime.py index e2f4f47..0d572c4 100644 --- a/scripts/provision-hosted-runtime.py +++ b/scripts/provision-hosted-runtime.py @@ -16,6 +16,7 @@ import stat import sys import tempfile +from contextlib import closing from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path @@ -778,7 +779,7 @@ def _publication_time( raise ProvisionError("invalid existing extract") extract_name = bound_path.name existing = extract_output / extract_name - with sqlite3.connect(f"file:{existing}?mode=ro", uri=True) as connection: + with closing(sqlite3.connect(f"file:{existing}?mode=ro", uri=True)) as connection, connection: rows = connection.execute( "SELECT published_at, extract_id FROM evidence_extract" ).fetchall() diff --git a/scripts/smoke-esignet.py b/scripts/smoke-esignet.py index 66b4cfd..69a16df 100755 --- a/scripts/smoke-esignet.py +++ b/scripts/smoke-esignet.py @@ -169,6 +169,7 @@ def request_json( raise SmokeFailure("response was not a JSON object") return value except urllib.error.HTTPError as exc: + exc.close() raise SmokeFailure(f"discovery HTTP {exc.code}") from None except (urllib.error.URLError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError): raise SmokeFailure("discovery response unavailable or invalid") from None diff --git a/scripts/test_live_lifecycle_proof.py b/scripts/test_live_lifecycle_proof.py index 8f47df7..f546c1c 100644 --- a/scripts/test_live_lifecycle_proof.py +++ b/scripts/test_live_lifecycle_proof.py @@ -9,6 +9,7 @@ import sys import tempfile import unittest +from contextlib import closing from contextlib import redirect_stderr, redirect_stdout from pathlib import Path @@ -226,7 +227,7 @@ def test_changed_extract_is_staged_then_published_read_only(self) -> None: observed_at=lifecycle._timestamp(lifecycle._now()), expected_extract_id=publication.extract_id, ) - with sqlite3.connect(publication.path) as connection: + with closing(sqlite3.connect(publication.path)) as connection, connection: row = connection.execute( "SELECT poverty_band FROM poverty_evidence WHERE uin = ?", (lifecycle.SRO_CONTROL_SUBJECT,), diff --git a/scripts/test_local_relay_source_publisher.py b/scripts/test_local_relay_source_publisher.py index 9f7052e..3bbc1c2 100644 --- a/scripts/test_local_relay_source_publisher.py +++ b/scripts/test_local_relay_source_publisher.py @@ -8,7 +8,9 @@ import sys import tempfile import unittest +from contextlib import closing from pathlib import Path +from unittest.mock import Mock, patch SCRIPT = Path(__file__).with_name("local-relay-source-publisher.py") SPEC = importlib.util.spec_from_file_location("local_relay_source_publisher", SCRIPT) @@ -28,7 +30,7 @@ def create_mosd_database(path: Path, *, include_control: bool = True) -> None: path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(path) as connection: + with closing(sqlite3.connect(path)) as connection, connection: connection.executescript( """ PRAGMA journal_mode = DELETE; @@ -56,7 +58,7 @@ def create_mosd_database(path: Path, *, include_control: bool = True) -> None: def create_cra_database(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(path) as connection: + with closing(sqlite3.connect(path)) as connection, connection: connection.executescript( """ PRAGMA journal_mode = DELETE; @@ -78,7 +80,7 @@ def create_cra_database(path: Path) -> None: def read_control(path: Path) -> tuple[object, ...]: - with sqlite3.connect(path) as connection: + with closing(sqlite3.connect(path)) as connection, connection: row = connection.execute( """ SELECT record_id, record_revision, lifecycle_state, recorded_at, @@ -93,6 +95,14 @@ def read_control(path: Path) -> tuple[object, ...]: class LocalRelaySourcePublisherTests(unittest.TestCase): + def test_failed_connection_configuration_closes_database(self) -> None: + connection = Mock(spec=sqlite3.Connection) + connection.execute.side_effect = sqlite3.OperationalError("fixture setup failure") + with patch.object(MODULE.sqlite3, "connect", return_value=connection): + with self.assertRaises(MODULE.PublisherError): + MODULE._open_mutable(Path("unused.sqlite")) + connection.close.assert_called_once_with() + def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.root = Path(self.temporary.name) @@ -107,7 +117,7 @@ def test_seed_is_published_once_and_existing_content_is_preserved(self) -> None: MODULE.ensure_seeded("mosd", self.database, self.seed) original_inode = self.database.stat().st_ino self.assertEqual(stat.S_IMODE(self.database.stat().st_mode), 0o644) - with sqlite3.connect(self.database) as connection: + with closing(sqlite3.connect(self.database)) as connection, connection: connection.execute( """ UPDATE beneficiary_enrolment_source @@ -140,7 +150,7 @@ def test_authority_filename_and_schema_isolation_are_enforced(self) -> None: def test_existing_database_must_match_the_seed_structure_exactly(self) -> None: MODULE.ensure_seeded("mosd", self.database, self.seed) inode = self.database.stat().st_ino - with sqlite3.connect(self.database) as connection: + with closing(sqlite3.connect(self.database)) as connection, connection: connection.execute("DROP VIEW relay_beneficiary_enrolment") connection.execute( """ diff --git a/scripts/test_provision_hosted_runtime.py b/scripts/test_provision_hosted_runtime.py index a50c833..69728cf 100644 --- a/scripts/test_provision_hosted_runtime.py +++ b/scripts/test_provision_hosted_runtime.py @@ -14,6 +14,7 @@ import sys import tempfile import unittest +from contextlib import closing from io import StringIO from pathlib import Path from types import SimpleNamespace @@ -879,7 +880,7 @@ def test_existing_direct_extract_publication_is_reused(self) -> None: published_at = "2026-08-12T09:00:00Z" extract_id = "sro-poverty-20260812T090000Z" extract = extracts / f"{extract_id}.sqlite" - with sqlite3.connect(extract) as connection: + with closing(sqlite3.connect(extract)) as connection, connection: connection.execute( "CREATE TABLE evidence_extract (published_at TEXT, publisher TEXT, extract_id TEXT)" ) @@ -1478,7 +1479,7 @@ def test_tampered_extract_past_its_serving_age_is_still_refused(self) -> None: # Relaxing the age ceiling must not relax integrity. with self._published_extract(bind=True) as (root, runtime, extracts, extract): extract.chmod(0o644) - with sqlite3.connect(extract) as connection: + with closing(sqlite3.connect(extract)) as connection, connection: connection.execute("UPDATE evidence_extract SET publisher = 'did:web:impostor'") extract.chmod(0o444) with self.assertRaisesRegex( diff --git a/scripts/test_publish_runtime_extracts.py b/scripts/test_publish_runtime_extracts.py index 932eec5..8a616bd 100644 --- a/scripts/test_publish_runtime_extracts.py +++ b/scripts/test_publish_runtime_extracts.py @@ -10,6 +10,7 @@ import sys import tempfile import unittest +from contextlib import closing from pathlib import Path from unittest import mock @@ -97,7 +98,7 @@ def test_fresh_publication_binds_only_generated_runtime_configs(self) -> None: ) extract = self.root / item["path"] self.assertEqual(stat.S_IMODE(extract.stat().st_mode), 0o444) - with sqlite3.connect(extract) as connection: + with closing(sqlite3.connect(extract)) as connection, connection: self.assertEqual( connection.execute( "SELECT published_at, publisher, extract_id FROM evidence_extract" @@ -116,7 +117,7 @@ def test_default_publication_uses_one_explicit_current_utc_time(self) -> None: clock.assert_called_once_with() for authority, item in result.items(): extract = self.root / item["path"] - with sqlite3.connect(extract) as connection: + with closing(sqlite3.connect(extract)) as connection, connection: self.assertEqual( connection.execute( "SELECT published_at FROM evidence_extract" @@ -188,7 +189,7 @@ def test_metadata_mismatch_fails_before_any_new_publication_or_binding(self) -> cra = Path(self._runtime_binding("cra")).name cra_extract = self.root / PUBLISHER.EVIDENCE_DIRECTORY / cra cra_extract.chmod(0o644) - with sqlite3.connect(cra_extract) as connection: + with closing(sqlite3.connect(cra_extract)) as connection, connection: connection.execute( "UPDATE evidence_extract SET publisher = ?", (PUBLISHER.PUBLISHERS["nia"],), diff --git a/scripts/test_smoke_esignet.py b/scripts/test_smoke_esignet.py index 3a38ae2..f22d4ee 100644 --- a/scripts/test_smoke_esignet.py +++ b/scripts/test_smoke_esignet.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import io import json import sys import tempfile @@ -142,6 +143,16 @@ def test_dependency_response_body_is_not_a_diagnostic(self) -> None: self.assertEqual(str(caught.exception), "discovery HTTP 500") self.assertIsNone(caught.exception.__cause__) + def test_dependency_http_error_closes_its_response(self) -> None: + body = io.BytesIO(b"sensitive-canary") + error = smoke_esignet.urllib.error.HTTPError( + "http://unused.invalid", 500, "failure", {}, body + ) + with mock.patch.object(smoke_esignet.urllib.request, "urlopen", side_effect=error): + with self.assertRaisesRegex(smoke_esignet.SmokeFailure, "^discovery HTTP 500$"): + smoke_esignet.request_json("GET", "http://unused.invalid") + self.assertTrue(body.closed) + 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( From 90aed702bed8954745f6674d9bf702cd4f194d82 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 9 Sep 2026 17:30:35 +0700 Subject: [PATCH 3/3] Refresh compatible eSignet UI dependencies and verify locked build Signed-off-by: Jeremi Joslin --- docker/esignet-ui/Dockerfile | 12 +- docker/esignet-ui/README.md | 43 ++ docker/esignet-ui/dependencies.patch | 578 +++++++++++++++++++++++++++ docs/esignet.md | 5 + 4 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 docker/esignet-ui/README.md create mode 100644 docker/esignet-ui/dependencies.patch diff --git a/docker/esignet-ui/Dockerfile b/docker/esignet-ui/Dockerfile index 08e5aab..5187b11 100644 --- a/docker/esignet-ui/Dockerfile +++ b/docker/esignet-ui/Dockerfile @@ -1,20 +1,24 @@ # syntax=docker/dockerfile:1 ARG NODE_BUILD_IMAGE ARG ESIGNET_NGINX_IMAGE +ARG ESIGNET_UI_LOCK_SHA256=bf4336641b4a07f3b1e5ad282db9753d5fd3171bbf5ff0268bab345f2d8f79e6 FROM ${NODE_BUILD_IMAGE} AS ui-build ARG ESIGNET_SOURCE_COMMIT ARG ESIGNET_SOURCE_ARCHIVE_SHA256 WORKDIR /src ADD --checksum=sha256:${ESIGNET_SOURCE_ARCHIVE_SHA256} https://api.github.com/repos/mosip/esignet/tarball/${ESIGNET_SOURCE_COMMIT} /tmp/esignet.tar.gz RUN tar -xzf /tmp/esignet.tar.gz --strip-components=1 && rm /tmp/esignet.tar.gz -COPY docker/esignet-ui/default-locale.patch /tmp/default-locale.patch -RUN apk add --no-cache git && git apply --check /tmp/default-locale.patch && git apply /tmp/default-locale.patch +COPY docker/esignet-ui/default-locale.patch docker/esignet-ui/dependencies.patch /tmp/ +RUN apk add --no-cache git && git apply --check /tmp/default-locale.patch /tmp/dependencies.patch && git apply /tmp/default-locale.patch /tmp/dependencies.patch WORKDIR /src/oidc-ui -RUN npm ci && VITE_API_URL= npm run build +ARG ESIGNET_UI_LOCK_SHA256 +RUN echo "$ESIGNET_UI_LOCK_SHA256 package-lock.json" | sha256sum -c - && npm ci && VITE_API_URL= npm run build FROM ${ESIGNET_NGINX_IMAGE} ARG ESIGNET_SOURCE_COMMIT -LABEL org.opencontainers.image.revision=${ESIGNET_SOURCE_COMMIT} \ +ARG ESIGNET_UI_LOCK_SHA256 +LABEL io.registry.esignet.ui.lock.sha256=${ESIGNET_UI_LOCK_SHA256} \ + org.opencontainers.image.revision=${ESIGNET_SOURCE_COMMIT} \ org.opencontainers.image.version="2.0.0-beta.1" ARG ESIGNET_NGINX_CONF=config/esignet/nginx-hosted.conf COPY --from=ui-build /src/oidc-ui/dist /usr/share/nginx/html diff --git a/docker/esignet-ui/README.md b/docker/esignet-ui/README.md new file mode 100644 index 0000000..c86c716 --- /dev/null +++ b/docker/esignet-ui/README.md @@ -0,0 +1,43 @@ +# Native eSignet UI build + +The image builds the exact eSignet source pinned in `versions.env`, applies +`default-locale.patch` and `dependencies.patch`, then uses `npm ci` to produce a +static UI. Both patches must apply cleanly. The Dockerfile verifies the patched +lockfile SHA256 and records it as `io.registry.esignet.ui.lock.sha256` in the +image. Node and test tools are not copied into the nginx runtime. + +The dependency patch retains Thunder React 0.11.2 and its existing API. It +updates compatible Axios, React Router, CSS/browser-data and test dependencies. +An exact DOMPurify 3.4.15 override replaces the sanitizer version pinned by that +Thunder release. Remove the override when a compatible Thunder update supplies +an appropriate sanitizer itself. + +## Validate or refresh + +Use a disposable checkout of the exact upstream commit from `versions.env`. +Apply both patches from this directory at that checkout's root, then run these +commands in its `oidc-ui` directory: + +```sh +npm ci +npm test +npm run build +npm run lint +npm audit --package-lock-only +npm audit --omit=dev --package-lock-only +``` + +The pinned upstream source has existing lint diagnostics. Compare the exact +file/rule/location set before and after a dependency update; do not hide or +reclassify diagnostics. Tests and production build must pass. + +Generate dependency changes with npm 12.0.2, which supports the Node version +used by this build. npm 10.9.8 can install the resulting lockfile but its peer +resolver fails when updating this graph. Keep the update within compatible +versions, regenerate `dependencies.patch` from only `oidc-ui/package.json` and +`oidc-ui/package-lock.json`, and update `ESIGNET_UI_LOCK_SHA256` in the Dockerfile. +The image build still uses its pinned Node image's `npm ci`. + +Finally rebuild the local fixture UI and edge images and repeat the login and +protocol checks described in [the native integration guide](../../docs/esignet.md). +Review metadata and the real browser journey before any separate publication. diff --git a/docker/esignet-ui/dependencies.patch b/docker/esignet-ui/dependencies.patch new file mode 100644 index 0000000..16796c9 --- /dev/null +++ b/docker/esignet-ui/dependencies.patch @@ -0,0 +1,578 @@ +diff --git a/oidc-ui/package-lock.json b/oidc-ui/package-lock.json +index 1935c6a..693a830 100644 +--- a/oidc-ui/package-lock.json ++++ b/oidc-ui/package-lock.json +@@ -33,7 +33,7 @@ + "@types/react-dom": "^19.2.3", + "@types/react-google-recaptcha": "^2.1.9", + "@vitejs/plugin-react": "^6.0.1", +- "@vitest/coverage-v8": "^4.1.10", ++ "@vitest/coverage-v8": "^4.1.11", + "autoprefixer": "^10.5.0", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", +@@ -48,7 +48,7 @@ + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10", +- "vitest": "^4.1.10" ++ "vitest": "^4.1.11" + } + }, + "node_modules/@adobe/css-tools": { +@@ -2125,14 +2125,14 @@ + } + }, + "node_modules/@vitest/coverage-v8": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", +- "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", ++ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", +- "@vitest/utils": "4.1.10", ++ "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", +@@ -2146,8 +2146,8 @@ + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { +- "@vitest/browser": "4.1.10", +- "vitest": "4.1.10" ++ "@vitest/browser": "4.1.11", ++ "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { +@@ -2156,16 +2156,16 @@ + } + }, + "node_modules/@vitest/expect": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", +- "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", ++ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", +- "@vitest/spy": "4.1.10", +- "@vitest/utils": "4.1.10", ++ "@vitest/spy": "4.1.11", ++ "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, +@@ -2174,13 +2174,13 @@ + } + }, + "node_modules/@vitest/mocker": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", +- "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", ++ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { +- "@vitest/spy": "4.1.10", ++ "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, +@@ -2201,9 +2201,9 @@ + } + }, + "node_modules/@vitest/pretty-format": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", +- "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", ++ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { +@@ -2214,13 +2214,13 @@ + } + }, + "node_modules/@vitest/runner": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", +- "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", ++ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { +- "@vitest/utils": "4.1.10", ++ "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { +@@ -2228,14 +2228,14 @@ + } + }, + "node_modules/@vitest/snapshot": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", +- "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", ++ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { +- "@vitest/pretty-format": "4.1.10", +- "@vitest/utils": "4.1.10", ++ "@vitest/pretty-format": "4.1.11", ++ "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, +@@ -2244,9 +2244,9 @@ + } + }, + "node_modules/@vitest/spy": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", +- "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", ++ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { +@@ -2254,13 +2254,13 @@ + } + }, + "node_modules/@vitest/utils": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", +- "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", ++ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { +- "@vitest/pretty-format": "4.1.10", ++ "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, +@@ -2433,13 +2433,13 @@ + } + }, + "node_modules/axios": { +- "version": "1.17.0", +- "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", +- "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", ++ "version": "1.20.0", ++ "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", ++ "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", +- "form-data": "^4.0.5", ++ "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } +@@ -2499,9 +2499,9 @@ + } + }, + "node_modules/baseline-browser-mapping": { +- "version": "2.10.33", +- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", +- "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", ++ "version": "2.11.21", ++ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", ++ "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { +@@ -2522,22 +2522,22 @@ + } + }, + "node_modules/brace-expansion": { +- "version": "5.0.6", +- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", +- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", ++ "version": "5.0.9", ++ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", ++ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { +- "node": "18 || 20 || >=22" ++ "node": "20 || >=22" + } + }, + "node_modules/browserslist": { +- "version": "4.28.2", +- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", +- "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", ++ "version": "4.28.9", ++ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", ++ "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { +@@ -2555,11 +2555,11 @@ + ], + "license": "MIT", + "dependencies": { +- "baseline-browser-mapping": "^2.10.12", +- "caniuse-lite": "^1.0.30001782", +- "electron-to-chromium": "^1.5.328", +- "node-releases": "^2.0.36", +- "update-browserslist-db": "^1.2.3" ++ "baseline-browser-mapping": "^2.11.20", ++ "caniuse-lite": "^1.0.30001810", ++ "electron-to-chromium": "^1.5.420", ++ "node-releases": "^2.0.54", ++ "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" +@@ -2615,9 +2615,9 @@ + } + }, + "node_modules/caniuse-lite": { +- "version": "1.0.30001793", +- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", +- "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", ++ "version": "1.0.30001810", ++ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", ++ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { +@@ -2842,9 +2842,9 @@ + "license": "MIT" + }, + "node_modules/dompurify": { +- "version": "3.4.11", +- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", +- "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", ++ "version": "3.4.15", ++ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", ++ "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" +@@ -2865,9 +2865,9 @@ + } + }, + "node_modules/electron-to-chromium": { +- "version": "1.5.366", +- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", +- "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", ++ "version": "1.5.425", ++ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz", ++ "integrity": "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==", + "dev": true, + "license": "ISC" + }, +@@ -2926,9 +2926,9 @@ + } + }, + "node_modules/es-module-lexer": { +- "version": "2.1.0", +- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", +- "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", ++ "version": "2.3.2", ++ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", ++ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, +@@ -3190,9 +3190,9 @@ + } + }, + "node_modules/expect-type": { +- "version": "1.3.0", +- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", +- "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", ++ "version": "1.4.0", ++ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", ++ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { +@@ -3322,16 +3322,16 @@ + } + }, + "node_modules/form-data": { +- "version": "4.0.5", +- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", +- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", ++ "version": "4.0.6", ++ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", ++ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", +- "hasown": "^2.0.2", +- "mime-types": "^2.1.12" ++ "hasown": "^2.0.4", ++ "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" +@@ -4361,9 +4361,9 @@ + "license": "MIT" + }, + "node_modules/nanoid": { +- "version": "3.3.12", +- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", +- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", ++ "version": "3.3.18", ++ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", ++ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { +@@ -4387,9 +4387,9 @@ + "license": "MIT" + }, + "node_modules/node-releases": { +- "version": "2.0.47", +- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", +- "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", ++ "version": "2.0.55", ++ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", ++ "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { +@@ -4406,15 +4406,18 @@ + } + }, + "node_modules/obug": { +- "version": "2.1.1", +- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", +- "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", ++ "version": "2.2.1", ++ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", ++ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], +- "license": "MIT" ++ "license": "MIT", ++ "engines": { ++ "node": ">=12.20.0" ++ } + }, + "node_modules/optionator": { + "version": "0.9.4", +@@ -4571,9 +4574,9 @@ + } + }, + "node_modules/postcss": { +- "version": "8.5.15", +- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", +- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", ++ "version": "8.5.28", ++ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", ++ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { +@@ -4591,7 +4594,7 @@ + ], + "license": "MIT", + "dependencies": { +- "nanoid": "^3.3.12", ++ "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, +@@ -4762,9 +4765,9 @@ + "license": "MIT" + }, + "node_modules/react-router": { +- "version": "7.16.0", +- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", +- "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", ++ "version": "7.18.3", ++ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", ++ "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", +@@ -4784,12 +4787,12 @@ + } + }, + "node_modules/react-router-dom": { +- "version": "7.16.0", +- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", +- "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", ++ "version": "7.18.3", ++ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", ++ "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { +- "react-router": "7.16.0" ++ "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" +@@ -5013,9 +5016,9 @@ + "license": "MIT" + }, + "node_modules/std-env": { +- "version": "4.1.0", +- "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", +- "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", ++ "version": "4.2.0", ++ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", ++ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, +@@ -5124,9 +5127,9 @@ + "license": "MIT" + }, + "node_modules/tinyexec": { +- "version": "1.2.4", +- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", +- "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", ++ "version": "1.3.1", ++ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", ++ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { +@@ -5151,9 +5154,9 @@ + } + }, + "node_modules/tinyrainbow": { +- "version": "3.1.0", +- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", +- "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", ++ "version": "3.1.1", ++ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", ++ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { +@@ -5277,9 +5280,9 @@ + } + }, + "node_modules/undici": { +- "version": "7.27.0", +- "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", +- "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", ++ "version": "7.29.1", ++ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", ++ "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { +@@ -5294,9 +5297,9 @@ + "license": "MIT" + }, + "node_modules/update-browserslist-db": { +- "version": "1.2.3", +- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", +- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", ++ "version": "1.3.2", ++ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", ++ "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { +@@ -5419,19 +5422,19 @@ + } + }, + "node_modules/vitest": { +- "version": "4.1.10", +- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", +- "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", ++ "version": "4.1.11", ++ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", ++ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { +- "@vitest/expect": "4.1.10", +- "@vitest/mocker": "4.1.10", +- "@vitest/pretty-format": "4.1.10", +- "@vitest/runner": "4.1.10", +- "@vitest/snapshot": "4.1.10", +- "@vitest/spy": "4.1.10", +- "@vitest/utils": "4.1.10", ++ "@vitest/expect": "4.1.11", ++ "@vitest/mocker": "4.1.11", ++ "@vitest/pretty-format": "4.1.11", ++ "@vitest/runner": "4.1.11", ++ "@vitest/snapshot": "4.1.11", ++ "@vitest/spy": "4.1.11", ++ "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", +@@ -5459,12 +5462,12 @@ + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", +- "@vitest/browser-playwright": "4.1.10", +- "@vitest/browser-preview": "4.1.10", +- "@vitest/browser-webdriverio": "4.1.10", +- "@vitest/coverage-istanbul": "4.1.10", +- "@vitest/coverage-v8": "4.1.10", +- "@vitest/ui": "4.1.10", ++ "@vitest/browser-playwright": "4.1.11", ++ "@vitest/browser-preview": "4.1.11", ++ "@vitest/browser-webdriverio": "4.1.11", ++ "@vitest/coverage-istanbul": "4.1.11", ++ "@vitest/coverage-v8": "4.1.11", ++ "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" +diff --git a/oidc-ui/package.json b/oidc-ui/package.json +index fc5afb7..a5c2b4d 100644 +--- a/oidc-ui/package.json ++++ b/oidc-ui/package.json +@@ -39,7 +39,7 @@ + "@types/react-dom": "^19.2.3", + "@types/react-google-recaptcha": "^2.1.9", + "@vitejs/plugin-react": "^6.0.1", +- "@vitest/coverage-v8": "^4.1.10", ++ "@vitest/coverage-v8": "^4.1.11", + "autoprefixer": "^10.5.0", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", +@@ -54,6 +54,9 @@ + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10", +- "vitest": "^4.1.10" ++ "vitest": "^4.1.11" ++ }, ++ "overrides": { ++ "dompurify": "3.4.15" + } + } diff --git a/docs/esignet.md b/docs/esignet.md index 4eba8dc..d8c773f 100644 --- a/docs/esignet.md +++ b/docs/esignet.md @@ -13,6 +13,11 @@ provisioned fields are read from BREG's `data.domainData`. The pairwise OIDC requests continue to use the synthetic citizen's UIN. Refusing that claim does not create a Portal session. +The UI build applies reviewed locale and dependency patches to that exact +source. It retains Thunder React 0.11.2 and pins the resulting npm lockfile +checksum in the image metadata. See [UI build maintenance](../docker/esignet-ui/README.md) +for dependency refresh and validation commands. + ## Build and start locally Prerequisites: Docker with Buildx, Go 1.26, Node with pnpm, uv, just, and matching