diff --git a/identity-provisioner/password_setup.py b/identity-provisioner/password_setup.py index 81a255a..6104aa7 100644 --- a/identity-provisioner/password_setup.py +++ b/identity-provisioner/password_setup.py @@ -79,6 +79,15 @@ class PasswordSetupGrants: grant = self._grants.get(digest) return grant is not None and grant.expires_at > self.clock() + def login_name(self, token: str) -> str: + """Directory login name bound to a still-valid grant, or empty.""" + digest = _digest(token) + with self._lock: + grant = self._grants.get(digest) + if grant is None or grant.expires_at <= self.clock(): + return "" + return grant.subject + def consume(self, token: str, password: str) -> str: if len(password) < 12: raise ValueError("password must contain at least 12 characters") diff --git a/identity-provisioner/server.py b/identity-provisioner/server.py index a86a949..0331795 100644 --- a/identity-provisioner/server.py +++ b/identity-provisioner/server.py @@ -14,6 +14,7 @@ class Handler(BaseHTTPRequestHandler): provisioner: LLDAPProvisioner password_setups: PasswordSetupGrants service_token: str + signin_url: str = "" def do_GET(self): path = urlsplit(self.path) @@ -34,7 +35,7 @@ class Handler(BaseHTTPRequestHandler): token = parse_qs(path.query).get("token", [""])[0] if not self.password_setups.valid(token): return self._html(400, _expired_page()) - return self._html(200, _setup_page(token)) + return self._html(200, _setup_page(token, self.password_setups.login_name(token))) self._send(404, {"error": "not_found"}) def do_POST(self): @@ -71,15 +72,16 @@ class Handler(BaseHTTPRequestHandler): token = body.get("token", [""])[0] password = body.get("password", [""])[0] confirmation = body.get("confirmation", [""])[0] + login_name = self.password_setups.login_name(token) if password != confirmation: - return self._html(400, _setup_page(token, "Passwords do not match.")) + return self._html(400, _setup_page(token, login_name, "Passwords do not match.")) try: return_to = self.password_setups.consume(token, password) except ValueError as exc: - return self._html(400, _setup_page(token, str(exc))) + return self._html(400, _setup_page(token, login_name, str(exc))) except (DependencyFailure, RuntimeError): return self._html(503, _failed_page()) - return self._html(200, _complete_page(return_to)) + return self._html(200, _complete_page(return_to, self.signin_url)) def _send(self, status: int, payload: dict): body = json.dumps(payload, separators=(",", ":")).encode() @@ -115,6 +117,7 @@ def main(): Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip() if not Handler.service_token: raise ValueError("PROVISIONER_SERVICE_TOKEN must not be empty") + Handler.signin_url = _https_or_empty(os.environ.get("PASSWORD_SETUP_SIGNIN_URL", "")) Handler.password_setups = PasswordSetupGrants( public_url=os.environ["PASSWORD_SETUP_PUBLIC_URL"], setter=LLDAPPasswordSetter( @@ -137,12 +140,27 @@ input,button{{padding:.7rem}}.error{{color:#a00}}

{escape(title)}

{content}
""" -def _setup_page(token: str, error: str = "") -> str: +def _https_or_empty(url: str) -> str: + url = url.strip() + if url and not url.startswith("https://"): + raise ValueError("PASSWORD_SETUP_SIGNIN_URL must be an HTTPS URL") + return url + + +def _setup_page(token: str, login_name: str = "", error: str = "") -> str: message = f'

{escape(error)}

' if error else "" + # A read-only username field lets password managers store the new + # password against the directory login name, not against nothing. + username = ( + f'' + if login_name else "" + ) return _page("Set up your password", f"""{message}

This single-use link expires shortly. Choose a password of at least 12 characters.

-
+ +{username}
""") @@ -156,10 +174,12 @@ def _failed_page() -> str: return _page("Setup unavailable", "

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

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

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

" if return_to: content += f'

Continue to your company

' + elif signin_url: + content += f'

Sign in

' else: content += "

Return to the application to sign in and enroll MFA.

" return _page("Password set", content) diff --git a/identity-provisioner/tests/test_company_return_http.py b/identity-provisioner/tests/test_company_return_http.py index 9487364..0c1f9ba 100644 --- a/identity-provisioner/tests/test_company_return_http.py +++ b/identity-provisioner/tests/test_company_return_http.py @@ -75,3 +75,46 @@ class CompanyReturnHTTPTests(unittest.TestCase): self.assertEqual(400, status) self.assertNotIn('https://app.example/demo/', body) self.assertEqual(['recipient'], self.calls) + + +class SetupFormUsabilityTests(CompanyReturnHTTPTests): + # Reuses the harness only; the company-return case is covered above. + test_authenticated_issue_and_completion_ignore_browser_return = None + + def setUp(self): + super().setUp() + self.handler.password_setups.tenant_returns.clear() + + def get_form(self, token): + with urlopen(self.base + '/setup/password?' + urlencode({'token': token})) as response: + return response.read().decode() + + def test_form_offers_read_only_username_for_password_managers(self): + body = self.get_form(self.issue()) + self.assertIn('name="username" value="recipient" readonly autocomplete="username"', body) + self.assertNotIn('autocomplete="off"', body) + + def test_submitted_username_is_ignored(self): + token = self.issue() + request = Request(self.base + '/setup/password', data=urlencode({ + 'token': token, 'username': 'someone-else', 'password': 'fixture-long-password', + 'confirmation': 'fixture-long-password', + }).encode(), headers={'Content-Type': 'application/x-www-form-urlencoded'}) + with urlopen(request) as response: + self.assertEqual(200, response.status) + self.assertEqual(['recipient'], self.calls) + + def test_completion_links_to_sign_in_when_no_company_return(self): + self.handler.signin_url = 'https://users.example/' + status, body, _ = self.submit(self.issue()) + self.assertEqual(200, status) + self.assertIn('href="https://users.example/">Sign in', body) + self.assertNotIn('recipient', body) + + def test_expired_link_shows_no_username(self): + token = self.issue() + self.handler.password_setups.clock = lambda: float('inf') + request = Request(self.base + '/setup/password?' + urlencode({'token': token})) + with self.assertRaises(HTTPError) as caught: + urlopen(request) + self.assertNotIn('recipient', caught.exception.read().decode()) diff --git a/sso-mfa/k8s/identity-provisioner/deployment.yaml b/sso-mfa/k8s/identity-provisioner/deployment.yaml index 453fc0f..e7b6d37 100644 --- a/sso-mfa/k8s/identity-provisioner/deployment.yaml +++ b/sso-mfa/k8s/identity-provisioner/deployment.yaml @@ -37,6 +37,7 @@ spec: secretKeyRef: {name: identity-provisioner-token, key: token} - {name: PASSWORD_SETUP_PUBLIC_URL, value: "https://kc.coulomb.social/setup/password"} - {name: PASSWORD_SETUP_TTL_SECONDS, value: "900"} + - {name: PASSWORD_SETUP_SIGNIN_URL, value: "https://users.coulomb.social/"} - name: PASSWORD_SETUP_TENANT_RETURNS value: '{"tenant:trial:demo-company":"https://vergabe-teilnahme.coulomb.social/demo-company/"}' securityContext: diff --git a/workplans/NK-WP-0036-restore-user-portal-client-registration.md b/workplans/NK-WP-0036-restore-user-portal-client-registration.md index 4dd1e9e..87b2185 100644 --- a/workplans/NK-WP-0036-restore-user-portal-client-registration.md +++ b/workplans/NK-WP-0036-restore-user-portal-client-registration.md @@ -4,12 +4,12 @@ type: workplan title: "Restore native portal login and tenant-onboarding integration" domain: infotech repo: net-kingdom -status: active +status: finished flavor: implementation owner: the-custodian topic_slug: netkingdom created: "2026-09-11" -updated: "2026-09-14" +updated: "2026-09-23" related: [KEY-WP-0007, RAPPS-WP-0014, VERGABE-WP-0019] state_hub_workstream_id: "6e1358d6-87e4-52e7-b3dd-09abdc48cefc" --- @@ -143,7 +143,7 @@ to export another live Secret. Retain NK-WP-0033's separate incident residuals. ```task id: NK-WP-0036-T05 -status: progress +status: done priority: high state_hub_task_id: "46d33ab6-d76b-537f-8d60-32c4451675b5" ``` @@ -176,6 +176,27 @@ covers provider login, user create/linkage and password setup as a single human attempt. Do not retry a rejected password in a loop. Demo users and application admission stay RAPPS-WP-0014 and VERGABE-WP-0019. +2026-09-23: one native onboarding journey was completed as a single human +attempt by operator Bernd Worsch, acting as both provider and recipient. The +recipient step used a private browser window. The steps were: + +1. The provider signed in to the user portal (KeyCape `user-engine-portal` + `auth_success`/`token_issued` at 19:22). +2. The provider created test recipient "Test Recipient" in + `tenant:trial:demo-company` and used Create login. This returned login name + `bernd.worsch-99` and the single-use setup handoff. +3. The recipient set a password through the link. +4. The recipient signed in to the portal with the login name (KeyCape + portal `auth_success` at 19:32). + +No password was retried in a loop. The agent handled no credential. The +account is kept as a labelled test user. + +Findings routed to NK-WP-0041: sign-in with a `+` email fails with an LDAP +filter error; the password manager stored the password without a username; +there was no sign-in link after setup; the setup link is shown to the +provider rather than mailed. + ## Admit the canonical users hostname and preserve callback validation diff --git a/workplans/NK-WP-0037-vergabe-company-welcome.md b/workplans/NK-WP-0037-vergabe-company-welcome.md index 6861ee7..031ce02 100644 --- a/workplans/NK-WP-0037-vergabe-company-welcome.md +++ b/workplans/NK-WP-0037-vergabe-company-welcome.md @@ -9,7 +9,7 @@ flavor: implementation owner: codex topic_slug: netkingdom created: "2026-09-12" -updated: "2026-09-12" +updated: "2026-09-23" related: [VERGABE-WP-0019, KEY-WP-0033, RAPPS-WP-0014] state_hub_workstream_id: "15f59624-ab25-5894-9b05-1e6b261749e5" --- @@ -38,7 +38,7 @@ Existing setup links remain process-local and expire on restart. ```task id: NK-WP-0037-T02 -status: progress +status: wait priority: high state_hub_task_id: "8921691b-e7a2-543c-8189-3abc24de1bc7" ``` @@ -82,3 +82,35 @@ identity mappings and staff accounts. Native invited-user sign-in/MFA and confirmation are now requested from the operator; no user credential was used by the agent. Recovery and two-user acceptance remain their existing tasks. Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md. + +### Attended recipient sign-in 2026-09-23 — blocked on fresh-login + +Test recipient `bernd.worsch-99` (NK-WP-0036-T05) opened the Vergabe demo +company in a private window. The sign-in failed with KeyCape's "Sign-in could +not be completed" page, in this sequence: + +1. KeyCape logged `auth_start` for `vergabe-demo-company` at 19:40:54. +2. The recipient authenticated at Authelia at 19:41:03. +3. Authelia registered the authorization request at 19:41:04.17 and + refused it: "prompt was set to 'login' but auth_time … happened before the + authorization request … was registered, indicating that the user was not + re-authenticated". It issued no code. +4. KeyCape's code exchange failed (`token_exchange_error`, then + `auth_failed`). + +Authelia `4.38` registers a `prompt=login` request only after the login, so +every human fresh-login flow through KeyCape fails. The earlier "fresh-login +forwarding" checks were redirect-only and never completed a real login. +Portal sign-in does not request a fresh login and works. + +Ownership: fresh-login propagation belongs to key-cape (KEY-WP-0033). The +Authelia version and configuration belong to the reference deployment. There +are two candidate fixes: + +- KeyCape enforces freshness itself (`max_age`/`auth_time`) instead of + forwarding `prompt=login`; +- move to an Authelia release whose flow handling supports `prompt=login` + (not yet verified upstream). + +T02 waits on key-cape. MFA enrollment and account confirmation were not +reached. Rerun the same recipient once the fix is live. diff --git a/workplans/NK-WP-0041-onboarding-journey-usability.md b/workplans/NK-WP-0041-onboarding-journey-usability.md new file mode 100644 index 0000000..008c708 --- /dev/null +++ b/workplans/NK-WP-0041-onboarding-journey-usability.md @@ -0,0 +1,77 @@ +--- +id: NK-WP-0041 +type: workplan +title: "Fix onboarding-journey defects found in the 2026-09-23 human run" +domain: infotech +repo: net-kingdom +status: active +flavor: implementation +owner: claude-code +topic_slug: netkingdom +created: "2026-09-23" +updated: "2026-09-23" +related: [NK-WP-0036, NK-WP-0037, KEY-WP-0033] +--- + +Found by the operator's native onboarding run (NK-WP-0036-T05) and the +blocked Vergabe sign-in (NK-WP-0037-T02). Each item names its owner, and only +items in this repository are implemented here. + +## Password managers and sign-in after password setup + +```task +id: NK-WP-0041-T01 +status: progress +priority: medium +``` + +The setup form had no username field, so browsers stored the new password +without a login. After setup there was no way on to the sign-in page. The +source is fixed in `identity-provisioner`: + +- The setup form shows the grant's login name as a read-only + `autocomplete="username"` field. The submitted value is ignored, and the + form no longer sets `autocomplete="off"`. +- The completion page links to `PASSWORD_SETUP_SIGNIN_URL` (HTTPS only) + when there is no company return. The deployment declares + `https://users.coulomb.social/`. +- The completion page still does not show the login name, as an existing + test requires. + +Four new HTTP tests cover this, and the 32 provisioner tests pass. Remaining: +publish through the identity-provisioner image lane, then promote the digest +with operator approval. + +## Plus-addressed email sign-in fails with an LDAP filter error + +```task +id: NK-WP-0041-T02 +status: todo +priority: medium +``` + +Authelia's `users_filter` accepts `uid` or `mail`. Sign-in as +`bernd.worsch+99@gmail.com` failed with "LDAP Result Code 201 Filter Compile +Error: invalid characters for escape", not a clean result. A probe with fake +addresses (`nk-probe@example.invalid` compared with `nk-probe+x@…`) showed +that only the `+` form triggers the error. Establish whether Authelia 4.38 +escapes `+` DN-style inside the filter, and fix it through the reference +configuration or the version. Users who use plus-addressing cannot sign in +by email until then. + +## Route the portal findings to user-engine + +```task +id: NK-WP-0041-T03 +status: todo +priority: low +``` + +These belong to user-engine: + +- The setup link is shown to the provider instead of being delivered to the + recipient (journey U04/T03: mail delivery unresolved). +- The derived login name (`bernd.worsch-99`) is not obvious to the recipient. +- There is no sign-in link from the user entry once a password has been set. + +Send these to user-engine and record the reply.