Add public registration browser journey
This commit is contained in:
parent
c12bc604a8
commit
0669fa7a85
3 changed files with 231 additions and 14 deletions
|
|
@ -171,6 +171,44 @@ class PortalApplication:
|
||||||
actor = self._optional_actor(environ)
|
actor = self._optional_actor(environ)
|
||||||
return self._html(start_response, self._home(actor), correlation_id)
|
return self._html(start_response, self._home(actor), correlation_id)
|
||||||
|
|
||||||
|
if path == "/register" and method == "GET":
|
||||||
|
if not self.public_registration or self.registration_verification is None:
|
||||||
|
raise NotFoundError("public registration is unavailable")
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
return self._html(
|
||||||
|
start_response, self._registration_form(token), correlation_id,
|
||||||
|
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
|
||||||
|
)
|
||||||
|
if path == "/register" and method == "POST":
|
||||||
|
body = self._form_body(environ)
|
||||||
|
self._require_registration_csrf(environ, str(body.get("csrf_token", "")))
|
||||||
|
return self._start_public_registration(
|
||||||
|
environ, start_response, correlation_id, body=body, browser=True
|
||||||
|
)
|
||||||
|
if path == "/registration/verify" and method == "GET":
|
||||||
|
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||||
|
handle = str(query.get("handle", [""])[0])
|
||||||
|
if len(handle) < 16:
|
||||||
|
raise ValidationError("verification handle is invalid")
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
return self._html(
|
||||||
|
start_response, self._registration_verification_form(token, handle),
|
||||||
|
correlation_id,
|
||||||
|
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
|
||||||
|
)
|
||||||
|
if path == "/registration/verify" and method == "POST":
|
||||||
|
body = self._form_body(environ)
|
||||||
|
self._require_registration_csrf(environ, str(body.get("csrf_token", "")))
|
||||||
|
return self._verify_public_registration(
|
||||||
|
environ, start_response, correlation_id, body=body, browser=True
|
||||||
|
)
|
||||||
|
if path == "/registration/resume" and method == "POST":
|
||||||
|
body = self._form_body(environ)
|
||||||
|
self._require_registration_csrf(environ, str(body.get("csrf_token", "")))
|
||||||
|
return self._resume_public_registration(
|
||||||
|
environ, start_response, correlation_id, body=body, browser=True
|
||||||
|
)
|
||||||
|
|
||||||
if path == "/api/v1/public/registrations" and method == "POST":
|
if path == "/api/v1/public/registrations" and method == "POST":
|
||||||
return self._start_public_registration(
|
return self._start_public_registration(
|
||||||
environ, start_response, correlation_id
|
environ, start_response, correlation_id
|
||||||
|
|
@ -820,10 +858,11 @@ class PortalApplication:
|
||||||
environ: Mapping[str, Any],
|
environ: Mapping[str, Any],
|
||||||
start_response: StartResponse,
|
start_response: StartResponse,
|
||||||
correlation_id: str,
|
correlation_id: str,
|
||||||
|
*, body: Mapping[str, Any] | None = None, browser: bool = False,
|
||||||
) -> Iterable[bytes]:
|
) -> Iterable[bytes]:
|
||||||
if not self.public_registration or self.registration_verification is None:
|
if not self.public_registration or self.registration_verification is None:
|
||||||
raise NotFoundError("public registration is unavailable")
|
raise NotFoundError("public registration is unavailable")
|
||||||
body = self._body(environ)
|
body = body if body is not None else self._body(environ)
|
||||||
username = self._registration_username(body.get("username"))
|
username = self._registration_username(body.get("username"))
|
||||||
email = self._registration_email(body.get("email"))
|
email = self._registration_email(body.get("email"))
|
||||||
display_name = str(body.get("display_name") or "").strip() or None
|
display_name = str(body.get("display_name") or "").strip() or None
|
||||||
|
|
@ -865,6 +904,17 @@ class PortalApplication:
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if browser:
|
||||||
|
return self._html(
|
||||||
|
start_response,
|
||||||
|
self._page_html(
|
||||||
|
"Check your email",
|
||||||
|
"<h1>Check your email.</h1>"
|
||||||
|
"<p>If the address can be registered, a verification link is on its way. "
|
||||||
|
"The link expires after 30 minutes.</p>",
|
||||||
|
),
|
||||||
|
correlation_id,
|
||||||
|
)
|
||||||
return self._json(
|
return self._json(
|
||||||
start_response,
|
start_response,
|
||||||
"202 Accepted",
|
"202 Accepted",
|
||||||
|
|
@ -877,10 +927,11 @@ class PortalApplication:
|
||||||
environ: Mapping[str, Any],
|
environ: Mapping[str, Any],
|
||||||
start_response: StartResponse,
|
start_response: StartResponse,
|
||||||
correlation_id: str,
|
correlation_id: str,
|
||||||
|
*, body: Mapping[str, Any] | None = None, browser: bool = False,
|
||||||
) -> Iterable[bytes]:
|
) -> Iterable[bytes]:
|
||||||
if not self.public_registration or self.registration_verification is None:
|
if not self.public_registration or self.registration_verification is None:
|
||||||
raise NotFoundError("public registration is unavailable")
|
raise NotFoundError("public registration is unavailable")
|
||||||
body = self._body(environ)
|
body = body if body is not None else self._body(environ)
|
||||||
evidence = self.registration_verification.consume(str(body.get("handle") or ""))
|
evidence = self.registration_verification.consume(str(body.get("handle") or ""))
|
||||||
session = self.service.store.registration_session(evidence.registration_id)
|
session = self.service.store.registration_session(evidence.registration_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
|
|
@ -932,19 +983,32 @@ class PortalApplication:
|
||||||
return self._provision_public_registration(
|
return self._provision_public_registration(
|
||||||
start_response, actor, completion.user, completion.session,
|
start_response, actor, completion.user, completion.session,
|
||||||
evidence.normalized_email, evidence.display_name,
|
evidence.normalized_email, evidence.display_name,
|
||||||
evidence.preferred_username, correlation_id,
|
evidence.preferred_username, correlation_id, browser=browser,
|
||||||
)
|
)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
if browser:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
return self._html(
|
||||||
|
start_response,
|
||||||
|
self._registration_resume_form(
|
||||||
|
token, updated.registration_id, resume_handle
|
||||||
|
),
|
||||||
|
correlation_id,
|
||||||
|
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
|
||||||
|
)
|
||||||
return self._json(start_response, "202 Accepted", {
|
return self._json(start_response, "202 Accepted", {
|
||||||
"status": "provisioning_pending",
|
"status": "provisioning_pending",
|
||||||
"registration_id": updated.registration_id,
|
"registration_id": updated.registration_id,
|
||||||
"resume_handle": resume_handle,
|
"resume_handle": resume_handle,
|
||||||
}, correlation_id)
|
}, correlation_id)
|
||||||
|
|
||||||
def _resume_public_registration(self, environ, start_response, correlation_id):
|
def _resume_public_registration(
|
||||||
|
self, environ, start_response, correlation_id, *,
|
||||||
|
body: Mapping[str, Any] | None = None, browser: bool = False,
|
||||||
|
):
|
||||||
if not self.public_registration:
|
if not self.public_registration:
|
||||||
raise NotFoundError("public registration is unavailable")
|
raise NotFoundError("public registration is unavailable")
|
||||||
body = self._body(environ)
|
body = body if body is not None else self._body(environ)
|
||||||
session = self.service.store.registration_session(str(body.get("registration_id") or ""))
|
session = self.service.store.registration_session(str(body.get("registration_id") or ""))
|
||||||
handle = str(body.get("resume_handle") or "")
|
handle = str(body.get("resume_handle") or "")
|
||||||
digest = hashlib.sha256(handle.encode()).hexdigest()
|
digest = hashlib.sha256(handle.encode()).hexdigest()
|
||||||
|
|
@ -970,11 +1034,13 @@ class PortalApplication:
|
||||||
return self._provision_public_registration(
|
return self._provision_public_registration(
|
||||||
start_response, actor, user, session, email_factor.normalized_value,
|
start_response, actor, user, session, email_factor.normalized_value,
|
||||||
user.display_name, str(session.applicant_username), correlation_id,
|
user.display_name, str(session.applicant_username), correlation_id,
|
||||||
|
browser=browser,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _provision_public_registration(
|
def _provision_public_registration(
|
||||||
self, start_response, actor, user, session, email, display_name,
|
self, start_response, actor, user, session, email, display_name,
|
||||||
preferred_username, correlation_id,
|
preferred_username, correlation_id,
|
||||||
|
*, browser: bool = False,
|
||||||
):
|
):
|
||||||
provisioned = self.provisioning.provision(
|
provisioned = self.provisioning.provision(
|
||||||
ProvisioningRequest(
|
ProvisioningRequest(
|
||||||
|
|
@ -1001,6 +1067,17 @@ class PortalApplication:
|
||||||
)
|
)
|
||||||
if provisioned.password_setup_url:
|
if provisioned.password_setup_url:
|
||||||
self._validate_registration_handoff(provisioned.password_setup_url)
|
self._validate_registration_handoff(provisioned.password_setup_url)
|
||||||
|
if browser:
|
||||||
|
return self._html(
|
||||||
|
start_response,
|
||||||
|
self._page_html(
|
||||||
|
"Create your password",
|
||||||
|
"<h1>Your identity is ready.</h1>"
|
||||||
|
"<p>Continue to the protected identity service to create your password.</p>"
|
||||||
|
f'<p><a class="button" rel="noreferrer" href="{escape(provisioned.password_setup_url)}">Create password</a></p>',
|
||||||
|
),
|
||||||
|
correlation_id,
|
||||||
|
)
|
||||||
return self._redirect(
|
return self._redirect(
|
||||||
start_response, provisioned.password_setup_url, correlation_id
|
start_response, provisioned.password_setup_url, correlation_id
|
||||||
)
|
)
|
||||||
|
|
@ -1075,6 +1152,22 @@ class PortalApplication:
|
||||||
if not supplied or not secrets.compare_digest(supplied, expected):
|
if not supplied or not secrets.compare_digest(supplied, expected):
|
||||||
raise AuthorizationDenied("invalid CSRF token")
|
raise AuthorizationDenied("invalid CSRF token")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _registration_csrf_cookie(token: str) -> str:
|
||||||
|
return (
|
||||||
|
f"ue_registration_csrf={token}; Path=/; HttpOnly; Secure; "
|
||||||
|
"SameSite=Strict; Max-Age=1800"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_registration_csrf(
|
||||||
|
self, environ: Mapping[str, Any], supplied: str
|
||||||
|
) -> None:
|
||||||
|
expected = cookie_value(
|
||||||
|
str(environ.get("HTTP_COOKIE", "")), "ue_registration_csrf"
|
||||||
|
)
|
||||||
|
if not supplied or not expected or not secrets.compare_digest(supplied, expected):
|
||||||
|
raise AuthorizationDenied("invalid registration CSRF token")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
|
def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
|
||||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||||
|
|
@ -1101,7 +1194,14 @@ class PortalApplication:
|
||||||
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
|
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
|
||||||
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
|
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
|
||||||
if actor is not None
|
if actor is not None
|
||||||
else f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
|
else (
|
||||||
|
f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
|
||||||
|
+ (
|
||||||
|
'<p>New here? <a href="/register">Create an account</a>.</p>'
|
||||||
|
if self.public_registration and self.registration_verification is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return self._page_html(
|
return self._page_html(
|
||||||
"Identity & access",
|
"Identity & access",
|
||||||
|
|
@ -1110,6 +1210,54 @@ class PortalApplication:
|
||||||
+ identity,
|
+ identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _registration_form(self, csrf_token: str) -> str:
|
||||||
|
client_options = "".join(
|
||||||
|
f'<option value="{escape(item)}">{escape(item)}</option>'
|
||||||
|
for item in sorted(self.registration_clients)
|
||||||
|
)
|
||||||
|
tenant_options = "".join(
|
||||||
|
f'<option value="{escape(item)}">{escape(item.removeprefix("tenant:"))}</option>'
|
||||||
|
for item in sorted(self.registration_tenants)
|
||||||
|
)
|
||||||
|
return self._page_html(
|
||||||
|
"Create account",
|
||||||
|
f"""<h1>Create your account.</h1>
|
||||||
|
<p>We will verify your email before creating an identity.</p>
|
||||||
|
<form method="post" action="/register">
|
||||||
|
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||||
|
<label>Username <input name="username" required minlength="3" maxlength="32" pattern="[A-Za-z][A-Za-z0-9._-]+" autocomplete="username"></label>
|
||||||
|
<label>Email <input name="email" type="email" required autocomplete="email"></label>
|
||||||
|
<label>Display name <input name="display_name" maxlength="200" autocomplete="name"></label>
|
||||||
|
<label>Application <select name="client_id" required>{client_options}</select></label>
|
||||||
|
<label>Community <select name="tenant" required>{tenant_options}</select></label>
|
||||||
|
<button type="submit">Send verification email</button>
|
||||||
|
</form><p><a href="/login">Already have an account? Sign in</a></p>""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _registration_verification_form(self, csrf_token: str, handle: str) -> str:
|
||||||
|
return self._page_html(
|
||||||
|
"Verify email",
|
||||||
|
f"""<h1>Verify your email.</h1>
|
||||||
|
<p>Confirm to finish creating your identity. This verification link can be used once.</p>
|
||||||
|
<form method="post" action="/registration/verify">
|
||||||
|
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||||
|
<input type="hidden" name="handle" value="{escape(handle)}">
|
||||||
|
<button type="submit">Verify and continue</button></form>""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _registration_resume_form(
|
||||||
|
self, csrf_token: str, registration_id: str, resume_handle: str
|
||||||
|
) -> str:
|
||||||
|
return self._page_html(
|
||||||
|
"Finish account setup",
|
||||||
|
f"""<h1>Your email is verified.</h1>
|
||||||
|
<p>The identity service is temporarily unavailable. Try again without repeating verification.</p>
|
||||||
|
<form method="post" action="/registration/resume">
|
||||||
|
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||||
|
<input type="hidden" name="registration_id" value="{escape(registration_id)}">
|
||||||
|
<input type="hidden" name="resume_handle" value="{escape(resume_handle)}">
|
||||||
|
<button type="submit">Try identity setup again</button></form>""",
|
||||||
|
)
|
||||||
def _admin(
|
def _admin(
|
||||||
self, tenant: str, memberships: tuple[Any, ...],
|
self, tenant: str, memberships: tuple[Any, ...],
|
||||||
invitations: tuple[Any, ...], diagnostics: Any,
|
invitations: tuple[Any, ...], diagnostics: Any,
|
||||||
|
|
@ -1328,9 +1476,17 @@ input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accen
|
||||||
a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
|
a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
|
||||||
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
|
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
|
||||||
|
|
||||||
def _html(self, start_response: StartResponse, body: str, correlation_id: str) -> list[bytes]:
|
def _html(
|
||||||
|
self, start_response: StartResponse, body: str, correlation_id: str,
|
||||||
|
*, extra_headers: list[tuple[str, str]] | None = None,
|
||||||
|
) -> list[bytes]:
|
||||||
data = body.encode()
|
data = body.encode()
|
||||||
start_response("200 OK", [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)])
|
start_response("200 OK", [
|
||||||
|
("Content-Type", "text/html; charset=utf-8"),
|
||||||
|
("Content-Length", str(len(data))),
|
||||||
|
*(extra_headers or []),
|
||||||
|
*self._security_headers(correlation_id),
|
||||||
|
])
|
||||||
return [data]
|
return [data]
|
||||||
|
|
||||||
def _json(self, start_response: StartResponse, status: str, payload: Any, correlation_id: str) -> list[bytes]:
|
def _json(self, start_response: StartResponse, status: str, payload: Any, correlation_id: str) -> list[bytes]:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
@ -29,7 +30,7 @@ SECRET = "test-proxy-secret-with-adequate-length"
|
||||||
|
|
||||||
def invoke(
|
def invoke(
|
||||||
app, path, *, method="GET", claims=None, marker=SECRET, body=None,
|
app, path, *, method="GET", claims=None, marker=SECRET, body=None,
|
||||||
form=None, cookie=None, headers=None,
|
form=None, cookie=None, headers=None, query="",
|
||||||
):
|
):
|
||||||
payload = (
|
payload = (
|
||||||
urlencode(form).encode()
|
urlencode(form).encode()
|
||||||
|
|
@ -39,7 +40,7 @@ def invoke(
|
||||||
environ = {
|
environ = {
|
||||||
"REQUEST_METHOD": method,
|
"REQUEST_METHOD": method,
|
||||||
"PATH_INFO": path,
|
"PATH_INFO": path,
|
||||||
"QUERY_STRING": "",
|
"QUERY_STRING": query,
|
||||||
"CONTENT_LENGTH": str(len(payload)),
|
"CONTENT_LENGTH": str(len(payload)),
|
||||||
"wsgi.input": io.BytesIO(payload),
|
"wsgi.input": io.BytesIO(payload),
|
||||||
"HTTP_X_REQUEST_ID": "corr_test",
|
"HTTP_X_REQUEST_ID": "corr_test",
|
||||||
|
|
@ -239,6 +240,49 @@ class PortalApplicationTests(unittest.TestCase):
|
||||||
provision.idempotency_key,
|
provision.idempotency_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_public_registration_browser_journey_uses_csrf_and_confirmation(self):
|
||||||
|
verifier = FakeRegistrationVerification()
|
||||||
|
self.app.registration_verification = verifier
|
||||||
|
self.app.provisioning = FakeProvisioning()
|
||||||
|
self.app.registration_clients = frozenset({"coulomb-social"})
|
||||||
|
self.app.registration_tenants = frozenset({"tenant:coulomb"})
|
||||||
|
self.app.registration_oidc_issuer = "https://kc.example"
|
||||||
|
self.app.registration_password_setup_origins = frozenset({"https://kc.example"})
|
||||||
|
|
||||||
|
page, html = invoke(self.app, "/register")
|
||||||
|
self.assertEqual("200 OK", page["status"])
|
||||||
|
self.assertIn(b"Create your account", html)
|
||||||
|
cookie = page["headers"]["Set-Cookie"]
|
||||||
|
token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode()
|
||||||
|
denied, _ = invoke(self.app, "/register", method="POST", form={
|
||||||
|
"csrf_token": "wrong", "username": "new.person",
|
||||||
|
"email": "new@example.test", "client_id": "coulomb-social",
|
||||||
|
"tenant": "tenant:coulomb",
|
||||||
|
}, cookie=cookie)
|
||||||
|
self.assertEqual("403 Forbidden", denied["status"])
|
||||||
|
started, html = invoke(self.app, "/register", method="POST", form={
|
||||||
|
"csrf_token": token, "username": "new.person",
|
||||||
|
"email": "new@example.test", "display_name": "New Person",
|
||||||
|
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
|
||||||
|
}, cookie=cookie)
|
||||||
|
self.assertEqual("200 OK", started["status"])
|
||||||
|
self.assertIn(b"Check your email", html)
|
||||||
|
|
||||||
|
verifier.registration_id = verifier.requested.registration_id
|
||||||
|
confirmation, html = invoke(
|
||||||
|
self.app, "/registration/verify", query="handle=" + "x" * 32
|
||||||
|
)
|
||||||
|
self.assertEqual("200 OK", confirmation["status"])
|
||||||
|
self.assertIn(b"Verify and continue", html)
|
||||||
|
cookie = confirmation["headers"]["Set-Cookie"]
|
||||||
|
token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode()
|
||||||
|
completed, html = invoke(
|
||||||
|
self.app, "/registration/verify", method="POST", cookie=cookie,
|
||||||
|
form={"csrf_token": token, "handle": "x" * 32},
|
||||||
|
)
|
||||||
|
self.assertEqual("200 OK", completed["status"])
|
||||||
|
self.assertIn(b"Create password", html)
|
||||||
|
|
||||||
def test_public_registration_rejects_untrusted_password_setup_origin(self):
|
def test_public_registration_rejects_untrusted_password_setup_origin(self):
|
||||||
verifier = FakeRegistrationVerification()
|
verifier = FakeRegistrationVerification()
|
||||||
self.app.registration_verification = verifier
|
self.app.registration_verification = verifier
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-08-09"
|
created: "2026-08-09"
|
||||||
updated: "2026-08-09"
|
updated: "2026-08-10"
|
||||||
depends_on:
|
depends_on:
|
||||||
- USER-WP-0021
|
- USER-WP-0021
|
||||||
- NK-WP-0025
|
- NK-WP-0025
|
||||||
|
|
@ -26,7 +26,7 @@ identity creation to NetKingdom.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0022-T01
|
id: USER-WP-0022-T01
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "43dd49b7-6dbc-4117-a401-6d9f6e59aa26"
|
state_hub_task_id: "43dd49b7-6dbc-4117-a401-6d9f6e59aa26"
|
||||||
```
|
```
|
||||||
|
|
@ -65,11 +65,21 @@ KeyCape issuer, and redirect only to an allow-listed HTTPS provider password
|
||||||
setup origin. Full suite now passes 126 tests with 3 skips. Automated recovery
|
setup origin. Full suite now passes 126 tests with 3 skips. Automated recovery
|
||||||
after local completion/provider failure remains open before production enablement.
|
after local completion/provider failure remains open before production enablement.
|
||||||
|
|
||||||
|
2026-08-10 browser increment: `/register` now provides the accessible public
|
||||||
|
account form and the home page links to it only when registration is configured.
|
||||||
|
Anonymous mutations use a short-lived Secure/HttpOnly/SameSite double-submit
|
||||||
|
CSRF cookie. Verification links land on an explicit one-time confirmation page,
|
||||||
|
successful provisioning presents the allow-listed provider password handoff,
|
||||||
|
and provider outages render a retry form backed by the separate resume handle.
|
||||||
|
Responses remain non-enumerating. The full suite passes 128 tests with three
|
||||||
|
environment-dependent skips. Cancellation, ingress rate limiting, and the
|
||||||
|
credential-gated production enablement remain.
|
||||||
|
|
||||||
## T02 - Orchestrate provider identity creation
|
## T02 - Orchestrate provider identity creation
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0022-T02
|
id: USER-WP-0022-T02
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "74239ce4-5c1e-46cf-8abf-3cbec3f3f989"
|
state_hub_task_id: "74239ce4-5c1e-46cf-8abf-3cbec3f3f989"
|
||||||
```
|
```
|
||||||
|
|
@ -97,6 +107,13 @@ digest, retries with the same registration idempotency key and local user,
|
||||||
and invalidates the handle after provider linking. Replay is denied. The full
|
and invalidates the handle after provider linking. Replay is denied. The full
|
||||||
suite passes 127 tests with 3 environment-dependent skips.
|
suite passes 127 tests with 3 environment-dependent skips.
|
||||||
|
|
||||||
|
2026-08-10 completion: identity-provisioner now validates and honors the
|
||||||
|
canonical preferred username instead of silently deriving it from the mailbox.
|
||||||
|
The corrected image is live at immutable digest
|
||||||
|
`sha256:4a6ec93d74eed6e17065a7e5d4c3d8d0ed14078bdfda0112738438528c6b2680`.
|
||||||
|
Together with the deterministic retry path, this completes the provider
|
||||||
|
orchestration boundary without exposing provider credentials or passwords.
|
||||||
|
|
||||||
## T03 - Create application profiles on first login
|
## T03 - Create application profiles on first login
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -143,7 +160,7 @@ step-up. user-engine must not become the token assurance authority.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0022-T05
|
id: USER-WP-0022-T05
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6"
|
state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue