diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py
index 1cf1091..89a673b 100644
--- a/src/user_engine/oidc.py
+++ b/src/user_engine/oidc.py
@@ -23,6 +23,7 @@ class PendingLogin:
class BrowserSession:
claims: Mapping[str, Any]
expires_at: float
+ csrf_token: str = ""
class OIDCClient:
@@ -88,7 +89,11 @@ class OIDCClient:
claims = self._verify(token)
session_id = secrets.token_urlsafe(32)
expiry = min(float(claims.get("exp", time.time() + self.session_ttl)), time.time() + self.session_ttl)
- self.sessions[session_id] = BrowserSession(claims=claims, expires_at=expiry)
+ self.sessions[session_id] = BrowserSession(
+ claims=claims,
+ expires_at=expiry,
+ csrf_token=secrets.token_urlsafe(32),
+ )
self._prune()
return session_id
@@ -102,6 +107,13 @@ class OIDCClient:
def logout(self, session_id: str) -> None:
self.sessions.pop(session_id, None)
+ def csrf_token(self, session_id: str) -> str | None:
+ session = self.sessions.get(session_id)
+ if session is None or session.expires_at <= time.time():
+ self.sessions.pop(session_id, None)
+ return None
+ return session.csrf_token
+
def _verify(self, token: str) -> Mapping[str, Any]:
if not token:
raise ValueError("OIDC token response is missing a token")
diff --git a/src/user_engine/web.py b/src/user_engine/web.py
index 964a9d6..a5101f6 100644
--- a/src/user_engine/web.py
+++ b/src/user_engine/web.py
@@ -15,7 +15,7 @@ from html import escape
import json
import secrets
from typing import Any, Callable, Iterable, Mapping
-from urllib.parse import parse_qs
+from urllib.parse import parse_qs, urlencode
from user_engine.domain import AccountStatus
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
@@ -68,6 +68,14 @@ class PortalApplication:
return self._dispatch(environ, start_response, str(correlation_id))
except (ValidationError, ConflictError, ValueError) as exc:
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
+ except RuntimeError:
+ return self._error(
+ start_response,
+ "502 Bad Gateway",
+ "provisioning_unavailable",
+ "Identity provisioning is temporarily unavailable.",
+ correlation_id,
+ )
except AuthorizationDenied:
return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id)
except NotFoundError:
@@ -211,23 +219,143 @@ class PortalApplication:
)
),
))
- return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
+ identity = self.service.link_identity(
+ actor,
+ user.user_id,
+ issuer="urn:netkingdom:directory",
+ subject=result.external_subject,
+ provider=result.provider,
+ correlation_id=correlation_id,
+ )
+ return self._json(start_response, "200 OK", {
+ "provisioning": _jsonable(result),
+ "identity": _jsonable(identity),
+ }, correlation_id)
if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "PATCH":
parts = path.split("/")
tenant, user_id = parts[4], parts[6]
body = self._body(environ)
status = AccountStatus(str(body["status"]))
- result = self.service.set_tenant_account_status(
- actor, user_id, status, tenant=tenant, correlation_id=correlation_id
+ idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
+ if len(idempotency_key) < 16:
+ raise ValidationError("Idempotency-Key must contain at least 16 characters")
+ result = self._change_status(
+ actor, tenant, user_id, status,
+ idempotency_key=idempotency_key,
+ correlation_id=correlation_id,
)
return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
if path.startswith("/admin/") and method == "GET":
tenant = path.split("/")[2]
self.service.resolve_tenant_context(actor, tenant)
memberships = self.service.store.memberships_for_tenant(tenant)
- return self._html(start_response, self._admin(tenant, memberships), correlation_id)
+ return self._html(
+ start_response,
+ self._admin(tenant, memberships, self._csrf_token(environ)),
+ correlation_id,
+ )
+ if path.startswith("/admin/") and method == "POST":
+ parts = path.split("/")
+ tenant = parts[2]
+ self.service.resolve_tenant_context(actor, tenant)
+ body = self._form_body(environ)
+ self._require_csrf(environ, str(body.get("csrf_token", "")))
+ if len(parts) == 4 and parts[3] == "users":
+ user = self.service.create_user(
+ actor,
+ display_name=body.get("display_name"),
+ primary_email=body.get("primary_email"),
+ correlation_id=correlation_id,
+ )
+ self.service.set_tenant_account_status(
+ actor, user.user_id, AccountStatus.ACTIVE,
+ tenant=tenant, correlation_id=correlation_id,
+ )
+ self.service.add_membership(
+ actor, user.user_id, tenant=tenant, scope_type="tenant",
+ scope_id=tenant, kind=str(body.get("role", "user")),
+ correlation_id=correlation_id,
+ )
+ return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
+ if len(parts) == 6 and parts[3] == "users" and parts[5] == "provision":
+ if self.provisioning is None:
+ raise ValidationError("identity provisioning is unavailable")
+ user_id = parts[4]
+ user = self.service.store.user(user_id)
+ if user is None:
+ raise NotFoundError("user not found")
+ result = self.provisioning.provision(ProvisioningRequest(
+ user_id=user.user_id,
+ tenant=tenant,
+ primary_email=user.primary_email,
+ display_name=user.display_name,
+ idempotency_key=f"portal-{user.user_id}-{tenant}",
+ correlation_id=correlation_id,
+ roles=tuple(
+ item.kind for item in self.service.store.memberships_for_user(
+ user.user_id, tenant=tenant
+ )
+ ),
+ ))
+ self.service.link_identity(
+ actor, user.user_id, issuer="urn:netkingdom:directory",
+ subject=result.external_subject, provider=result.provider,
+ correlation_id=correlation_id,
+ )
+ query = urlencode({"provisioned": user.user_id, "status": result.status})
+ return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id)
+ if len(parts) == 6 and parts[3] == "users" and parts[5] == "status":
+ status = AccountStatus(str(body.get("status", "")))
+ if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}:
+ raise ValidationError("browser lifecycle supports active or suspended")
+ self._change_status(
+ actor, tenant, parts[4], status,
+ idempotency_key=f"portal-status-{parts[4]}-{status.value}",
+ correlation_id=correlation_id,
+ )
+ return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
+ def _change_status(
+ self,
+ actor: Any,
+ tenant: str,
+ user_id: str,
+ status: AccountStatus,
+ *,
+ idempotency_key: str,
+ correlation_id: str,
+ ) -> Any:
+ if self.provisioning is None:
+ raise ValidationError("identity provisioning is unavailable")
+ self.service.resolve_tenant_context(actor, tenant)
+ identity = next(
+ (
+ item for item in self.service.store.identities_for_user(user_id)
+ if item.provider == "netkingdom-lldap"
+ ),
+ None,
+ )
+ if identity is None:
+ raise ValidationError("user has no managed login identity")
+ if status == AccountStatus.SUSPENDED:
+ self.provisioning.suspend(
+ external_subject=identity.subject,
+ idempotency_key=idempotency_key,
+ correlation_id=correlation_id,
+ )
+ elif status == AccountStatus.ACTIVE:
+ self.provisioning.reactivate(
+ external_subject=identity.subject,
+ idempotency_key=idempotency_key,
+ correlation_id=correlation_id,
+ )
+ else:
+ raise ValidationError("provider lifecycle supports active or suspended")
+ return self.service.set_tenant_account_status(
+ actor, user_id, status, tenant=tenant, correlation_id=correlation_id
+ )
+
def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]:
if self.oidc_client is not None:
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
@@ -264,6 +392,29 @@ class PortalApplication:
raise ValidationError("request body must be an object")
return value
+ @staticmethod
+ def _form_body(environ: Mapping[str, Any]) -> Mapping[str, str]:
+ content_type = str(environ.get("CONTENT_TYPE", "")).partition(";")[0]
+ if content_type != "application/x-www-form-urlencoded":
+ raise ValidationError("form content type is required")
+ length = min(int(environ.get("CONTENT_LENGTH") or 0), 65536)
+ payload = environ["wsgi.input"].read(length).decode("utf-8")
+ return {key: values[0] for key, values in parse_qs(payload).items()}
+
+ def _csrf_token(self, environ: Mapping[str, Any]) -> str:
+ if self.oidc_client is None:
+ raise AuthorizationDenied("browser session required")
+ session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
+ token = self.oidc_client.csrf_token(session_id or "")
+ if token is None:
+ raise AuthorizationDenied("browser session required")
+ return token
+
+ def _require_csrf(self, environ: Mapping[str, Any], supplied: str) -> None:
+ expected = self._csrf_token(environ)
+ if not supplied or not secrets.compare_digest(supplied, expected):
+ raise AuthorizationDenied("invalid CSRF token")
+
@staticmethod
def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
query = parse_qs(str(environ.get("QUERY_STRING", "")))
@@ -284,16 +435,56 @@ class PortalApplication:
+ identity,
)
- def _admin(self, tenant: str, memberships: tuple[Any, ...]) -> str:
+ def _admin(self, tenant: str, memberships: tuple[Any, ...], csrf_token: str) -> str:
rows = "".join(
- f"
| {escape(item.user_id)} | {escape(item.kind)} | {escape(item.scope_id)} |
"
+ self._admin_row(tenant, item, csrf_token)
for item in memberships
- ) or '| No members yet. |
'
+ ) or '| No members yet. |
'
return self._page_html(
f"{tenant} users",
- f"{escape(tenant)} users
",
+ f"""{escape(tenant)} users
+
+Members
| User | Email | Role | Status | Directory | Action |
{rows}
""",
)
+ def _admin_row(self, tenant: str, membership: Any, csrf_token: str) -> str:
+ user = self.service.store.user(membership.user_id)
+ identities = self.service.store.identities_for_user(membership.user_id)
+ directory = next(
+ (item for item in identities if item.provider == "netkingdom-lldap"),
+ None,
+ )
+ tenant_account = self.service.store.tenant_account(tenant, membership.user_id)
+ status = tenant_account.status if tenant_account else AccountStatus.INVITED
+ action = (
+ f"""Linked as {escape(directory.subject)}
+"""
+ if directory
+ else f""""""
+ )
+ return (
+ f"| {escape(user.display_name or membership.user_id) if user else escape(membership.user_id)} | "
+ f"{escape(user.primary_email or '') if user else ''} | "
+ f"{escape(membership.kind)} | "
+ f"{escape(status.value)} | "
+ f"{'linked' if directory else 'pending'} | {action} |
"
+ )
+
+ def _redirect(self, start_response: StartResponse, location: str, correlation_id: str) -> list[bytes]:
+ start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
+ return [b""]
+
@staticmethod
def _page_html(title: str, body: str) -> str:
return f"""
@@ -305,7 +496,9 @@ header,main{{max-width:68rem;margin:auto;padding:1.25rem}}header{{border-bottom:
h1{{font:clamp(2.2rem,7vw,5.5rem)/.98 Georgia,serif;max-width:13ch}}a{{color:var(--accent)}}
.button{{display:inline-block;background:var(--accent);color:white;padding:.8rem 1.15rem;border-radius:.3rem;text-decoration:none}}
table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.75rem;text-align:left;border-bottom:1px solid var(--line)}}
-a:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}}}
+section{{margin:2rem 0}}form{{display:grid;gap:.8rem;max-width:42rem}}label{{display:grid;gap:.25rem}}
+input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accent);color:white;border:0;border-radius:.3rem;cursor:pointer}}
+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}}}}
{body}"""
def _html(self, start_response: StartResponse, body: str, correlation_id: str) -> list[bytes]:
diff --git a/tests/test_oidc.py b/tests/test_oidc.py
index 1c2efe2..54d7e80 100644
--- a/tests/test_oidc.py
+++ b/tests/test_oidc.py
@@ -26,6 +26,7 @@ class OIDCClientTests(unittest.TestCase):
claims={"sub": "person"}, expires_at=9999999999
)
self.assertEqual("person", self.client.claims("opaque")["sub"])
+ self.assertEqual("", self.client.csrf_token("opaque"))
self.assertEqual("opaque", cookie_value("x=1; ue_session=opaque", "ue_session"))
self.client.logout("opaque")
self.assertIsNone(self.client.claims("opaque"))
diff --git a/tests/test_web.py b/tests/test_web.py
index ce594f2..5756d5f 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -1,8 +1,11 @@
import io
import json
import unittest
+from urllib.parse import urlencode
from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort
+from user_engine.oidc import BrowserSession, OIDCClient
+from user_engine.ports import ProvisioningResult
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.web import PortalApplication
@@ -11,8 +14,15 @@ from user_engine.web import PortalApplication
SECRET = "test-proxy-secret-with-adequate-length"
-def invoke(app, path, *, method="GET", claims=None, marker=SECRET, body=None):
- payload = json.dumps(body or {}).encode()
+def invoke(
+ app, path, *, method="GET", claims=None, marker=SECRET, body=None,
+ form=None, cookie=None,
+):
+ payload = (
+ urlencode(form).encode()
+ if form is not None
+ else json.dumps(body or {}).encode()
+ )
environ = {
"REQUEST_METHOD": method,
"PATH_INFO": path,
@@ -21,6 +31,10 @@ def invoke(app, path, *, method="GET", claims=None, marker=SECRET, body=None):
"wsgi.input": io.BytesIO(payload),
"HTTP_X_REQUEST_ID": "corr_test",
}
+ if form is not None:
+ environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded"
+ if cookie is not None:
+ environ["HTTP_COOKIE"] = cookie
if claims is not None:
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker
@@ -84,6 +98,140 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("corr_test", result["headers"]["X-Request-ID"])
self.assertEqual("factor_pending", json.loads(payload)["status"])
+ def test_provision_api_links_provider_subject(self):
+ self.app.provisioning = FakeProvisioning()
+ created, payload = invoke(
+ self.app,
+ "/api/v1/tenants/tenant:friendly:binky/users",
+ method="POST",
+ claims=self.claims,
+ body={
+ "display_name": "Ada Admin",
+ "primary_email": "ada@example.test",
+ "role": "tenant-admin",
+ },
+ )
+ self.assertEqual("201 Created", created["status"])
+ user_id = json.loads(payload)["user"]["user_id"]
+ provisioned, payload = invoke(
+ self.app,
+ f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}/provision",
+ method="POST",
+ claims=self.claims,
+ )
+ # The helper does not set an idempotency header.
+ self.assertEqual("400 Bad Request", provisioned["status"])
+ result, payload = invoke_with_idempotency(
+ self.app,
+ f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}/provision",
+ self.claims,
+ )
+ self.assertEqual("200 OK", result["status"])
+ self.assertEqual("ada", json.loads(payload)["identity"]["subject"])
+ changed, payload = invoke_with_idempotency(
+ self.app,
+ f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}",
+ self.claims,
+ method="PATCH",
+ body={"status": "suspended"},
+ )
+ self.assertEqual("200 OK", changed["status"])
+ self.assertEqual("suspended", json.loads(payload)["status"])
+ self.assertIn(("suspend", "ada"), self.app.provisioning.actions)
+
+ def test_admin_form_requires_csrf_and_supports_two_step_provisioning(self):
+ oidc = OIDCClient(
+ issuer="https://kc.example",
+ client_id="portal",
+ redirect_uri="https://users.example/oidc/callback",
+ audience="portal",
+ )
+ oidc.sessions["browser"] = BrowserSession(
+ claims=self.claims,
+ expires_at=9999999999,
+ csrf_token="csrf-test-token",
+ )
+ self.app.oidc_client = oidc
+ self.app.provisioning = FakeProvisioning()
+ denied, _ = invoke(
+ self.app,
+ "/admin/tenant:friendly:binky/users",
+ method="POST",
+ cookie="ue_session=browser",
+ form={
+ "csrf_token": "wrong",
+ "display_name": "Ada Admin",
+ "primary_email": "ada@example.test",
+ "role": "tenant-admin",
+ },
+ )
+ self.assertEqual("403 Forbidden", denied["status"])
+ created, _ = invoke(
+ self.app,
+ "/admin/tenant:friendly:binky/users",
+ method="POST",
+ cookie="ue_session=browser",
+ form={
+ "csrf_token": "csrf-test-token",
+ "display_name": "Ada Admin",
+ "primary_email": "ada@example.test",
+ "role": "tenant-admin",
+ },
+ )
+ self.assertEqual("303 See Other", created["status"])
+ page, html = invoke(
+ self.app,
+ "/admin/tenant:friendly:binky",
+ cookie="ue_session=browser",
+ )
+ self.assertEqual("200 OK", page["status"])
+ self.assertIn(b"ada@example.test", html)
+ self.assertIn(b"Create login", html)
+
+
+class FakeProvisioning:
+ def __init__(self):
+ self.actions = []
+
+ def provision(self, request):
+ self.actions.append(("provision", request.primary_email))
+ return ProvisioningResult(
+ provider="netkingdom-lldap",
+ external_subject=request.primary_email.split("@")[0],
+ status="password_setup_required",
+ )
+
+ def suspend(self, *, external_subject, idempotency_key, correlation_id):
+ self.actions.append(("suspend", external_subject))
+ return ProvisioningResult("netkingdom-lldap", external_subject, "suspended")
+
+ def reactivate(self, *, external_subject, idempotency_key, correlation_id):
+ self.actions.append(("reactivate", external_subject))
+ return ProvisioningResult("netkingdom-lldap", external_subject, "active")
+
+
+def invoke_with_idempotency(app, path, claims, *, method="POST", body=None):
+ payload = json.dumps(body or {}).encode()
+ environ = {
+ "REQUEST_METHOD": method,
+ "PATH_INFO": path,
+ "QUERY_STRING": "",
+ "CONTENT_LENGTH": str(len(payload)),
+ "wsgi.input": io.BytesIO(payload),
+ "HTTP_X_REQUEST_ID": "corr_test",
+ "HTTP_X_VERIFIED_OIDC_CLAIMS": json.dumps(claims),
+ "HTTP_X_USER_ENGINE_PROXY_SECRET": SECRET,
+ "HTTP_IDEMPOTENCY_KEY": "test-idempotency-123456",
+ }
+ captured = {}
+ response = b"".join(app(
+ environ,
+ lambda status, headers: captured.update(
+ {"status": status, "headers": dict(headers)}
+ ),
+ ))
+ return captured, response
+
if __name__ == "__main__":
unittest.main()