Add KeyCape PKCE browser sessions
This commit is contained in:
parent
654880ec91
commit
67ad2af640
6 changed files with 224 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
141
src/user_engine/oidc.py
Normal 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("=")
|
||||
|
|
@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
34
tests/test_oidc.py
Normal 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"))
|
||||
Loading…
Add table
Add a link
Reference in a new issue