Record the human onboarding run and fix password-setup usability
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 5s
Identity provider journey acceptance / provider (push) Successful in 15s
Build and Publish identity-provisioner / build-and-push (push) Successful in 7s

- NK-WP-0036 finished: native onboarding journey completed by the operator.
- NK-WP-0037-T02 waits on key-cape: Authelia 4.38 rejects every human
  prompt=login flow (auth_time precedes request registration).
- identity-provisioner: read-only autocomplete=username field on the setup
  form (submitted value ignored) and an HTTPS sign-in link on completion.
- NK-WP-0041 tracks the fixes and routes Authelia/user-engine findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 299762@bnt-lap001
Assistant-Session: d3d3cea1-869c-44f1-be2a-3d6d3550e72e
This commit is contained in:
tegwick 2026-09-23 21:48:29 +02:00
parent 15939d00af
commit 6c4fcaf9ae
7 changed files with 215 additions and 12 deletions

View file

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

View file

@ -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}}
</style><main><h1>{escape(title)}</h1>{content}</main></html>"""
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'<p class="error">{escape(error)}</p>' 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'<label>Login name<input type="text" name="username" value="{escape(login_name, quote=True)}" '
'readonly autocomplete="username"></label>'
if login_name else ""
)
return _page("Set up your password", f"""{message}
<p>This single-use link expires shortly. Choose a password of at least 12 characters.</p>
<form method="post" action="/setup/password" autocomplete="off">
<form method="post" action="/setup/password">
<input type="hidden" name="token" value="{escape(token)}">
{username}
<label>New password<input type="password" name="password" minlength="12" required autocomplete="new-password"></label>
<label>Confirm password<input type="password" name="confirmation" minlength="12" required autocomplete="new-password"></label>
<button type="submit">Set password</button></form>""")
@ -156,10 +174,12 @@ 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(return_to: str = "") -> str:
def _complete_page(return_to: str = "", signin_url: 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>'
elif signin_url:
content += f'<p><a rel="noreferrer" href="{escape(signin_url, quote=True)}">Sign in</a></p>'
else:
content += "<p>Return to the application to sign in and enroll MFA.</p>"
return _page("Password set", content)

View file

@ -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</a>', 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())

View file

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

View file

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

View file

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

View file

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