From 48a75b1a5456f224b077b73af1b345473823a743 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 02:43:31 +0200 Subject: [PATCH] Bind password setup grants to approved company welcome pages Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- identity-provisioner/password_setup.py | 18 ++++- identity-provisioner/server.py | 16 ++-- .../tests/test_company_return_http.py | 77 +++++++++++++++++++ .../tests/test_password_setup.py | 35 +++++++++ .../keycape/test_vergabe_client_rollout.py | 34 ++++++++ sso-mfa/k8s/keycape/vergabe-client-rollout.py | 39 ++++++++++ .../NK-WP-0037-vergabe-company-welcome.md | 56 ++++++++++++++ 7 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 identity-provisioner/tests/test_company_return_http.py create mode 100644 sso-mfa/k8s/keycape/test_vergabe_client_rollout.py create mode 100644 sso-mfa/k8s/keycape/vergabe-client-rollout.py create mode 100644 workplans/NK-WP-0037-vergabe-company-welcome.md diff --git a/identity-provisioner/password_setup.py b/identity-provisioner/password_setup.py index 0fc0e2d..d5f68cf 100644 --- a/identity-provisioner/password_setup.py +++ b/identity-provisioner/password_setup.py @@ -12,13 +12,14 @@ import threading import time from typing import Callable from urllib.request import Request, urlopen -from urllib.parse import urlencode +from urllib.parse import urlencode, urlsplit @dataclass(frozen=True) class SetupGrant: subject: str expires_at: float + return_to: str = "" class PasswordSetupGrants: @@ -34,9 +35,18 @@ class PasswordSetupGrants: *, public_url: str, setter: Callable[[str, str], None], + tenant_returns: dict[str, str] | None = None, ttl_seconds: int = 900, clock: Callable[[], float] = time.monotonic, ) -> None: + self.tenant_returns = dict(tenant_returns or {}) + for tenant, target in self.tenant_returns.items(): + parts = urlsplit(target) + if (not tenant.startswith("tenant:") or parts.scheme != "https" + or not parts.hostname or parts.username or parts.password + or parts.query or parts.fragment or not parts.path.startswith("/") + or parts.hostname in {"localhost", "127.0.0.1"}): + raise ValueError("tenant welcome targets require exact HTTPS URLs") self.public_url = public_url.rstrip("/") self.setter = setter self.ttl_seconds = ttl_seconds @@ -45,7 +55,7 @@ class PasswordSetupGrants: self._subjects: dict[str, str] = {} self._lock = threading.Lock() - def issue(self, subject: str) -> str: + def issue(self, subject: str, tenant: str = "") -> str: if not subject or len(subject) > 255: raise ValueError("valid external subject is required") token = secrets.token_urlsafe(32) @@ -56,6 +66,7 @@ class PasswordSetupGrants: self._grants.pop(previous, None) self._grants[digest] = SetupGrant( subject=subject, + return_to=self.tenant_returns.get(tenant, ""), expires_at=self.clock() + self.ttl_seconds, ) self._subjects[subject] = digest @@ -67,7 +78,7 @@ class PasswordSetupGrants: grant = self._grants.get(digest) return grant is not None and grant.expires_at > self.clock() - def consume(self, token: str, password: str) -> None: + def consume(self, token: str, password: str) -> str: if len(password) < 12: raise ValueError("password must contain at least 12 characters") digest = _digest(token) @@ -77,6 +88,7 @@ class PasswordSetupGrants: raise ValueError("password setup link is invalid or expired") self._subjects.pop(grant.subject, None) self.setter(grant.subject, password) + return grant.return_to class LLDAPPasswordSetter: diff --git a/identity-provisioner/server.py b/identity-provisioner/server.py index 596b3e7..7a85fad 100644 --- a/identity-provisioner/server.py +++ b/identity-provisioner/server.py @@ -46,7 +46,7 @@ class Handler(BaseHTTPRequestHandler): response = asdict(result) if path == "/v1/identities/provision" and result.status == "password_setup_required": response["password_setup_url"] = self.password_setups.issue( - _directory_username(result.external_subject) + _directory_username(result.external_subject), tenant=str(payload.get("tenant", "")) ) self._send(200, response) @@ -61,12 +61,12 @@ class Handler(BaseHTTPRequestHandler): if password != confirmation: return self._html(400, _setup_page(token, "Passwords do not match.")) try: - self.password_setups.consume(token, password) + return_to = self.password_setups.consume(token, password) except ValueError as exc: return self._html(400, _setup_page(token, str(exc))) except RuntimeError: return self._html(503, _failed_page()) - return self._html(200, _complete_page()) + return self._html(200, _complete_page(return_to)) def _send(self, status: int, payload: dict): body = json.dumps(payload, separators=(",", ":")).encode() @@ -108,6 +108,7 @@ def main(): base_url=os.environ["LLDAP_URL"], admin_password=os.environ["LLDAP_ADMIN_PASSWORD"], ), + tenant_returns=json.loads(os.environ.get("PASSWORD_SETUP_TENANT_RETURNS", "{}")), ttl_seconds=int(os.environ.get("PASSWORD_SETUP_TTL_SECONDS", "900")), ) ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever() @@ -142,8 +143,13 @@ def _failed_page() -> str: return _page("Setup unavailable", "

Password setup could not be completed. Request a new link and try again.

") -def _complete_page() -> str: - return _page("Password set", "

Your password is ready. Return to the application to sign in and enroll MFA.

") +def _complete_page(return_to: str = "") -> str: + content = "

Your password is ready. Sign in with your own account to continue.

" + if return_to: + content += f'

Continue to your company

' + else: + content += "

Return to the application to sign in and enroll MFA.

" + return _page("Password set", content) if __name__ == "__main__": diff --git a/identity-provisioner/tests/test_company_return_http.py b/identity-provisioner/tests/test_company_return_http.py new file mode 100644 index 0000000..9487364 --- /dev/null +++ b/identity-provisioner/tests/test_company_return_http.py @@ -0,0 +1,77 @@ +import json +import pathlib +import sys +import threading +import unittest +from http.server import ThreadingHTTPServer +from urllib.error import HTTPError +from urllib.parse import urlencode, urlsplit, parse_qs +from urllib.request import Request, urlopen + +sys.path.insert(0, str(pathlib.Path(__file__).parents[1])) +from password_setup import PasswordSetupGrants +from provisioner import Result +from server import Handler + + +class CompanyReturnHTTPTests(unittest.TestCase): + def setUp(self): + self.calls = [] + class Provisioner: + def provision(self, payload): + return Result('fixture', 'uid=recipient,ou=people', 'password_setup_required', False) + class TestHandler(Handler): + service_token = 'fixture-only-service-token' + provisioner = Provisioner() + password_setups = PasswordSetupGrants( + public_url='https://kc.example/setup/password', + setter=lambda subject, password: self.calls.append(subject), + tenant_returns={'tenant:trial:demo': 'https://app.example/demo/'}, + ) + self.handler = TestHandler + self.server = ThreadingHTTPServer(('127.0.0.1', 0), TestHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.base = 'http://127.0.0.1:' + str(self.server.server_port) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + + def issue(self): + payload = {'user_id': 'fixture', 'tenant': 'tenant:trial:demo', + 'primary_email': 'fixture@example.test', 'idempotency_key': 'fixture-long-idempotency', + 'correlation_id': 'fixture', 'return_to': 'https://evil.example/'} + request = Request(self.base + '/v1/identities/provision', data=json.dumps(payload).encode(), + headers={'Authorization': 'Bearer fixture-only-service-token'}) + with urlopen(request) as response: + url = json.load(response)['password_setup_url'] + return parse_qs(urlsplit(url).query)['token'][0] + + def submit(self, token): + request = Request(self.base + '/setup/password', data=urlencode({ + 'token': token, 'password': 'fixture-long-password', 'confirmation': 'fixture-long-password', + 'return_to': 'https://evil.example/', + }).encode(), headers={'Content-Type': 'application/x-www-form-urlencoded'}) + try: + response = urlopen(request) + except HTTPError as error: + response = error + with response: + return response.status, response.read().decode(), response.headers + + def test_authenticated_issue_and_completion_ignore_browser_return(self): + token = self.issue() + status, body, headers = self.submit(token) + self.assertEqual(200, status) + self.assertIn('https://app.example/demo/', body) + self.assertNotIn('evil.example', body) + self.assertNotIn(token, body) + self.assertNotIn('recipient', body) + self.assertEqual('no-referrer', headers['Referrer-Policy']) + self.assertEqual(['recipient'], self.calls) + status, body, headers = self.submit(token) + self.assertEqual(400, status) + self.assertNotIn('https://app.example/demo/', body) + self.assertEqual(['recipient'], self.calls) diff --git a/identity-provisioner/tests/test_password_setup.py b/identity-provisioner/tests/test_password_setup.py index 68315f4..d775267 100644 --- a/identity-provisioner/tests/test_password_setup.py +++ b/identity-provisioner/tests/test_password_setup.py @@ -47,3 +47,38 @@ class PasswordSetupGrantTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "12 characters"): self.grants.consume(token, "too-short") self.assertTrue(self.grants.valid(token)) + + +class CompanyReturnTests(unittest.TestCase): + def test_return_is_bound_to_grant_and_cannot_be_changed_by_browser(self): + mapping = {"tenant:trial:demo-company": "https://vergabe.example/demo-company/"} + grants = PasswordSetupGrants(public_url="https://kc.example/setup/password", + setter=lambda *args: None, tenant_returns=mapping) + url = grants.issue("recipient", "tenant:trial:demo-company") + token = url.partition("token=")[2] + mapping["tenant:trial:demo-company"] = "https://attacker.example/" + self.assertNotIn("recipient", url) + self.assertNotIn("return", url) + self.assertEqual("https://vergabe.example/demo-company/", grants.consume(token, "test-password-long")) + with self.assertRaises(ValueError): + grants.consume(token, "test-password-long") + + def test_unknown_tenant_has_no_return_and_bad_targets_fail(self): + for target in ("http://example.test/", "https://example.test/?next=evil", + "https://example.test/#fragment", "https://user:password@example.test/"): + with self.assertRaises(ValueError): + PasswordSetupGrants(public_url="https://kc.example/setup/password", + setter=lambda *args: None, tenant_returns={"tenant:trial:demo": target}) + grants = PasswordSetupGrants(public_url="https://kc.example/setup/password", setter=lambda *args: None) + token = grants.issue("recipient", "tenant:unknown").partition("token=")[2] + self.assertEqual("", grants.consume(token, "test-password-long")) + + def test_expiry_and_failure_never_release_a_return(self): + clock = [0] + grants = PasswordSetupGrants(public_url="https://kc.example/setup/password", + setter=lambda *args: None, clock=lambda: clock[0], ttl_seconds=1, + tenant_returns={"tenant:trial:demo": "https://app.example/demo/"}) + token = grants.issue("recipient", "tenant:trial:demo").partition("token=")[2] + clock[0] = 2 + with self.assertRaises(ValueError): + grants.consume(token, "test-password-long") diff --git a/sso-mfa/k8s/keycape/test_vergabe_client_rollout.py b/sso-mfa/k8s/keycape/test_vergabe_client_rollout.py new file mode 100644 index 0000000..cc2d011 --- /dev/null +++ b/sso-mfa/k8s/keycape/test_vergabe_client_rollout.py @@ -0,0 +1,34 @@ +import base64 +import importlib.util +from pathlib import Path +import unittest +import yaml + +spec = importlib.util.spec_from_file_location('vergabe', Path(__file__).with_name('vergabe-client-rollout.py')) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class RegistrationTests(unittest.TestCase): + def secret(self, clients): + self.config = {'issuer': 'https://kc.coulomb.social', 'authelia': {'issuer': 'https://auth.coulomb.social'}, + 'clients': clients} + raw = yaml.safe_dump(self.config, sort_keys=False) + return {'data': {'config.yaml': base64.b64encode(raw.encode()).decode()}} + + def test_adds_only_exact_public_client_and_is_idempotent(self): + prior = {'clientId': 'existing', 'clientType': 'public', + 'redirectUris': ['https://existing.example/callback']} + secret = self.secret([prior]) + encoded, changed = module.replacement(secret) + self.assertTrue(changed) + result = yaml.safe_load(base64.b64decode(encoded)) + self.assertEqual(dict(self.config, clients=[prior, module.CLIENT]), result) + secret['data']['config.yaml'] = encoded + self.assertEqual((encoded, False), module.replacement(secret)) + + def test_no_existing_client_rewrite_and_new_binary_invalid_fields_refused(self): + for client in [dict(module.CLIENT, redirectUris=['https://wrong.example/']), + {'clientId': 'existing', 'roles': ['operator']}]: + with self.assertRaises(module.rollout.Refused): + module.replacement(self.secret([client])) diff --git a/sso-mfa/k8s/keycape/vergabe-client-rollout.py b/sso-mfa/k8s/keycape/vergabe-client-rollout.py new file mode 100644 index 0000000..1875b96 --- /dev/null +++ b/sso-mfa/k8s/keycape/vergabe-client-rollout.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Fixed Vergabe registration using the existing guarded KeyCape rollout lane.""" +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('portal_rollout', ROOT / 'portal-client-rollout.py') +rollout = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rollout) +CLIENT = { + 'clientId': 'vergabe-demo-company', + 'displayName': 'Vergabe Demo Company', + 'redirectUris': ['https://vergabe-teilnahme.coulomb.social/demo-company/accounts/oidc/callback/'], + 'allowedScopes': ['openid', 'profile', 'groups'], + 'grantTypes': ['authorization_code'], + 'clientType': 'public', +} +# Reuse the owner's byte-preserving insertion, CAS, cluster pin and safe receipts. +# The portal-specific legacy migration branch is explicitly unreachable here. +original_replacement = rollout.replacement +rollout.portal = SimpleNamespace(CLIENT_ID=CLIENT['clientId'], CLIENT=CLIENT) + + +def replacement(secret): + _, config, _ = rollout.pin.issuer_document(secret) + for client in config.get('clients', []): + if 'client_credentials' not in client.get('grantTypes', []): + rollout.require(not client.get('roles') and not client.get('serviceSubject'), + 'browser_service_identity_fields_block_new_binary') + if client.get('clientId') == CLIENT['clientId']: + rollout.require(client == CLIENT, 'existing_vergabe_registration_differs') + return original_replacement(secret) + + +rollout.replacement = replacement + +if __name__ == '__main__': + raise SystemExit(rollout.main()) diff --git a/workplans/NK-WP-0037-vergabe-company-welcome.md b/workplans/NK-WP-0037-vergabe-company-welcome.md new file mode 100644 index 0000000..3567ca8 --- /dev/null +++ b/workplans/NK-WP-0037-vergabe-company-welcome.md @@ -0,0 +1,56 @@ +--- +id: NK-WP-0037 +type: workplan +title: "Bind password setup to the Vergabe company welcome and sign-in" +domain: infotech +repo: net-kingdom +status: active +owner: codex +topic_slug: netkingdom +created: "2026-09-12" +updated: "2026-09-12" +related: [VERGABE-WP-0019, KEY-WP-0033, RAPPS-WP-0014] +--- + +## Keep the company return inside the one-use setup grant + +```task +id: NK-WP-0037-T01 +status: done +priority: high +``` + +PasswordSetupGrants now accepts an exact tenant-to-HTTPS-entry mapping, copies +it at startup, and stores the destination inside the recipient's grant when +issuing a setup link. Only successful password setup releases the company link. +The service-authenticated provisioning tenant selects it; browser return fields +are ignored. No recipient, credential or setup token travels to the product. +The product performs its own fresh login and explicit account confirmation. + +Sixteen provisioner tests pass, including real HTTP issuance/completion, +malicious return fields, replay/expiry, other tenants and configuration validation. +Existing setup links remain process-local and expire on restart. + +## Bind and release the provider and company client + +```task +id: NK-WP-0037-T02 +status: todo +priority: high +``` + +Register public client vergabe-demo-company with only openid/profile/groups, +authorization_code and the exact callback +https://vergabe-teilnahme.coulomb.social/demo-company/accounts/oidc/callback/. +No client-declared tenant, secret, public registration or MFA downgrade. +Preserve all existing KeyCape configuration using the established guarded +client-registration lane. KEY-WP-0033 retains fresh-login propagation and the +shared issuer rollout preflight. + +Set PASSWORD_SETUP_TENANT_RETURNS to map tenant:trial:demo-company to +https://vergabe-teilnahme.coulomb.social/demo-company/. Publish and pin the +provisioner image with its 25m/32Mi request unchanged. Preserve the password +setter, credential references and directory contents. Recipient sign-in and +provider-required MFA need an attended acceptance; no credential capture or +operator impersonation. VERGABE-WP-0019-T06 owns product acceptance and +RAPPS-WP-0014 retains application placement and recovery.