Add KeyCape PKCE browser sessions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-07-28 00:06:21 +02:00
parent 654880ec91
commit 67ad2af640
6 changed files with 224 additions and 4 deletions

View file

@ -19,6 +19,7 @@ from urllib.parse import parse_qs
from user_engine.domain import AccountStatus
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
from user_engine.oidc import OIDCClient, cookie_value
from user_engine.service import UserEngineService
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
@ -48,6 +49,7 @@ class PortalApplication:
trusted_proxy_secret: str,
login_url: str,
public_registration: bool = True,
oidc_client: OIDCClient | None = None,
) -> None:
if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters")
@ -55,12 +57,13 @@ class PortalApplication:
self.trusted_proxy_secret = trusted_proxy_secret
self.login_url = login_url
self.public_registration = public_registration
self.oidc_client = oidc_client
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
try:
return self._dispatch(environ, start_response, str(correlation_id))
except (ValidationError, ConflictError) as exc:
except (ValidationError, ConflictError, ValueError) as exc:
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
except AuthorizationDenied:
return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id)
@ -77,8 +80,35 @@ class PortalApplication:
if path == "/readyz":
report = self.service.readiness()
return self._json(start_response, "200 OK" if report.ready else "503 Service Unavailable", _jsonable(report), correlation_id)
if path == "/login":
start_response("303 See Other", [("Location", self.login_url), *self._security_headers(correlation_id)])
if path in {"/login", "/oidc/start"}:
location = self.oidc_client.begin() if self.oidc_client else self.login_url
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
return [b""]
if path == "/oidc/callback":
if self.oidc_client is None:
raise NotFoundError("OIDC login is not configured")
query = parse_qs(str(environ.get("QUERY_STRING", "")))
if query.get("error"):
raise AuthorizationDenied("OIDC login failed")
session_id = self.oidc_client.complete(
code=query.get("code", [""])[0],
state=query.get("state", [""])[0],
)
headers = [
("Location", "/"),
("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"),
*self._security_headers(correlation_id),
]
start_response("303 See Other", headers)
return [b""]
if path == "/logout" and method == "POST":
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
if session_id and self.oidc_client:
self.oidc_client.logout(session_id)
start_response(
"303 See Other",
[("Location", "/"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
)
return [b""]
if path == "/" and method == "GET":
actor = self._optional_actor(environ)
@ -133,6 +163,12 @@ class PortalApplication:
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", 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")
if session_id:
claims = self.oidc_client.claims(session_id)
if claims is not None:
return claims
marker = str(environ.get("HTTP_X_USER_ENGINE_PROXY_SECRET", ""))
if not secrets.compare_digest(marker, self.trusted_proxy_secret):
raise AuthorizationDenied("untrusted identity source")