From 43ce09fc09d313659fc046535209b773b4d987e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:34:27 +0700 Subject: [PATCH 1/6] chore(citizen): checkpoint deferred BREG self-service fixture Work-package checkpoint. Existing citizen-related work is preserved but phase 4 is deferred pending a separate decision; institutional delivery must use stock ThunderID. Signed-off-by: Jeremi Joslin --- breg/.gitignore | 1 + breg/README.md | 159 ++++++++++++++++++++ breg/dev.py | 218 ++++++++++++++++++++++++++++ breg/generate-seeds.py | 61 ++++++++ breg/population/dev-clients.yaml | 196 +++++++++++++++++++++++++ breg/population/registry.yaml | 178 +++++++++++++++++++++++ breg/population/tests/journeys.yaml | 125 ++++++++++++++++ breg/test_dev.py | 150 +++++++++++++++++++ 8 files changed, 1088 insertions(+) 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 breg/test_dev.py 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..f3af926 --- /dev/null +++ b/breg/README.md @@ -0,0 +1,159 @@ +# Synthetic population registry for eSignet 2 + +This local fixture runs an authored Base Registry Engine project over its own +PostgreSQL database. The lifecycle's ThunderID issuer issues OAuth access tokens +to separate operator and eSignet clients. It uses installed matching `bregctl` +and `breg` binaries, without a Registry Stack source checkout. The candidate +tools must support `bregctl dev --issuer-port`, `dev export-client`, and +`dev token`. No published Registry Stack release is claimed to support this +candidate fixture yet. + +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 those commands, build `bregctl` and `breg` +together from the exact reviewed Registry Stack candidate source supplied for +this integration. Use its pinned Rust toolchain and Cargo lockfile, and build in +a fresh directory 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 +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 --bins +mkdir -p ../breg-tools/bin +install -m 755 target/debug/breg target/debug/bregctl ../breg-tools/bin/ +export PATH="$(cd ../breg-tools/bin && pwd):$PATH" +bregctl dev --help | grep -- --issuer-port +bregctl dev export-client --help +bregctl dev token --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. The placeholder must be replaced with the exact +reviewed candidate commit and must not be treated as a version or release +promise. 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 --issuer-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 `token-client-private.jwk`, `psut-secret` and `static-otp` under its +container mount `/etc/registry-esignet`. ThunderID's token endpoint transport +address is `http://host.docker.internal:18191/oauth2/token`; the private JWT +assertion audience remains the exported loopback issuer origin. The provider's +resource, scopes, and key ID come unchanged from `dev export-client`. +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 through `bregctl dev token` without +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 +seven readable fields: `uin`, `status`, `personReference`, `givenName`, +`familyName`, `birthdate`, and `gender`. The registry enforces the unique UIN +constraint. Its row boundary compares `status` with the issuer's fixed +`registry_identity_status: active` claim. +The caller-provided UIN is a selector input and never creates authority. +The source has no list, get, create, or patch permission. `operatorNote` remains +operator-only. The operator's unrestricted row finding is intentional for +maintenance and seeding; its credential is never exported to eSignet. + +The separate `citizen-self-service-agent` client uses the +`citizen-self-service` profile, `registry:population:self-service` scope, and +`citizen-self-service` purpose. It can only look up `person` through +`by-person-reference` and read `personReference` plus `status`. BREG requires +the selected `person-reference` to equal the verified +`registry_subject_person_reference` claim and requires `status` to equal the +trusted `registry_identity_status` claim fixed to `active` by the agent policy. +All three profiles use the verified token `sub` as their principal. For the +citizen flow, `sub` remains the citizen while the verified client is +`citizen-self-service-agent`; BREG separately requires `act.sub` to match the +operator-configured native actor for that exact client. +The existing eSignet UIN lookup remains a separate profile and client. + +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 `person_reference` explicitly as an essential consented +claim. It maps to the governed opaque `personReference` field generated with the +fixture. The UIN remains a challenge input and is not the Portal correlation +identifier. The OIDC `sub` remains the pairwise pseudonymous subject token and +never substitutes for that reference. 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, missing lookup, and +the refusal when a citizen-bound agent changes the selector to another person's +governed reference. +`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..99a8dbc --- /dev/null +++ b/breg/dev.py @@ -0,0 +1,218 @@ +#!/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 re +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", "personReference", "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 require_candidate_bregctl(args): + help_text = command(args.bregctl, "dev", "--help") + if not re.search(r"(?m)^\s*--issuer-port\b", help_text): + raise RuntimeError( + "installed bregctl dev is Mint-era and incompatible; use the reviewed candidate with --issuer-port" + ) + if not re.search(r"(?m)^\s*token\s+", help_text): + raise RuntimeError( + "installed bregctl dev is incompatible; the reviewed candidate dev token command is required" + ) + + +def ready(args): + return json.loads(command( + args.bregctl, "dev", str(PROJECT), "--breg-port", str(args.breg_port), + "--issuer-port", str(args.issuer_port), "--database-port", str(args.database_port), + "--format", "json", + )) + + +def export_provider(args): + 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 / "token-client-id"), + "--assertion-key-file", str(SECRETS / "token-client-private.jwk"), "--format", "json", + )) + if not exported.get("ok"): + raise RuntimeError("native client export failed") + required = ("tokenEndpoint", "clientAssertionAudience", "resource", "scopes") + if any(not exported.get(field) for field in required): + raise RuntimeError("native client export omitted required token client metadata") + key = json.loads((SECRETS / "token-client-private.jwk").read_text()) + key_id = key.get("kid") + if not isinstance(key_id, str) or not key_id: + raise RuntimeError("native client export assertion key omitted kid") + scopes = exported["scopes"] + if not isinstance(scopes, list) or not all(isinstance(scope, str) and scope for scope in scopes): + raise RuntimeError("native client export scopes are invalid") + token_path = urllib.parse.urlparse(exported["tokenEndpoint"]).path + if not token_path.startswith("/"): + raise RuntimeError("native client export token endpoint is invalid") + 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"]}, + "token_client": {"token_endpoint": f"http://{args.provider_host}:{args.issuer_port}{token_path}", + "assertion_audience": exported["clientAssertionAudience"], + "client_id": (SECRETS / "token-client-id").read_text().strip(), + "private_key_file": f"{container_root}/token-client-private.jwk", + "key_id": key_id, "resource": exported["resource"], "scopes": scopes}, + "claim_map": {"sub": "$psut", "person_reference": "personReference", "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, client): + result = json.loads(command( + args.bregctl, "dev", "token", client, str(PROJECT), "--format", "json", + )) + header_file = result.get("headerFile") + if not result.get("ok") or not isinstance(header_file, str): + raise RuntimeError("native token acquisition failed") + header = Path(header_file).read_text().strip() + prefix = "Authorization: Bearer " + if not header.startswith(prefix) or len(header) == len(prefix): + raise RuntimeError("native token header is invalid") + return header[len(prefix):] + + +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, "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", "personReference") + assert status == 200 and document["data"]["domainData"] == { + "personReference": "5260223c-e413-57f4-9d3c-02f46ff38bfc"}, "opaque person reference projection failed" + checks += 1 + status, document = lookup("2300010248", "uin") + assert status == 200 and document["data"]["domainData"] == { + "uin": "2300010248"}, "challenge 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, "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 ThunderID/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("--breg-port", type=int, default=18190) + parser.add_argument("--issuer-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 + require_candidate_bregctl(args) + report = ready(args) + if args.action == "start": + export_provider(args) + 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..dc84d36 --- /dev/null +++ b/breg/generate-seeds.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Regenerate the bounded eSignet identity fixture from canonical synthetic citizens.""" + +import argparse +import csv +from pathlib import Path +import uuid + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +CLIENTS = ROOT / "breg/population/dev-clients.yaml" +PERSON_REFERENCE_NAMESPACE = uuid.UUID("ae832c0a-a922-4f65-8c01-cc4a41d6c660") + + +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"], + "personReference": str(uuid.uuid5(PERSON_REFERENCE_NAMESPACE, row["uin"])), + "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", + "personReference": str(uuid.uuid5(PERSON_REFERENCE_NAMESPACE, "2399999999")), + "givenName": "Inactive", "familyName": "Fixture", + "birthdate": "1980-01-01", "gender": "unknown", + "operatorNote": "Synthetic seed; not disclosed to authentication provider.", + }, + }) + 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..995cde4 --- /dev/null +++ b/breg/population/dev-clients.yaml @@ -0,0 +1,196 @@ +version: 1 +clients: +- id: operator + accessProfiles: + - operator + scopes: + - registry:population:operate + claims: + registry_purpose: population-maintenance +- id: esignet + accessProfiles: + - esignet-source + scopes: + - registry:population:lookup + claims: + registry_purpose: identity-authentication + registry_identity_status: active +- id: citizen-self-service-agent + accessProfiles: + - citizen-self-service + scopes: + - registry:population:self-service + claims: + registry_purpose: citizen-self-service + registry_subject_person_reference: cd00b0e5-e2fd-5df4-a343-029b7c4aed41 + registry_identity_status: active +seed: +- id: person-01 + client: operator + entity: person + accessProfile: operator + data: + uin: '2300010248' + status: active + personReference: 5260223c-e413-57f4-9d3c-02f46ff38bfc + 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 + personReference: 44a5ef0a-aaa2-574e-8969-1069bf62b948 + 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 + personReference: 99447082-8141-5e59-aa9f-d8138f8986c5 + 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 + personReference: f2f7a5e6-4ffe-53f2-b870-ac5f6bde2ec2 + 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 + personReference: 93c624a2-6e9f-5b94-a27d-45f1dc47fd4d + 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 + personReference: 7072f22b-ec8c-50f6-b60f-10f49cf24831 + 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 + personReference: d458b083-3199-572d-84b5-782e8c1c2610 + 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 + personReference: e2e8c712-d840-560d-9c50-775e5099d8f3 + 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 + personReference: 48b21113-7b59-59d8-ba50-32da9a6a49e1 + 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 + personReference: 7753112a-09a3-5982-ba3e-ae7aba6cd6a1 + 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 + personReference: d9596aad-6009-5543-b214-ad9e8eb7c835 + 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 + personReference: 50862595-f88e-5aca-8c9a-b29fb70d96bd + 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 + personReference: 4c4955a1-96c9-5dfd-8609-7226d74f0f38 + givenName: Inactive + familyName: Fixture + birthdate: '1980-01-01' + gender: unknown + operatorNote: Synthetic seed; not disclosed to authentication provider. diff --git a/breg/population/registry.yaml b/breg/population/registry.yaml new file mode 100644 index 0000000..e4ede31 --- /dev/null +++ b/breg/population/registry.yaml @@ -0,0 +1,178 @@ +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: person-reference + apiName: personReference + type: string + required: true + classification: internal + minLength: 36 + maxLength: 36 + - 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 + - id: by-person-reference + fields: + - person-reference +accessProfiles: +- id: operator + default: true + principalClaim: sub + requiredScopes: + - registry:population:operate + requiredPurposes: + - population-maintenance + permissions: + - entity: person + operations: + - create + - get + - patch + readableFields: &id001 + - uin + - status + - person-reference + - given-name + - family-name + - birthdate + - gender + - operator-note + writableFields: *id001 + rowBoundaries: [] +- id: esignet-source + principalClaim: sub + requiredScopes: + - registry:population:lookup + requiredPurposes: + - identity-authentication + permissions: + - entity: person + operations: + - lookup + readableFields: + - uin + - status + - person-reference + - given-name + - family-name + - birthdate + - gender + lookups: + - selector: by-uin + valueOrigin: request + rowBoundaries: + - field: status + claim: registry_identity_status + operator: equals +- id: citizen-self-service + principalClaim: sub + actorKind: agent + requesterClients: + - citizen-self-service-agent + requiredScopes: + - registry:population:self-service + requiredPurposes: + - citizen-self-service + permissions: + - entity: person + operations: + - lookup + readableFields: + - person-reference + - status + lookups: + - selector: by-person-reference + valueOrigin: request + rowBoundaries: + - field: person-reference + claim: registry_subject_person_reference + operator: equals + - 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..82559ff --- /dev/null +++ b/breg/population/tests/journeys.yaml @@ -0,0 +1,125 @@ +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 + person-reference: cd00b0e5-e2fd-5df4-a343-029b7c4aed41 + 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 + personReference: cd00b0e5-e2fd-5df4-a343-029b7c4aed41 + - 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 + - id: operator-creates-other-person + entity: person + accessProfile: operator + claims: + principal: solmara-population-operator + purpose: population-maintenance + scopes: + - registry:population:operate + request: + operation: create + data: + uin: '2300000001' + status: active + person-reference: a506b48c-c8b8-5a09-b735-999bb05039f5 + given-name: Other + family-name: Journey + birthdate: '1991-01-01' + gender: unknown + operator-note: Private operator fact + expect: + outcome: success + status: 201 + fields: + personReference: a506b48c-c8b8-5a09-b735-999bb05039f5 + - id: citizen-self-service-exact-person + entity: person + accessProfile: citizen-self-service + claims: &citizen-self-service-claims + principal: solmara-citizen-self-service-agent + actorKind: agent + requesterClient: citizen-self-service-agent + actorSubject: solmara-citizen-self-service-agent + purpose: citizen-self-service + scopes: + - registry:population:self-service + directClaims: + registry_subject_person_reference: cd00b0e5-e2fd-5df4-a343-029b7c4aed41 + registry_identity_status: active + request: + operation: lookup + selector: by-person-reference + values: + person-reference: cd00b0e5-e2fd-5df4-a343-029b7c4aed41 + expect: + outcome: success + status: 200 + fields: + personReference: cd00b0e5-e2fd-5df4-a343-029b7c4aed41 + status: active + - id: citizen-self-service-refuses-other-person + entity: person + accessProfile: citizen-self-service + claims: *citizen-self-service-claims + request: + operation: lookup + selector: by-person-reference + values: + person-reference: a506b48c-c8b8-5a09-b735-999bb05039f5 + expect: + outcome: refusal + status: 404 + problemCode: lookup.unresolved diff --git a/breg/test_dev.py b/breg/test_dev.py new file mode 100644 index 0000000..7b4c19d --- /dev/null +++ b/breg/test_dev.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest import mock + +import yaml + + +SCRIPT = Path(__file__).with_name("dev.py") +SPEC = importlib.util.spec_from_file_location("solmara_breg_dev", SCRIPT) +dev = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(dev) + + +class DevFixtureTests(unittest.TestCase): + def args(self) -> SimpleNamespace: + return SimpleNamespace( + bregctl="candidate-bregctl", breg_port=18190, issuer_port=18191, + database_port=55449, provider_host="host.docker.internal", + ) + + def test_ready_uses_issuer_port(self) -> None: + with mock.patch.object(dev, "command", return_value='{"ok":true}') as command: + self.assertTrue(dev.ready(self.args())["ok"]) + arguments = command.call_args.args + self.assertIn("--issuer-port", arguments) + self.assertNotIn("--mint-port", arguments) + + def test_candidate_check_rejects_mint_era_cli(self) -> None: + old_help = " --mint-port \nCommands:\n export-client\n" + with mock.patch.object(dev, "command", return_value=old_help): + with self.assertRaisesRegex(RuntimeError, "Mint-era.*--issuer-port"): + dev.require_candidate_bregctl(self.args()) + + def test_candidate_check_requires_dev_token(self) -> None: + partial_help = "Commands:\n export-client\nOptions:\n --issuer-port \n" + with mock.patch.object(dev, "command", return_value=partial_help): + with self.assertRaisesRegex(RuntimeError, "dev token command is required"): + dev.require_candidate_bregctl(self.args()) + + def test_candidate_check_accepts_issuer_and_token_contract(self) -> None: + candidate_help = "Commands:\n token Acquire token\nOptions:\n --issuer-port \n" + with mock.patch.object(dev, "command", return_value=candidate_help): + dev.require_candidate_bregctl(self.args()) + + def test_citizen_self_service_is_bound_to_governed_person_reference(self) -> None: + registry = yaml.safe_load((dev.PROJECT / "registry.yaml").read_text()) + profile = next( + profile + for profile in registry["accessProfiles"] + if profile["id"] == "citizen-self-service" + ) + self.assertEqual(profile["requiredScopes"], ["registry:population:self-service"]) + self.assertEqual(profile["principalClaim"], "sub") + self.assertEqual(profile["requiredPurposes"], ["citizen-self-service"]) + self.assertEqual(profile["actorKind"], "agent") + self.assertEqual(profile["requesterClients"], ["citizen-self-service-agent"]) + self.assertEqual(len(profile["permissions"]), 1) + permission = profile["permissions"][0] + self.assertEqual(permission["operations"], ["lookup"]) + self.assertEqual(permission["readableFields"], ["person-reference", "status"]) + self.assertEqual(permission["lookups"], [{ + "selector": "by-person-reference", "valueOrigin": "request", + }]) + self.assertEqual(permission["rowBoundaries"], [ + { + "field": "person-reference", + "claim": "registry_subject_person_reference", + "operator": "equals", + }, + { + "field": "status", + "claim": "registry_identity_status", + "operator": "equals", + }, + ]) + + journeys = yaml.safe_load((dev.PROJECT / "tests/journeys.yaml").read_text())["journeys"] + refusal = next( + step + for journey in journeys + for step in journey["steps"] + if step["id"] == "citizen-self-service-refuses-other-person" + ) + self.assertNotEqual( + refusal["request"]["values"]["person-reference"], + refusal["claims"]["directClaims"]["registry_subject_person_reference"], + ) + self.assertEqual(refusal["expect"], { + "outcome": "refusal", "status": 404, "problemCode": "lookup.unresolved", + }) + self.assertTrue(all( + candidate["principalClaim"] == "sub" + for candidate in registry["accessProfiles"] + )) + + def test_export_writes_complete_issuer_neutral_token_client(self) -> None: + with tempfile.TemporaryDirectory() as directory: + secrets = Path(directory) + + def command(*arguments: str) -> str: + Path(arguments[arguments.index("--client-id-file") + 1]).write_text("esignet") + Path(arguments[arguments.index("--assertion-key-file") + 1]).write_text( + json.dumps({"kty": "EC", "kid": "esignet-key"}) + ) + return json.dumps({ + "ok": True, + "tokenEndpoint": "http://127.0.0.1:18191/oauth2/token", + "clientAssertionAudience": "http://127.0.0.1:18191", + "resource": "urn:breg:dev:solmara-esignet-v2", + "scopes": ["registry:population:lookup"], + }) + + with mock.patch.object(dev, "SECRETS", secrets), mock.patch.object(dev, "command", command): + dev.export_provider(self.args()) + config = yaml.safe_load((secrets / "registry.yaml").read_text()) + self.assertNotIn("mint", config) + self.assertEqual(config["token_client"], { + "token_endpoint": "http://host.docker.internal:18191/oauth2/token", + "assertion_audience": "http://127.0.0.1:18191", + "client_id": "esignet", + "private_key_file": "/etc/registry-esignet/token-client-private.jwk", + "key_id": "esignet-key", + "resource": "urn:breg:dev:solmara-esignet-v2", + "scopes": ["registry:population:lookup"], + }) + self.assertEqual(config["claim_map"]["sub"], "$psut") + self.assertEqual(config["claim_map"]["person_reference"], "personReference") + self.assertNotIn("individual_id", config["claim_map"]) + + def test_token_uses_native_dev_token_header(self) -> None: + with tempfile.TemporaryDirectory() as directory: + header = Path(directory) / "esignet.header" + header.write_text("Authorization: Bearer short-lived-token\n") + response = json.dumps({"ok": True, "headerFile": str(header)}) + with mock.patch.object(dev, "command", return_value=response) as command: + self.assertEqual(dev.token(self.args(), "esignet"), "short-lived-token") + self.assertEqual(command.call_args.args[:4], ( + "candidate-bregctl", "dev", "token", "esignet", + )) + + +if __name__ == "__main__": + unittest.main() From d18eeef23237a3c26b5be4d501d300a8f3f832b2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:34:27 +0700 Subject: [PATCH 2/6] feat(auth): checkpoint issuer migration and protocol fixtures Work-package checkpoint. Existing citizen-related work is preserved but phase 4 is deferred pending a separate decision; institutional delivery must use stock ThunderID. Signed-off-by: Jeremi Joslin --- .env.example | 7 +- .gitattributes | 1 + .github/workflows/release-candidate.yml | 23 +- README.md | 18 +- 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 | 43 +- docker/esignet-ui/README.md | 43 ++ docker/esignet-ui/default-locale.patch | 12 + docker/esignet-ui/dependencies.patch | 578 +++++++++++++++++++ docker/esignet-ui/hosted-entrypoint.sh | 8 +- docs/changelog.md | 23 +- docs/esignet.md | 173 +++++- generator/solmara_lab/publisher.py | 9 +- generator/tests/test_publisher.py | 37 +- justfile | 18 +- portal/e2e/support/auth.ts | 93 +-- portal/package.json | 1 + portal/pnpm-lock.yaml | 21 + portal/src/lib/server/esignet.test.ts | 338 +++++++---- portal/src/lib/server/esignet.ts | 347 +++++------ 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/lifecycle_proof.py | 5 +- scripts/live-lifecycle-proof.py | 5 +- scripts/local-relay-source-publisher.py | 12 +- scripts/provision-hosted-runtime.py | 3 +- scripts/seed-esignet.py | 206 +++---- scripts/smoke-esignet-login.mjs | 3 + scripts/smoke-esignet.py | 79 +-- 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_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_registry_stack_release_pin.py | 11 +- scripts/test_runtime_topology.py | 10 +- scripts/test_seed_esignet.py | 86 +++ scripts/test_smoke_esignet.py | 154 ++--- versions.env | 16 +- 54 files changed, 2653 insertions(+), 1288 deletions(-) create mode 100644 .gitattributes create mode 100644 compose.esignet-fixture.yaml create mode 100644 docker/esignet-ui/README.md create mode 100644 docker/esignet-ui/default-locale.patch create mode 100644 docker/esignet-ui/dependencies.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..06674a0 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,10 @@ 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= +# Required only for the native eSignet profile. Build the candidate locally and +# set its exact local image tag or verified immutable image reference here. +SOLMARA_ESIGNET_CANDIDATE_IMAGE= 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..bc373f5 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,13 @@ 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 an unreleased native 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 tools supporting `--issuer-port`, `dev export-client`, and +`dev token`; 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 +76,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/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..000deb4 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: person_reference 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..359d922 --- /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: person_reference + 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..d861622 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: person_reference + 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..5187b11 100644 --- a/docker/esignet-ui/Dockerfile +++ b/docker/esignet-ui/Dockerfile @@ -1,18 +1,31 @@ -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 +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 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 +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 +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 ${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/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/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/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/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..48be602 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 @@ -199,8 +218,8 @@ exact lifecycle, while rejecting anonymous PGDATA mounts. Local eSignet now exposes its issuer port through the same standards edge used by hosted deployments, so root discovery and `/v1/esignet` share the public issuer origin while the Java backend remains internal. The NIA UserInfo release -profile now resolves eSignet's `individual_id` input against the canonical UIN -column, matching the portal login identifier and returning the typed identity +profile now resolves eSignet's `person_reference` claim against a governed +opaque reference column, matching the portal login identifier and returning the typed identity claims from the authority source. ## 2026-07-05 Visitor's Center completion pass diff --git a/docs/esignet.md b/docs/esignet.md index e8258cd..8d8cfeb 100644 --- a/docs/esignet.md +++ b/docs/esignet.md @@ -1,24 +1,149 @@ -# 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 +an unreleased BREG provider candidate built from the companion provider +checkout. BREG is the population system of record and the retained ThunderID +dev issuer authenticates the provider. Relay integration is deferred. + +The provider verifies the OTP before contacting ThunderID 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 +`person_reference` as an essential, consented opaque reference. That reference +is a governed BREG field distinct from both the challenge UIN and the pairwise +subject. 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 +native `bregctl` and `breg` tools supporting `--issuer-port`, `dev +export-client`, and `dev token`. The fixture uses the native toolchain's own +PostgreSQL and ThunderID lifecycle. Put the matching candidate binaries on +`PATH` if the installed release lacks these commands. See [the BREG +fixture](../breg/README.md) for its bounded seed population and exact candidate +requirements. + +From the `esignet-relay-authenticator` checkout: + +```sh +GOTOOLCHAIN=go1.26.8 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` in the Lab's ignored `.env` to the exact image +tag loaded by that build. The Lab does not supply a default candidate tag or +claim released support. 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 opaque person-reference 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, ThunderID, 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-ThunderID 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 issuer 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 and ThunderID are running and that + Docker can reach host loopback via `host.docker.internal`. The client assertion + audience remains the exported issuer origin even when transport uses another + address. Resource and scopes remain the exact exported client values. +- 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 token 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/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/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..5f8a8ef 100644 --- a/portal/src/lib/server/esignet.test.ts +++ b/portal/src/lib/server/esignet.test.ts @@ -1,149 +1,253 @@ +// @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: 'person_reference', 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, person_reference: '8fa0e667-5042-5ba2-9837-a9fd852f0394', 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, person_reference: { essential: true } } + }); + expect(flow.jar.get(ESIGNET_LOGIN_COOKIE)).toBe(flow.authorize.searchParams.get('state')); + expect(await flow.complete()).toEqual({ subject: '8fa0e667-5042-5ba2-9837-a9fd852f0394', 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: { person_reference: 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: '8fa0e667-5042-5ba2-9837-a9fd852f0394' }); + 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); + }); - await expect(completeEsignetLogin(cookiesForTest(jar), callbackUrl(authorize), cfg, fetchFn)).rejects.toThrow( - 'configured subject claim individual_id' - ); + 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); }); - 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('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('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: '8fa0e667-5042-5ba2-9837-a9fd852f0394', 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..c01b65f 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', - subjectClaim: env.PORTAL_ESIGNET_SUBJECT_CLAIM || 'individual_id', + 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 || 'person_reference', 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..d1396d6 --- /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 = ['person_reference', '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 = { person_reference: { 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, ['person_reference', '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, ['person_reference', 'given_name', 'name']); + const claims = await signedClaims(signed, token); + assertDisclosure(claims, ['person_reference', 'given_name']); + assert.equal(claims.person_reference, process.env.ESIGNET_DEMO_REFERENCE || '44a5ef0a-aaa2-574e-8969-1069bf62b948'); + 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, ['person_reference', 'gender', 'name']); + assertDisclosure(await signedClaims(signed, token), ['person_reference', '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, { person_reference: 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, ['person_reference', 'given_name', 'name']); + const claims = await encryptedClaims(encrypted, token); + assertDisclosure(claims, ['person_reference', 'given_name']); + assert.equal(claims.person_reference, process.env.ESIGNET_DEMO_REFERENCE || '44a5ef0a-aaa2-574e-8969-1069bf62b948'); + const repeatClaims = await encryptedClaims(encrypted, token); + assertDisclosure(repeatClaims, ['person_reference', '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/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/seed-esignet.py b/scripts/seed-esignet.py index ae185a5..a83715f 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", + "person_reference", + "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..69a16df 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,18 @@ 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 + 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 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_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_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..a859ca7 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": "person_reference", } 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..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 @@ -83,94 +84,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 +106,52 @@ 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_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: diff --git a/versions.env b/versions.env index bc1e182..7cd63ce 100644 --- a/versions.env +++ b/versions.env @@ -21,16 +21,14 @@ 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 - SOLMARA_SCENARIO_RUNNER_IMAGE=solmara-lab-scenario-runner:local SOLMARA_CHILD_BENEFIT_FEDERATOR_IMAGE=solmara-lab-scenario-runner:local SOLMARA_PORTAL_IMAGE=solmara-lab-portal:local From 18cefac70beae480b84bc5cf6d803dceb8b4c874 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:09:15 +0700 Subject: [PATCH 3/6] fix(esignet): preserve the existing consented identity claim contract Signed-off-by: Jeremi Joslin --- breg/README.md | 2 +- breg/dev.py | 2 +- breg/test_dev.py | 4 ++-- compose.coolify.yaml | 2 +- compose.esignet-fixture.yaml | 2 +- compose.esignet.yaml | 2 +- docs/changelog.md | 2 +- docs/esignet.md | 6 +++--- portal/src/lib/server/esignet.test.ts | 8 ++++---- portal/src/lib/server/esignet.ts | 2 +- scripts/esignet-protocol-proof.mjs | 26 +++++++++++++------------- scripts/seed-esignet.py | 2 +- scripts/test_runtime_topology.py | 2 +- 13 files changed, 31 insertions(+), 31 deletions(-) diff --git a/breg/README.md b/breg/README.md index f3af926..a76f38f 100644 --- a/breg/README.md +++ b/breg/README.md @@ -137,7 +137,7 @@ 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 `person_reference` explicitly as an essential consented +The portal requests `individual_id` explicitly as an essential consented claim. It maps to the governed opaque `personReference` field generated with the fixture. The UIN remains a challenge input and is not the Portal correlation identifier. The OIDC `sub` remains the pairwise pseudonymous subject token and diff --git a/breg/dev.py b/breg/dev.py index 99a8dbc..54048fe 100644 --- a/breg/dev.py +++ b/breg/dev.py @@ -86,7 +86,7 @@ def export_provider(args): "client_id": (SECRETS / "token-client-id").read_text().strip(), "private_key_file": f"{container_root}/token-client-private.jwk", "key_id": key_id, "resource": exported["resource"], "scopes": scopes}, - "claim_map": {"sub": "$psut", "person_reference": "personReference", "given_name": "givenName", "family_name": "familyName", + "claim_map": {"sub": "$psut", "individual_id": "personReference", "given_name": "givenName", "family_name": "familyName", "birthdate": "birthdate", "gender": "gender"}, "http": {"allow_insecure_http": True, "timeout_seconds": 10, "max_response_bytes": 1048576}, diff --git a/breg/test_dev.py b/breg/test_dev.py index 7b4c19d..11d3d8e 100644 --- a/breg/test_dev.py +++ b/breg/test_dev.py @@ -131,8 +131,8 @@ def command(*arguments: str) -> str: "scopes": ["registry:population:lookup"], }) self.assertEqual(config["claim_map"]["sub"], "$psut") - self.assertEqual(config["claim_map"]["person_reference"], "personReference") - self.assertNotIn("individual_id", config["claim_map"]) + self.assertEqual(config["claim_map"]["individual_id"], "personReference") + self.assertNotIn("person_reference", config["claim_map"]) def test_token_uses_native_dev_token_header(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/compose.coolify.yaml b/compose.coolify.yaml index 000deb4..0f36cef 100644 --- a/compose.coolify.yaml +++ b/compose.coolify.yaml @@ -147,7 +147,7 @@ services: PORTAL_ESIGNET_ISSUER: ${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org} PORTAL_ESIGNET_REDIRECT_URI: ${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback PORTAL_ESIGNET_SCOPE: ${PORTAL_ESIGNET_SCOPE:-openid} - PORTAL_ESIGNET_SUBJECT_CLAIM: person_reference + 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 index 359d922..f3f278e 100644 --- a/compose.esignet-fixture.yaml +++ b/compose.esignet-fixture.yaml @@ -146,7 +146,7 @@ services: 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: person_reference + PORTAL_ESIGNET_SUBJECT_CLAIM: individual_id PORTAL_ESIGNET_TRANSPORT_ORIGIN: http://esignet:8080 PORTAL_ESIGNET_ALLOW_HTTP: 'true' HOST: 0.0.0.0 diff --git a/compose.esignet.yaml b/compose.esignet.yaml index d861622..37b2321 100644 --- a/compose.esignet.yaml +++ b/compose.esignet.yaml @@ -143,7 +143,7 @@ services: 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: person_reference + PORTAL_ESIGNET_SUBJECT_CLAIM: individual_id PORTAL_ESIGNET_TRANSPORT_ORIGIN: http://esignet:8080 PORTAL_ESIGNET_ALLOW_HTTP: 'true' esignet-key-init: diff --git a/docs/changelog.md b/docs/changelog.md index 48be602..4251e9d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -218,7 +218,7 @@ exact lifecycle, while rejecting anonymous PGDATA mounts. Local eSignet now exposes its issuer port through the same standards edge used by hosted deployments, so root discovery and `/v1/esignet` share the public issuer origin while the Java backend remains internal. The NIA UserInfo release -profile now resolves eSignet's `person_reference` claim against a governed +profile now resolves eSignet's existing `individual_id` claim against a governed opaque reference column, matching the portal login identifier and returning the typed identity claims from the authority source. diff --git a/docs/esignet.md b/docs/esignet.md index 8d8cfeb..6c7a340 100644 --- a/docs/esignet.md +++ b/docs/esignet.md @@ -1,8 +1,8 @@ # 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 -an unreleased BREG provider candidate built from the companion provider +`df0d0e771dae16eb2597b8e5b5dc65e70baa7f86`, its matching UI, and +an unreleased native BREG provider candidate built from the companion provider checkout. BREG is the population system of record and the retained ThunderID dev issuer authenticates the provider. Relay integration is deferred. @@ -10,7 +10,7 @@ The provider verifies the OTP before contacting ThunderID or BREG. It checks onl `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 -`person_reference` as an essential, consented opaque reference. That reference +`individual_id` as an essential, consented opaque reference. That reference is a governed BREG field distinct from both the challenge UIN and the pairwise subject. Refusing that claim does not create a Portal session. diff --git a/portal/src/lib/server/esignet.test.ts b/portal/src/lib/server/esignet.test.ts index 5f8a8ef..c34104b 100644 --- a/portal/src/lib/server/esignet.test.ts +++ b/portal/src/lib/server/esignet.test.ts @@ -28,7 +28,7 @@ const cfg: EsignetConfig = { 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: 'person_reference', secureCookies: true + subjectClaim: 'individual_id', secureCookies: true }; const metadata = { issuer: cfg.issuer, authorization_endpoint: `${cfg.issuer}/oauth2/authorize`, @@ -73,7 +73,7 @@ function provider(options: Options = {}) { } if (url.pathname === '/oauth2/userinfo') { expect(new Headers(init?.headers).get('authorization')).toBe('Bearer access-token'); - const claims = { ...common, person_reference: '8fa0e667-5042-5ba2-9837-a9fd852f0394', name: 'Elena Dela Cruz', ...options.userinfo }; + const claims = { ...common, individual_id: '8fa0e667-5042-5ba2-9837-a9fd852f0394', name: 'Elena Dela Cruz', ...options.userinfo }; return options.unsignedUserinfo ? json(claims) : new Response(jwt(claims, options.tamperUserinfo), { headers: { 'content-type': 'application/jwt' } }); @@ -104,7 +104,7 @@ describe('eSignet portal login', () => { 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, person_reference: { essential: true } } + 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: '8fa0e667-5042-5ba2-9837-a9fd852f0394', displayName: 'Elena Dela Cruz' }); @@ -136,7 +136,7 @@ describe('eSignet portal login', () => { ['UserInfo audience', { userinfo: { aud: 'another-client' } }], ['UserInfo subject correlation', { userinfo: { sub: 'different-subject' } }], ['unsigned UserInfo', { unsignedUserinfo: true }], - ['missing identity claim', { userinfo: { person_reference: null } }], + ['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); diff --git a/portal/src/lib/server/esignet.ts b/portal/src/lib/server/esignet.ts index c01b65f..eebf76a 100644 --- a/portal/src/lib/server/esignet.ts +++ b/portal/src/lib/server/esignet.ts @@ -71,7 +71,7 @@ export function esignetConfigFor(url: URL, env: NodeJS.ProcessEnv = process.env) 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 || 'person_reference', + subjectClaim: env.PORTAL_ESIGNET_SUBJECT_CLAIM || 'individual_id', secureCookies: env.PORTAL_SECURE_COOKIES === 'true' }; } diff --git a/scripts/esignet-protocol-proof.mjs b/scripts/esignet-protocol-proof.mjs index d1396d6..c16b252 100644 --- a/scripts/esignet-protocol-proof.mjs +++ b/scripts/esignet-protocol-proof.mjs @@ -23,7 +23,7 @@ import { requestEsignetOtp, verifyEsignetOtp } from '../portal/e2e/support/auth. 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 = ['person_reference', 'name', 'given_name', 'family_name', 'gender', 'birthdate']; +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']); @@ -271,7 +271,7 @@ export async function main() { 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 = { person_reference: { essential: true }, given_name: null, name: null, gender: null }; + 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(); } @@ -300,7 +300,7 @@ export async function main() { const strongerFlow = await beginLogin(browser, stronger, requested, callbacks); try { await authenticate(strongerFlow); - const body = await continueConsent(strongerFlow, ['person_reference', 'given_name']); + 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)); @@ -313,10 +313,10 @@ export async function main() { let subject; try { await authenticate(first); - const { token, callback } = await finishLogin(first, signed, ['person_reference', 'given_name', 'name']); + const { token, callback } = await finishLogin(first, signed, ['individual_id', 'given_name', 'name']); const claims = await signedClaims(signed, token); - assertDisclosure(claims, ['person_reference', 'given_name']); - assert.equal(claims.person_reference, process.env.ESIGNET_DEMO_REFERENCE || '44a5ef0a-aaa2-574e-8969-1069bf62b948'); + assertDisclosure(claims, ['individual_id', 'given_name']); + assert.equal(claims.individual_id, process.env.ESIGNET_DEMO_REFERENCE || '44a5ef0a-aaa2-574e-8969-1069bf62b948'); subject = claims.sub; await assert.rejects(() => oidc.authorizationCodeGrant(signed.config, callback, { expectedState: first.pending.state, expectedNonce: first.pending.nonce, @@ -329,13 +329,13 @@ export async function main() { const repeat = await beginLogin(browser, signed, requested, callbacks); try { await authenticate(repeat); - const { token } = await finishLogin(repeat, signed, ['person_reference', 'gender', 'name']); - assertDisclosure(await signedClaims(signed, token), ['person_reference', 'gender'], subject); + 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, { person_reference: null, given_name: null, name: null, gender: null }, callbacks); + 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, []); @@ -347,12 +347,12 @@ export async function main() { const encryptedFlow = await beginLogin(browser, encrypted, requested, callbacks); try { await authenticate(encryptedFlow); - const { token } = await finishLogin(encryptedFlow, encrypted, ['person_reference', 'given_name', 'name']); + const { token } = await finishLogin(encryptedFlow, encrypted, ['individual_id', 'given_name', 'name']); const claims = await encryptedClaims(encrypted, token); - assertDisclosure(claims, ['person_reference', 'given_name']); - assert.equal(claims.person_reference, process.env.ESIGNET_DEMO_REFERENCE || '44a5ef0a-aaa2-574e-8969-1069bf62b948'); + assertDisclosure(claims, ['individual_id', 'given_name']); + assert.equal(claims.individual_id, process.env.ESIGNET_DEMO_REFERENCE || '44a5ef0a-aaa2-574e-8969-1069bf62b948'); const repeatClaims = await encryptedClaims(encrypted, token); - assertDisclosure(repeatClaims, ['person_reference', 'given_name'], claims.sub); + assertDisclosure(repeatClaims, ['individual_id', 'given_name'], claims.sub); } finally { await encryptedFlow.close(); } console.log('esignet-protocol-proof: encrypted-userinfo PASS'); diff --git a/scripts/seed-esignet.py b/scripts/seed-esignet.py index a83715f..813bc46 100755 --- a/scripts/seed-esignet.py +++ b/scripts/seed-esignet.py @@ -22,7 +22,7 @@ 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") DEFAULT_CLIENT_CLAIMS = [ - "person_reference", + "individual_id", "family_name", "name", "given_name", diff --git a/scripts/test_runtime_topology.py b/scripts/test_runtime_topology.py index a859ca7..d54df72 100644 --- a/scripts/test_runtime_topology.py +++ b/scripts/test_runtime_topology.py @@ -772,7 +772,7 @@ def test_hosted_esignet_is_standalone_and_core_portal_owns_login_config( "PORTAL_ESIGNET_ISSUER": "${SOLMARA_ESIGNET_PUBLIC_BASE_URL:-https://esignet.solmara.registrystack.org}", "PORTAL_ESIGNET_REDIRECT_URI": "${SOLMARA_PORTAL_PUBLIC_BASE_URL:-https://portal.solmara.registrystack.org}/auth/callback", "PORTAL_ESIGNET_SCOPE": "${PORTAL_ESIGNET_SCOPE:-openid}", - "PORTAL_ESIGNET_SUBJECT_CLAIM": "person_reference", + "PORTAL_ESIGNET_SUBJECT_CLAIM": "individual_id", } for key, value in expected.items(): self.assertEqual(portal[key], value) From c4547130346d69a79837940701c3beeb881a4e7a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:12:23 +0700 Subject: [PATCH 4/6] fix(lab): validate native provider candidates without a release pin Signed-off-by: Jeremi Joslin --- scripts/check-registry-stack-release-pin.py | 1 - scripts/test_registry_stack_release_pin.py | 1 - 2 files changed, 2 deletions(-) diff --git a/scripts/check-registry-stack-release-pin.py b/scripts/check-registry-stack-release-pin.py index c75cbb3..9ff1e23 100755 --- a/scripts/check-registry-stack-release-pin.py +++ b/scripts/check-registry-stack-release-pin.py @@ -59,7 +59,6 @@ def validate(values: dict[str, str], *, require_public: bool) -> list[str]: if require_public and not asset_url: failures.append(f"{key} is not published; v{REQUIRED_VERSION} promotion is blocked") expected = { - "ESIGNET_AUTHENTICATOR_VERSION": "0.3.0", "ESIGNET_SOURCE_VERSION": "2.0.0-beta.1", "ESIGNET_SOURCE_COMMIT": "df0d0e771dae16eb2597b8e5b5dc65e70baa7f86", } diff --git a/scripts/test_registry_stack_release_pin.py b/scripts/test_registry_stack_release_pin.py index 5e33f5b..cb8bd3b 100644 --- a/scripts/test_registry_stack_release_pin.py +++ b/scripts/test_registry_stack_release_pin.py @@ -13,7 +13,6 @@ def authenticator_values() -> dict[str, str]: return { - "ESIGNET_AUTHENTICATOR_VERSION": "0.3.0", "ESIGNET_SOURCE_VERSION": "2.0.0-beta.1", "ESIGNET_SOURCE_COMMIT": "df0d0e771dae16eb2597b8e5b5dc65e70baa7f86", "ESIGNET_SOURCE_ARCHIVE_SHA256": "e" * 64, From 39e711b2297f5963c3527bda194af52bf84e1ea9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:14:48 +0700 Subject: [PATCH 5/6] fix(lab): validate optional identity topology without a built image Signed-off-by: Jeremi Joslin --- justfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 275eb0b..1dfce98 100644 --- a/justfile +++ b/justfile @@ -60,11 +60,12 @@ test: cd home && pnpm test # Validate local, hosted, eSignet, and every Coolify topology without starting it. +# The eSignet image below is a structural fixture, never pulled or used by startup. 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 + SOLMARA_ESIGNET_CANDIDATE_IMAGE="esignet-config-check@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 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 + SOLMARA_ESIGNET_CANDIDATE_IMAGE="esignet-config-check@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 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 From f3ef558f452bc66a893e179ac5509eee8deac3fd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 09:46:00 +0700 Subject: [PATCH 6/6] fix(esignet): preserve configured candidate image Signed-off-by: Jeremi Joslin --- scripts/gen-secrets.py | 20 +++++++++++++++++--- scripts/test_gen_secrets.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/scripts/gen-secrets.py b/scripts/gen-secrets.py index 0c0bdef..0e813b9 100755 --- a/scripts/gen-secrets.py +++ b/scripts/gen-secrets.py @@ -31,6 +31,9 @@ "SOLMARA_ESIGNET_POSTGRES_PASSWORD", "SOLMARA_ESIGNET_V2_KEYSTORE_PASSWORD", ) +PRESERVED_OPERATOR_ENV_KEYS = ( + "SOLMARA_ESIGNET_CANDIDATE_IMAGE", +) def b64url(raw: bytes) -> str: @@ -138,9 +141,22 @@ def compose_environment_values( **operator_values, } ) + values.update( + { + key: existing[key] + for key in PRESERVED_OPERATOR_ENV_KEYS + if key in existing + } + ) return values +def write_environment(path: Path, operator_values: dict[str, str]) -> None: + values = compose_environment_values(load_environment(path), operator_values) + path.write_text("# Generated by scripts/gen-secrets.py. Do not commit.\n" + "\n".join(f"{key}={shlex.quote(value)}" for key, value in sorted(values.items())) + "\n", encoding="utf-8") + path.chmod(0o600) + + def ensure_client_identifier(path: Path, client_id: str) -> None: """Write an exact public identifier while preserving unrelated material.""" @@ -233,9 +249,7 @@ def main() -> int: operator_values = ensure_operator_material() ensure_tls() output = ROOT / ".env" - values = compose_environment_values(load_environment(output), operator_values) - output.write_text("# Generated by scripts/gen-secrets.py. Do not commit.\n" + "\n".join(f"{key}={shlex.quote(value)}" for key, value in sorted(values.items())) + "\n", encoding="utf-8") - output.chmod(0o600) + write_environment(output, operator_values) print(f"Wrote {output}") return 0 diff --git a/scripts/test_gen_secrets.py b/scripts/test_gen_secrets.py index ae37f05..ac1d70d 100644 --- a/scripts/test_gen_secrets.py +++ b/scripts/test_gen_secrets.py @@ -85,6 +85,40 @@ def test_generated_environment_preserves_runtime_secrets(self) -> None: self.assertEqual(values[key], value) self.assertEqual(values["NIA_ESIGNET_CLIENT_PRIVATE_JWK"], "operator-jwk") + def test_regeneration_preserves_exact_operator_image(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / ".env" + candidate_image = ( + "registry.example/esignet-breg-candidate@sha256:" + + "a" * 64 + ) + output.write_text( + f"SOLMARA_ESIGNET_CANDIDATE_IMAGE={candidate_image}\n", + encoding="utf-8", + ) + operator = { + "NIA_ESIGNET_CLIENT_PRIVATE_JWK": "operator-jwk", + "SOLMARA_EVIDENCE_CLIENT_KEY": "/operator/client-key", + } + with ( + mock.patch.object( + MODULE, + "raw_key", + return_value="fresh-runtime-secret", + ), + mock.patch.object( + MODULE, + "rsa_private_key_b64", + return_value="fresh-portal-key", + ), + mock.patch.object(MODULE, "compose_project_name", return_value="test"), + ): + MODULE.write_environment(output, operator) + regenerated = MODULE.load_environment(output) + self.assertEqual(regenerated["SOLMARA_ESIGNET_CANDIDATE_IMAGE"], candidate_image) + self.assertEqual(regenerated["CRA_RELAY_AUDIT_KEY"], "fresh-runtime-secret") + self.assertEqual(regenerated["PORTAL_AUTH_PROVIDER"], "mock") + def test_generated_environment_rejects_duplicate_or_empty_values(self) -> None: with tempfile.TemporaryDirectory() as temporary: path = Path(temporary) / ".env"