diff --git a/Containerfile b/Containerfile index b526626..b55979b 100644 --- a/Containerfile +++ b/Containerfile @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 11edb67..a835d4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py new file mode 100644 index 0000000..1bb76e5 --- /dev/null +++ b/src/user_engine/oidc.py @@ -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("=") diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index acce4bd..3fc8e8f 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -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"), + ), ) diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 77f8749..52223e9 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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") diff --git a/tests/test_oidc.py b/tests/test_oidc.py new file mode 100644 index 0000000..1c2efe2 --- /dev/null +++ b/tests/test_oidc.py @@ -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"))