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
|
WORKDIR /app
|
||||||
COPY pyproject.toml README.md /app/
|
COPY pyproject.toml README.md /app/
|
||||||
COPY src /app/src
|
COPY src /app/src
|
||||||
RUN pip install --no-cache-dir ".[postgres]"
|
RUN pip install --no-cache-dir ".[runtime]"
|
||||||
|
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ requires-python = ">=3.12"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
postgres = ["psycopg[binary]>=3.2,<4"]
|
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]
|
[project.scripts]
|
||||||
user-engine-portal = "user_engine.runtime:main"
|
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,
|
VerifiedIdentityClaimsAdapter,
|
||||||
)
|
)
|
||||||
from user_engine.service import UserEngineService
|
from user_engine.service import UserEngineService
|
||||||
|
from user_engine.oidc import OIDCClient
|
||||||
from user_engine.web import PortalApplication
|
from user_engine.web import PortalApplication
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,6 +44,12 @@ def create_application() -> PortalApplication:
|
||||||
login_url=_required("USER_ENGINE_LOGIN_URL"),
|
login_url=_required("USER_ENGINE_LOGIN_URL"),
|
||||||
public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower()
|
public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower()
|
||||||
== "true",
|
== "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.domain import AccountStatus
|
||||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||||
|
from user_engine.oidc import OIDCClient, cookie_value
|
||||||
from user_engine.service import UserEngineService
|
from user_engine.service import UserEngineService
|
||||||
|
|
||||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||||
|
|
@ -48,6 +49,7 @@ class PortalApplication:
|
||||||
trusted_proxy_secret: str,
|
trusted_proxy_secret: str,
|
||||||
login_url: str,
|
login_url: str,
|
||||||
public_registration: bool = True,
|
public_registration: bool = True,
|
||||||
|
oidc_client: OIDCClient | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(trusted_proxy_secret) < 24:
|
if len(trusted_proxy_secret) < 24:
|
||||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
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.trusted_proxy_secret = trusted_proxy_secret
|
||||||
self.login_url = login_url
|
self.login_url = login_url
|
||||||
self.public_registration = public_registration
|
self.public_registration = public_registration
|
||||||
|
self.oidc_client = oidc_client
|
||||||
|
|
||||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
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)}"
|
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
|
||||||
try:
|
try:
|
||||||
return self._dispatch(environ, start_response, str(correlation_id))
|
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)
|
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
|
||||||
except AuthorizationDenied:
|
except AuthorizationDenied:
|
||||||
return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id)
|
return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id)
|
||||||
|
|
@ -77,8 +80,35 @@ class PortalApplication:
|
||||||
if path == "/readyz":
|
if path == "/readyz":
|
||||||
report = self.service.readiness()
|
report = self.service.readiness()
|
||||||
return self._json(start_response, "200 OK" if report.ready else "503 Service Unavailable", _jsonable(report), correlation_id)
|
return self._json(start_response, "200 OK" if report.ready else "503 Service Unavailable", _jsonable(report), correlation_id)
|
||||||
if path == "/login":
|
if path in {"/login", "/oidc/start"}:
|
||||||
start_response("303 See Other", [("Location", self.login_url), *self._security_headers(correlation_id)])
|
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""]
|
return [b""]
|
||||||
if path == "/" and method == "GET":
|
if path == "/" and method == "GET":
|
||||||
actor = self._optional_actor(environ)
|
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)
|
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]:
|
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", ""))
|
marker = str(environ.get("HTTP_X_USER_ENGINE_PROXY_SECRET", ""))
|
||||||
if not secrets.compare_digest(marker, self.trusted_proxy_secret):
|
if not secrets.compare_digest(marker, self.trusted_proxy_secret):
|
||||||
raise AuthorizationDenied("untrusted identity source")
|
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