Bind password setup grants to approved company welcome pages
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-12 02:43:31 +02:00
parent c8ad7a85ea
commit 48a75b1a54
7 changed files with 267 additions and 8 deletions

View file

@ -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:

View file

@ -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", "<p>Password setup could not be completed. Request a new link and try again.</p>")
def _complete_page() -> str:
return _page("Password set", "<p>Your password is ready. Return to the application to sign in and enroll MFA.</p>")
def _complete_page(return_to: str = "") -> str:
content = "<p>Your password is ready. Sign in with your own account to continue.</p>"
if return_to:
content += f'<p><a rel="noreferrer" href="{escape(return_to, quote=True)}">Continue to your company</a></p>'
else:
content += "<p>Return to the application to sign in and enroll MFA.</p>"
return _page("Password set", content)
if __name__ == "__main__":

View file

@ -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)

View file

@ -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")

View file

@ -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]))

View file

@ -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())

View file

@ -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.