Bind password setup grants to approved company welcome pages
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
c8ad7a85ea
commit
48a75b1a54
7 changed files with 267 additions and 8 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
77
identity-provisioner/tests/test_company_return_http.py
Normal file
77
identity-provisioner/tests/test_company_return_http.py
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
34
sso-mfa/k8s/keycape/test_vergabe_client_rollout.py
Normal file
34
sso-mfa/k8s/keycape/test_vergabe_client_rollout.py
Normal 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]))
|
||||
39
sso-mfa/k8s/keycape/vergabe-client-rollout.py
Normal file
39
sso-mfa/k8s/keycape/vergabe-client-rollout.py
Normal 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())
|
||||
56
workplans/NK-WP-0037-vergabe-company-welcome.md
Normal file
56
workplans/NK-WP-0037-vergabe-company-welcome.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue