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

@ -4,7 +4,7 @@ RUN useradd --create-home --uid 10001 user-engine
WORKDIR /app
COPY pyproject.toml README.md /app/
COPY src /app/src
RUN pip install --no-cache-dir ".[postgres]"
RUN pip install --no-cache-dir ".[runtime]"
USER 10001:10001
EXPOSE 8080

View file

@ -6,6 +6,8 @@ requires-python = ">=3.12"
[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2,<4"]
oidc = ["PyJWT[crypto]>=2.10,<3"]
runtime = ["psycopg[binary]>=3.2,<4", "PyJWT[crypto]>=2.10,<3"]
[project.scripts]
user-engine-portal = "user_engine.runtime:main"

141
src/user_engine/oidc.py Normal file
View file

@ -0,0 +1,141 @@
"""OIDC Authorization Code + PKCE relying-party support."""
from __future__ import annotations
from dataclasses import dataclass
import base64
import hashlib
import json
import secrets
import time
from typing import Any, Mapping
from urllib.parse import urlencode
from urllib.request import Request, urlopen
@dataclass
class PendingLogin:
verifier: str
created_at: float
@dataclass
class BrowserSession:
claims: Mapping[str, Any]
expires_at: float
class OIDCClient:
"""Minimal confidential-state/public-client OIDC adapter."""
def __init__(
self,
*,
issuer: str,
client_id: str,
redirect_uri: str,
audience: str,
session_ttl: int = 3600,
) -> None:
self.issuer = issuer.rstrip("/")
self.client_id = client_id
self.redirect_uri = redirect_uri
self.audience = audience
self.session_ttl = session_ttl
self.pending: dict[str, PendingLogin] = {}
self.sessions: dict[str, BrowserSession] = {}
def begin(self) -> str:
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
challenge = _b64(hashlib.sha256(verifier.encode("ascii")).digest())
self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time())
self._prune()
return f"{self.issuer}/authorize?{urlencode({
'response_type': 'code',
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'scope': 'openid profile email groups',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
})}"
def complete(self, *, code: str, state: str) -> str:
pending = self.pending.pop(state, None)
if pending is None or time.time() - pending.created_at > 600:
raise ValueError("invalid or expired OIDC state")
form = urlencode(
{
"grant_type": "authorization_code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"code": code,
"code_verifier": pending.verifier,
}
).encode()
request = Request(
f"{self.issuer}/token",
data=form,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urlopen(request, timeout=10) as response:
tokens = json.loads(response.read())
token = str(tokens.get("id_token") or tokens.get("access_token") or "")
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._prune()
return session_id
def claims(self, session_id: str) -> Mapping[str, Any] | 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.claims
def logout(self, session_id: str) -> None:
self.sessions.pop(session_id, None)
def _verify(self, token: str) -> Mapping[str, Any]:
if not token:
raise ValueError("OIDC token response is missing a token")
try:
import jwt
except ImportError as exc: # pragma: no cover
raise RuntimeError("install user-engine[oidc] for OIDC login") from exc
jwks = jwt.PyJWKClient(f"{self.issuer}/jwks")
key = jwks.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
key.key,
algorithms=["RS256"],
audience=self.audience,
issuer=self.issuer,
options={"require": ["exp", "iat", "iss", "sub", "aud"]},
)
return dict(claims)
def _prune(self) -> None:
now = time.time()
self.pending = {
key: value for key, value in self.pending.items() if now - value.created_at <= 600
}
self.sessions = {
key: value for key, value in self.sessions.items() if value.expires_at > now
}
def cookie_value(header: str, name: str) -> str | None:
for part in header.split(";"):
key, separator, value = part.strip().partition("=")
if separator and key == name:
return value
return None
def _b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")

View file

@ -11,6 +11,7 @@ from user_engine.adapters import (
VerifiedIdentityClaimsAdapter,
)
from user_engine.service import UserEngineService
from user_engine.oidc import OIDCClient
from user_engine.web import PortalApplication
@ -43,6 +44,12 @@ def create_application() -> PortalApplication:
login_url=_required("USER_ENGINE_LOGIN_URL"),
public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower()
== "true",
oidc_client=OIDCClient(
issuer=_required("USER_ENGINE_OIDC_ISSUER"),
client_id=_required("USER_ENGINE_OIDC_CLIENT_ID"),
redirect_uri=_required("USER_ENGINE_OIDC_REDIRECT_URI"),
audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
),
)

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

34
tests/test_oidc.py Normal file
View file

@ -0,0 +1,34 @@
import unittest
from urllib.parse import parse_qs, urlparse
from user_engine.oidc import BrowserSession, OIDCClient, cookie_value
class OIDCClientTests(unittest.TestCase):
def setUp(self):
self.client = OIDCClient(
issuer="https://kc.example",
client_id="portal",
redirect_uri="https://users.example/oidc/callback",
audience="portal",
)
def test_begin_uses_s256_and_one_time_state(self):
url = urlparse(self.client.begin())
query = parse_qs(url.query)
self.assertEqual(["S256"], query["code_challenge_method"])
self.assertEqual(["portal"], query["client_id"])
self.assertIn(query["state"][0], self.client.pending)
self.assertNotIn(self.client.pending[query["state"][0]].verifier, url.query)
def test_opaque_session_and_cookie_parser(self):
self.client.sessions["opaque"] = BrowserSession(
claims={"sub": "person"}, expires_at=9999999999
)
self.assertEqual("person", self.client.claims("opaque")["sub"])
self.assertEqual("opaque", cookie_value("x=1; ue_session=opaque", "ue_session"))
self.client.logout("opaque")
self.assertIsNone(self.client.claims("opaque"))
def test_unknown_session_is_rejected(self):
self.assertIsNone(self.client.claims("not-there"))