feat: add OIDC browser sessions with live access enforcement
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 12:17:16 +02:00
parent fdea2f5192
commit a15fe032b0
14 changed files with 807 additions and 31 deletions

View file

@ -12,7 +12,7 @@ upgrade:** the default production factory has no admitted owner adapters yet and
returns 401 for missing credentials and 503 for credential-bearing requests.
The current deployed image and its release configuration have not been changed.
Only exact `GET /healthz` is public, returning `{"status":"ok"}`. The shared
Without explicit browser composition, only exact `GET /healthz` is public, returning `{"status":"ok"}`. The shared
ASGI boundary protects docs, readiness, native ports, projections, compatibility
aliases and subsequently attached routes. Unknown method/route/handler combinations,
WebSockets, mounts without admission, and slash redirects without catalog entries
@ -26,6 +26,10 @@ It is packaged in the wheel and tested against actual route construction. Source
inventory generation does **not** auto-admit a new route. Duplicate docs handlers
remain separately inventoried; the boundary selects the first effective route.
These technical action names require flex-auth/owner review before policy delivery.
Optional browser composition adds four exact `/auth/*` protocol routes and the
`hub.browser.session` action; login initiation is public, callback state is
browser-bound, and session access requires current root authority. See the
[browser integration contract](owner-access-integration.md#browser-composition-source-candidate).
## Composition and trust
@ -76,7 +80,7 @@ and Phase 2 storage/query isolation are not implemented by this classification.
The PDP request carries actor, target tenant, action, concrete resource path,
assurance, root entitlement, authoritative evidence reference, and a digest of
HTTP method/path/query/body. Client bodies and bearer tokens are not sent to the
PDP or audit sink. Body size is bounded at 1 MiB. The controller's identity/facts/
PDP or audit sink. Body size is bounded at 1 MiB with a ten-second receive deadline. The controller's identity/facts/
policy/audit chain has a ten-second timeout; individual HTTP calls have three seconds.
A separate refusal-audit attempt is bounded at three seconds.

View file

@ -0,0 +1,43 @@
# HUB-WP-0012 browser source evidence — 2026-09-28
This is local implementation evidence, not live identity, entitlement, policy,
archive-custody or deployment acceptance.
Implemented explicit browser composition with confidential OIDC code exchange,
S256 PKCE, one-time browser-bound state, signed ID-token validation, nonce,
authentication freshness, optional access-token hash binding and live root
access authorization before session creation. Secure host-only cookies contain
opaque IDs; tokens remain in bounded process-local memory. Sessions last no
longer than five minutes or either token, and restart invalidates them.
Cookie writes require exact Origin and CSRF; every protected request still
rechecks identity/facts/policy/audit. Session validity is rechecked after authority
awaits. Local logout remains available during owner outages. Browser refusal
records exclude authorization codes, tokens and query parameters.
Two additional regressions are fixed: explicit controllers cannot silently run
without enforcement, and slow request bodies time out before authority/handlers.
Validation:
- `HUB_CORE_AUDIT_CORE_SOURCE=/home/worsch/audit-core .venv/bin/python -m pytest -q --disable-warnings`:
**328 passed**, one existing Starlette/httpx deprecation warning.
- After the final correlation-ID adjustment, the browser suite passed again:
**22 passed**. It uses actual RSA signatures, mock OIDC discovery/code exchange
and synthetic authority/policy/audit owners. It covers replay/browser binding,
bad nonce/audience/subject/authorized party/type/time/hash, non-root/non-platform/
non-MFA denial, expiry/restart, grant withdrawal, CSRF and mixed credentials,
logout during authority awaits, capacity and owner outage denial. A real native
message handler accepts a valid session write and refuses a spoofed sender.
- Wheel/source distribution build passes; the browser module is packaged.
- Inventory drift/coverage check passes: **165 Hub source surfaces**, 48 platform
rows and 250 dated cluster objects. Four optional browser protocol routes have
their own profile; inventory coverage is not live conformance.
- `git diff --check` passes.
Remaining gates: confidential issuer registration and real MFA behavior, immutable
root and authoritative owner-facts integration, Hub policy admission including
`hub.browser.session`, durable production audit delivery, proxy logging/rate limits,
consumer adoption and complete platform acceptance. The source candidate provides
neither distributed sessions, upstream IdP logout, automatic renewal nor operation
completion auditing. T01–T04 remain in progress. No public listener or production
entitlement changed.

View file

@ -99,3 +99,56 @@ A composed fixture journey verifies a real RSA JWT, fresh facts, an Ed25519
policy decision, receiver custody and native Hub write, followed by entitlement
withdrawal denial. Real root enrollment/login, service deployment, live key and
sender custody, and operational acceptance remain open.
## Browser composition (source candidate)
Pass `browser_settings=BrowserSettings(origin="https://hub.example",
client_id="hub-browser", client_secret_file=Path("/run/secrets/hub-oidc-client"))`
to `create_app`, alongside the enforced controller or authoritative-facts security
composition described above. Import `BrowserSettings` from
`hub_core.security.browser`. Browser configuration cannot activate a development
runtime or a missing controller. Its confidential-client credential is separate
from the policy/audit workload credentials and is reread at each code exchange.
Register the exact HTTPS origin plus `/auth/callback` with the admitted issuer.
The source candidate requires authorization code, S256 PKCE, RS256 ID tokens and
`client_secret_basic` discovery support. It requests fresh authentication and
AAL2; validated access-token assurance and current owner facts decide access.
Issuer registration, actual MFA behavior, audience mapping and live owner
acceptance remain deployment gates. The implementation follows the
[OIDC ID token validation contract](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation)
and [OAuth security best practices](https://www.rfc-editor.org/rfc/rfc9700.html).
- `GET /auth/login` initiates login; arbitrary query parameters are refused.
- `GET /auth/callback` consumes browser-bound state once, exchanges the code with
PKCE, checks signature/issuer/audience/nonce/authentication time/access binding,
and requires root authorization and audit custody before issuing a session.
- `GET /auth/session` rechecks authority and returns expiry and a CSRF token.
- `POST /auth/logout` requires the session, exact `Origin` and `X-Hub-CSRF`, then
destroys the local session even during identity/policy/audit outages.
Sessions contain access tokens only in server memory. Cookies carry random opaque
IDs with `Secure`, `HttpOnly`, `SameSite=Lax`, host-only scope and `/` path. Sessions
expire within five minutes or either token's expiry, whichever comes first;
there is no refresh-token renewal. Process restart/shutdown invalidates sessions.
Multiple workers need sticky routing or a separately reviewed shared session
store; this candidate does not provide distributed sessions. Pending logins and
sessions have bounded capacity. Production ingress must also rate-limit login.
All protected API requests recheck identity, current facts, signed policy and
durable audit. Cookie-authenticated writes additionally require exact `Origin`
and `X-Hub-CSRF`; browser clients obtain the latter from `/auth/session`. The
bundled Swagger UI does not automatically inject that header. Mixed cookies and
bearer credentials are rejected. Session expiry/logout is checked again after
authority awaits and before dispatch. Logout cannot cancel already executing
requests or log out the upstream identity provider; subsequent login requests
fresh authentication. Logout is local invalidation, not a durable audited
business mutation. Refused browser flows are recorded without codes/tokens/query
parameters; audit failure returns 503. Login initiation does not require authority.
The app must observe the configured HTTPS origin. Do not trust arbitrary
forwarded headers; configure an authenticated trusted proxy separately. Callback
query strings are cleared before response-time application access logging, but
reverse proxies and tracing collectors must independently suppress callback
queries, cookies and authorization headers. No public listener is enabled by
these source changes.

View file

@ -118,6 +118,23 @@
"audience": "none: minimal process liveness",
"test_owner": "hub-core",
"enforcement": "No identity required; no sensitive details"
},
"browser-session": {
"actor_tenant": "tenant:platform for root; named workload tenant otherwise",
"target_tenant": "resolved server-side from resource; root cross-tenant action explicitly audited",
"action_resource_rule": "hub.browser.session for login/session authority; protected API uses existing route action",
"cases": [
"ROOT",
"OTHER",
"INVALID",
"REVOKE",
"OUTAGE",
"BYPASS",
"CALLER"
],
"audience": "hub-browser (OIDC ID token); hub-core (access token); deployment registration required",
"test_owner": "hub-core",
"enforcement": "Explicit BrowserSessions: login is public initiation; callback requires one-time browser-bound state, PKCE and nonce; session requires current authority; logout requires session and CSRF"
}
},
"acceptance_cases": {
@ -399,6 +416,39 @@
"conditional": false,
"handler": "list_widgets"
},
{
"id": "http:GET:/auth/callback:hub_core.security.browser.browser_flow",
"kind": "runtime-http",
"method": "GET",
"path": "/auth/callback",
"profile": "browser-session",
"current_gate": "OIDC state/PKCE callback or server-side session; explicit browser composition only",
"source": "hub_core.security.browser",
"conditional": true,
"handler": "browser_flow"
},
{
"id": "http:GET:/auth/login:hub_core.security.browser.browser_flow",
"kind": "runtime-http",
"method": "GET",
"path": "/auth/login",
"profile": "browser-session",
"current_gate": "OIDC state/PKCE callback or server-side session; explicit browser composition only",
"source": "hub_core.security.browser",
"conditional": true,
"handler": "browser_flow"
},
{
"id": "http:GET:/auth/session:hub_core.security.browser.browser_flow",
"kind": "runtime-http",
"method": "GET",
"path": "/auth/session",
"profile": "browser-session",
"current_gate": "OIDC state/PKCE callback or server-side session; explicit browser composition only",
"source": "hub_core.security.browser",
"conditional": true,
"handler": "browser_flow"
},
{
"id": "http:GET:/console:hub_core.runtime.compat.console",
"kind": "runtime-http",
@ -982,6 +1032,17 @@
"conditional": false,
"handler": "create_widget"
},
{
"id": "http:POST:/auth/logout:hub_core.security.browser.browser_flow",
"kind": "runtime-http",
"method": "POST",
"path": "/auth/logout",
"profile": "browser-session",
"current_gate": "OIDC state/PKCE callback or server-side session; explicit browser composition only",
"source": "hub_core.security.browser",
"conditional": true,
"handler": "browser_flow"
},
{
"id": "http:POST:/decision-records:hub_core.runtime.compat.accept_deferred",
"kind": "runtime-http",

View file

@ -1,7 +1,7 @@
# Platform-root access inventory — 2026-09-28
This is the coverage baseline for HUB-WP-0012-T01, not an access grant or a
passing security test. It enumerates 161 Hub source surfaces (88 runtime route
passing security test. It enumerates 165 Hub source surfaces (92 runtime route
registrations, 42 embedded router operations, 31 MCP tools), 39 observed cluster
namespaces and nine additional extension/native-management boundaries. All 250
observed Deployment/StatefulSet/DaemonSet/CronJob/Service/Ingress objects map to
@ -19,7 +19,9 @@ pending owner confirmation; none establishes an effective platform-root grant.
Hub source revision is recorded in the JSON. Runtime routes are constructed
locally without running startup, sending requests or connecting to a database.
The optional inbox router is included separately. Compatibility aliases and
The optional inbox and browser-session routers are included separately. Browser
login initiation and callback are exact protocol entry points; they do not
grant access without verified tokens and live root authorization. Compatibility aliases and
FastAPI built-in documentation endpoints are included even when absent from
OpenAPI; disabled compatibility groups still belong in the coverage contract.
Embedded factories are scanned with their default prefixes and include optional

View file

@ -31,6 +31,7 @@ from hub_core.runtime.workload_projection import (
from hub_core.runtime.workload_projection_routes import create_workload_projection_router
from hub_core.security.boundary import AccessBoundary, AccessController, FactSource
from hub_core.security.config import SecuritySettings
from hub_core.security.browser import BrowserSettings, BrowserSessions, create_browser_router
def create_app(
@ -42,8 +43,11 @@ def create_app(
access_controller: AccessController | None = None,
access_facts: FactSource | None = None,
security_settings: SecuritySettings | None = None,
browser_settings: BrowserSettings | None = None,
) -> FastAPI:
resolved_settings = settings or RuntimeSettings.from_env()
if access_controller is not None and not resolved_settings.enforce_access:
raise ValueError("an access controller requires enforcement mode")
# Importing this module also constructs the standalone app. Environment
# configuration is activated only by an explicit owner-facts composition;
# without that adapter the default app stays closed, not import-broken.
@ -59,6 +63,9 @@ def create_app(
raise ValueError("security composition requires configuration and authoritative owner facts")
security_client = httpx.AsyncClient(trust_env=False)
access_controller = security_settings.compose(facts=access_facts, client=security_client)
if browser_settings is not None and (not resolved_settings.enforce_access or access_controller is None):
raise ValueError("browser sessions require an enforced access controller")
browser = BrowserSessions(settings=browser_settings, controller=access_controller) if browser_settings else None
resolved_store = port_store or _create_store(resolved_settings)
owns_store = port_store is None
resolved_repo_projection_client = repo_projection_client
@ -107,6 +114,8 @@ def create_app(
try:
yield
finally:
if browser is not None:
browser.clear()
if refresh_task is not None:
refresh_task.cancel()
with suppress(asyncio.CancelledError):
@ -135,8 +144,11 @@ def create_app(
app.state.repository_navigation = repository_navigation
app.state.workload_projection = workload_projection
app.state.access_controller = access_controller
app.state.browser_sessions = browser
if browser is not None:
app.include_router(create_browser_router())
if resolved_settings.enforce_access:
app.add_middleware(AccessBoundary, host=app, controller=access_controller)
app.add_middleware(AccessBoundary, host=app, controller=access_controller, browser=browser)
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
async def healthz() -> HealthResponse:

View file

@ -179,8 +179,12 @@ class AccessBoundary:
routers. Routes added without catalog admission remain denied.
"""
def __init__(self, app, *, host, controller: AccessController | None,
catalog: dict[str, str] | None = None):
catalog: dict[str, str] | None = None, body_timeout: float = 10, browser=None):
if not 0 < body_timeout <= 10:
raise ValueError("body timeout must be positive and at most ten seconds")
self.app, self.host, self.controller = app, host, controller
self.body_timeout = body_timeout
self.browser = browser
self.catalog = catalog if catalog is not None else json.loads(
files("hub_core.security").joinpath("routes.json").read_text()
)["routes"]
@ -196,15 +200,31 @@ class AccessBoundary:
if scope["method"] == "GET" and scope["path"] == "/healthz":
await JSONResponse({"status": "ok"})(scope, receive, send)
return
if self.browser is not None:
from hub_core.security.browser import BROWSER_ROUTES
if (scope["method"], scope["path"]) in BROWSER_ROUTES:
response = await self.browser.handle(Request(scope, receive))
await response(scope, receive, send)
return
correlation = str(uuid4())
context = None
cookie_auth = False
action = None
digest = None
try:
headers = Request(scope).headers.getlist("authorization")
if len(headers) != 1 or not headers[0].startswith("Bearer "):
raise AccessFailure(401, "bearer_required")
token = headers[0][7:]
request = Request(scope, receive)
headers = request.headers.getlist("authorization")
if self.browser is not None and not headers:
token = self.browser.access_token(request)
cookie_auth = True
else:
if self.browser is not None:
from hub_core.security.browser import SESSION_COOKIE
if SESSION_COOKIE in request.cookies:
raise AccessFailure(400, "mixed_browser_credentials")
if len(headers) != 1 or not headers[0].startswith("Bearer "):
raise AccessFailure(401, "bearer_required")
token = headers[0][7:]
if not token or len(token) > 16384 or any(c.isspace() for c in token):
raise AccessFailure(401, "invalid_access_token")
if self.controller is None:
@ -218,11 +238,15 @@ class AccessBoundary:
# Bind policy to the exact request without exposing content to PDP/audit.
request = Request(scope, receive)
chunks, size = [], 0
async for chunk in request.stream():
size += len(chunk)
if size > 1024 * 1024:
raise AccessFailure(413, "request_too_large")
chunks.append(chunk)
try:
async with asyncio.timeout(self.body_timeout):
async for chunk in request.stream():
size += len(chunk)
if size > 1024 * 1024:
raise AccessFailure(413, "request_too_large")
chunks.append(chunk)
except TimeoutError as exc:
raise AccessFailure(408, "request_body_timeout") from exc
body = b"".join(chunks)
digest = hashlib.sha256(b"\0".join([
scope["method"].encode(), scope["path"].encode(),
@ -241,6 +265,8 @@ class AccessBoundary:
for field in ("from_address", "from_agent", "author"):
if field in payload and payload[field] not in context.facts.producer_addresses:
raise AccessFailure(403, "producer_identity_mismatch")
if cookie_auth:
self.browser.access_token(request) # Recheck expiry/logout after owner awaits.
scope.setdefault("state", {})["hub_access"] = context
except Exception as exc:
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")

View file

@ -0,0 +1,288 @@
"""Confidential OIDC/PKCE browser sessions; bearer tokens never leave the backend."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote_plus, urlencode, urlsplit
from uuid import uuid4
import httpx
import jwt
from fastapi import APIRouter, Request
from starlette.responses import JSONResponse, RedirectResponse, Response
from hub_core.security.boundary import AccessController, PROFILE
from hub_core.security.identity import AccessFailure, OIDCVerifier, require_https
SESSION_COOKIE = "__Host-hub-session"
LOGIN_COOKIE = "__Host-hub-login"
BROWSER_ROUTES = frozenset({("GET", "/auth/login"), ("GET", "/auth/callback"),
("GET", "/auth/session"), ("POST", "/auth/logout")})
SESSION_ACTION = "hub.browser.session"
@dataclass(frozen=True)
class BrowserSettings:
origin: str
client_id: str
client_secret_file: Path
session_seconds: int = 300
capacity: int = 1024
def __post_init__(self):
require_https(self.origin)
if urlsplit(self.origin).path or not self.client_id:
raise ValueError("exact origin without path and client ID required")
if not isinstance(self.client_secret_file, Path) or not self.client_secret_file.is_absolute():
raise ValueError("absolute OIDC client credential path required")
if not 1 <= self.session_seconds <= 300 or not 1 <= self.capacity <= 10000:
raise ValueError("bounded browser session settings required")
@property
def redirect_uri(self):
return self.origin + "/auth/callback"
@dataclass(frozen=True)
class PendingLogin:
binding: str
nonce: str
verifier: str
created_at: float
token_endpoint: str
@dataclass(frozen=True)
class Session:
access_token: str
csrf: str
expires_at: float
class BrowserSessions:
def __init__(self, *, settings: BrowserSettings, controller: AccessController):
if not isinstance(controller.identity, OIDCVerifier):
raise ValueError("browser sessions require the admitted OIDC verifier")
self.settings, self.controller = settings, controller
self.identity = controller.identity
self.pending: dict[str, PendingLogin] = {}
self.sessions: dict[str, Session] = {}
def clear(self):
self.pending.clear()
self.sessions.clear()
def _prune(self):
now = time.time()
self.pending = {k: v for k, v in self.pending.items() if now - v.created_at < 300}
self.sessions = {k: v for k, v in self.sessions.items() if now < v.expires_at}
def _origin(self, request: Request):
if str(request.base_url).rstrip("/") != self.settings.origin:
raise AccessFailure(403, "browser_origin_mismatch")
def _session(self, request: Request) -> tuple[str, Session]:
self._origin(request)
self._prune()
sid = request.cookies.get(SESSION_COOKIE, "")
session = self.sessions.get(sid)
if session is None:
raise AccessFailure(401, "browser_session_required")
return sid, session
def _csrf(self, request: Request, session: Session):
values = request.headers.getlist("x-hub-csrf")
if (request.headers.getlist("origin") != [self.settings.origin]
or len(values) != 1 or not hmac.compare_digest(values[0], session.csrf)):
raise AccessFailure(403, "csrf_failed")
def access_token(self, request: Request) -> str:
_, session = self._session(request)
if request.method not in {"GET", "HEAD", "OPTIONS"}:
self._csrf(request, session)
return session.access_token
@staticmethod
def _cookie(response: Response, name: str, value: str, lifetime: int):
response.set_cookie(name, value, max_age=lifetime, path="/", secure=True,
httponly=True, samesite="lax")
@staticmethod
def _delete_cookie(response: Response, name: str):
response.delete_cookie(name, path="/", secure=True, httponly=True, samesite="lax")
async def _begin(self, request: Request) -> Response:
self._origin(request)
# No caller-controlled redirect, scope, prompt or authorization endpoint.
if request.query_params:
raise AccessFailure(400, "unsupported_login_parameters")
self._prune()
if len(self.pending) >= self.settings.capacity:
raise AccessFailure(503, "login_capacity_reached")
response = await self.identity.client.get(
self.identity.issuer.rstrip("/") + "/.well-known/openid-configuration",
timeout=3, follow_redirects=False)
response.raise_for_status()
metadata = response.json()
if (metadata["issuer"] != self.identity.issuer
or "S256" not in metadata.get("code_challenge_methods_supported", [])
or "code" not in metadata.get("response_types_supported", [])
or "client_secret_basic" not in metadata.get("token_endpoint_auth_methods_supported", [])):
raise AccessFailure(503, "unsupported_browser_issuer")
for name in ("authorization_endpoint", "token_endpoint"):
require_https(metadata[name])
# Reserve only after discovery, checking capacity again after the await.
if len(self.pending) >= self.settings.capacity:
raise AccessFailure(503, "login_capacity_reached")
state, binding, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(4))
old_binding = request.cookies.get(LOGIN_COOKIE)
if old_binding:
self.pending = {k: v for k, v in self.pending.items() if v.binding != old_binding}
self.pending[state] = PendingLogin(binding, nonce, verifier, time.time(), metadata["token_endpoint"])
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
location = metadata["authorization_endpoint"] + "?" + urlencode({
"response_type": "code", "client_id": self.settings.client_id,
"redirect_uri": self.settings.redirect_uri, "scope": "openid profile",
"state": state, "nonce": nonce, "code_challenge": challenge,
"code_challenge_method": "S256", "max_age": "0", "acr_values": "aal2",
})
result = RedirectResponse(location, status_code=303)
self._cookie(result, LOGIN_COOKIE, binding, 300)
return result
async def _complete(self, request: Request, correlation: str) -> Response:
params = request.query_params
# Uvicorn's response-time access log must not retain the code/query.
# Reverse proxies must independently exclude callback query logging.
request.scope["query_string"] = b""
self._origin(request)
if (any(len(params.getlist(k)) != 1 for k in params)
or set(params) - {"code", "state", "iss", "session_state", "error", "error_description"}):
raise AccessFailure(400, "invalid_login_response")
self._prune()
state = params.get("state", "")
pending = self.pending.get(state)
if (pending is None or not hmac.compare_digest(
request.cookies.get(LOGIN_COOKIE, ""), pending.binding)):
raise AccessFailure(401, "invalid_login_state")
del self.pending[state] # One-time consumption precedes any await.
if params.get("iss", self.identity.issuer) != self.identity.issuer:
raise AccessFailure(401, "invalid_login_issuer")
code = params.get("code")
if params.get("error") or not code or len(code) > 4096:
raise AccessFailure(401, "login_failed")
secret = self.settings.client_secret_file.read_text().strip()
if not secret or not secret.isascii():
raise AccessFailure(503, "browser_client_unavailable")
response = await self.identity.client.post(pending.token_endpoint,
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": self.settings.redirect_uri, "code_verifier": pending.verifier},
auth=httpx.BasicAuth(quote_plus(self.settings.client_id), quote_plus(secret)),
timeout=3, follow_redirects=False)
response.raise_for_status()
tokens = response.json()
if str(tokens.get("token_type", "")).lower() != "bearer":
raise AccessFailure(401, "invalid_login_token")
access, identity_token = tokens["access_token"], tokens["id_token"]
if (not isinstance(access, str) or not isinstance(identity_token, str)
or len(access) > 16384 or len(identity_token) > 16384):
raise AccessFailure(401, "invalid_login_token")
header, key = await self.identity.signing_key(identity_token)
if header.get("typ", "JWT") != "JWT":
raise AccessFailure(401, "invalid_identity_token_type")
claims = jwt.decode(identity_token, key, algorithms=["RS256"], issuer=self.identity.issuer,
audience=self.settings.client_id,
options={"require": ["iss", "sub", "aud", "iat", "exp", "nonce", "auth_time"]})
if claims.get("typ", "ID") != "ID":
raise AccessFailure(401, "invalid_identity_token_type")
if (not isinstance(claims["nonce"], str) or not hmac.compare_digest(claims["nonce"], pending.nonce)
or (isinstance(claims["aud"], list) and len(claims["aud"]) > 1 and "azp" not in claims)
or claims.get("azp", self.settings.client_id) != self.settings.client_id):
raise AccessFailure(401, "invalid_login_identity")
now = time.time()
if (any(type(claims[k]) is not int for k in ("iat", "exp", "auth_time"))
or not pending.created_at - 1 <= claims["auth_time"] <= now
or not 0 <= now - claims["iat"] <= 300):
raise AccessFailure(401, "stale_login_identity")
if "at_hash" in claims:
expected = base64.urlsafe_b64encode(hashlib.sha256(access.encode()).digest()[:16]).decode().rstrip("=")
if not isinstance(claims["at_hash"], str) or not hmac.compare_digest(claims["at_hash"], expected):
raise AccessFailure(401, "invalid_access_binding")
context = await self.controller.authorize(access, SESSION_ACTION, "/auth/session", correlation,
hashlib.sha256(b"browser-login").hexdigest())
if context.actor.principal_type != "human" or context.actor.subject != claims["sub"]:
raise AccessFailure(403, "invalid_browser_principal")
self._prune()
if len(self.sessions) >= self.settings.capacity:
raise AccessFailure(503, "session_capacity_reached")
old_session = request.cookies.get(SESSION_COOKIE, "")
self.sessions.pop(old_session, None)
sid = secrets.token_urlsafe(32)
expiry = min(time.time() + self.settings.session_seconds, context.actor.expires_at, claims["exp"])
self.sessions[sid] = Session(access, secrets.token_urlsafe(32), expiry)
result = RedirectResponse("/docs", status_code=303)
self._cookie(result, SESSION_COOKIE, sid, max(0, int(expiry - time.time())))
self._delete_cookie(result, LOGIN_COOKIE)
return result
async def handle(self, request: Request) -> Response:
correlation = str(uuid4())
try:
async with asyncio.timeout(10):
if request.headers.getlist("authorization"):
raise AccessFailure(400, "browser_flow_requires_cookies")
if request.url.path == "/auth/login":
result = await self._begin(request)
elif request.url.path == "/auth/callback":
result = await self._complete(request, correlation)
else:
sid, session = self._session(request)
if request.method == "POST":
self._csrf(request, session)
del self.sessions[sid] # Local logout works during owner outages.
result = Response(status_code=204)
self._delete_cookie(result, SESSION_COOKIE)
else:
await self.controller.authorize(session.access_token, SESSION_ACTION, "/auth/session",
correlation, hashlib.sha256(b"browser-session").hexdigest())
self._session(request)
result = JSONResponse({"authenticated": True, "csrf_token": session.csrf,
"expires_at": session.expires_at})
except Exception as exc:
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(
401 if isinstance(exc, jwt.PyJWTError) else 503, "browser_login_unavailable")
try:
async with asyncio.timeout(3):
await self.controller.audit.append({
"profile": PROFILE, "correlation_id": correlation,
"outcome": "refused", "reason": failure.code,
"action": SESSION_ACTION, "target_tenant": "tenant:platform",
"resource_digest": hashlib.sha256(request.url.path.encode()).hexdigest(),
})
except Exception:
failure = AccessFailure(503, "audit_unavailable")
result = JSONResponse({"detail": failure.code}, status_code=failure.status)
if request.url.path == "/auth/callback":
self._delete_cookie(result, LOGIN_COOKIE)
result.headers.update({"X-Correlation-ID": correlation, "Cache-Control": "no-store", "Pragma": "no-cache",
"Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff"})
return result
def create_browser_router() -> APIRouter:
router = APIRouter()
@router.get("/auth/login", include_in_schema=False)
@router.get("/auth/callback", include_in_schema=False)
@router.get("/auth/session", include_in_schema=False)
@router.post("/auth/logout", include_in_schema=False)
async def browser_flow(request: Request) -> Response:
return await request.app.state.browser_sessions.handle(request)
return router

View file

@ -94,19 +94,22 @@ class OIDCVerifier:
except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as exc:
raise AccessFailure(503, "identity_unavailable") from exc
async def signing_key(self, token: str):
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
raise ValueError("unsupported token")
async with self._lock:
age = time.monotonic() - self._loaded
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
await self._refresh()
key = self._keys.get(header["kid"])
if key is None:
raise ValueError("unknown key")
return header, key
async def authenticate(self, token: str) -> Actor:
try:
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
raise ValueError("unsupported token")
async with self._lock:
# At most one unknown-key refresh per second, to bound random-kid traffic.
age = time.monotonic() - self._loaded
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
await self._refresh()
key = self._keys.get(header["kid"])
if key is None:
raise ValueError("unknown key")
header, key = await self.signing_key(token)
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
audience=self.audience, leeway=0,
options={"require": ["iss", "sub", "aud", "exp", "iat",

View file

@ -227,3 +227,24 @@ def test_fact_strings_cannot_be_truthy_grants_and_denials_retain_actor():
assert owners.records[-1]['subject'] == 'ordinary'
assert owners.records[-1]['action']
assert owners.records[-1]['request_digest']
def test_slow_request_body_times_out_before_authority_or_handler():
from hub_core.security.boundary import AccessBoundary
owners = Owners()
host = runtime(owners)
called = []
messages = []
async def handler(scope, receive, send):
called.append(True)
boundary = AccessBoundary(handler, host=host, controller=owners.controller(), body_timeout=0.01)
scope = {'type':'http','method':'POST','path':'/ports/messaging/messages',
'headers':[(b'authorization',b'Bearer verified-root')], 'query_string':b''}
async def receive():
await asyncio.Future()
async def send(message):
messages.append(message)
asyncio.run(boundary(scope,receive,send))
assert messages[0]['status'] == 408
assert not called and not owners.requests
assert owners.records[-1]['reason'] == 'request_body_timeout'

View file

@ -62,3 +62,8 @@ def test_environment_composition_requires_explicit_owner_adapter(monkeypatch):
with TestClient(composed) as client:
assert client.get('/healthz').status_code == 200
assert composed.state.access_controller is not None
def test_direct_controller_cannot_be_silently_disabled():
with pytest.raises(ValueError, match='enforcement mode'):
create_app(settings=RuntimeSettings(), access_controller=object())

View file

@ -0,0 +1,231 @@
import base64
import hashlib
import json
import time
from dataclasses import replace
from urllib.parse import parse_qs, urlencode
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.security.browser import BrowserSettings, LOGIN_COOKIE, SESSION_COOKIE
from hub_core.security.identity import OIDCVerifier
from test_access_boundary import Owners
@pytest.fixture(scope='module')
def signing_key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@pytest.fixture
def browser(tmp_path, signing_key):
secret = tmp_path / 'client-secret'
secret.write_text('test-client-secret')
owners = Owners()
state = {'exchanges': 0, 'id_changes': {}, 'access_changes': {}, 'id_type': 'JWT'}
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key()))
jwk.update(kid='browser-key', alg='RS256', use='sig')
def handle(request):
if request.url.path.endswith('openid-configuration'):
return httpx.Response(200, json={
'issuer': 'https://issuer.example', 'jwks_uri': 'https://issuer.example/keys',
'authorization_endpoint': 'https://issuer.example/authorize',
'token_endpoint': 'https://issuer.example/token',
'code_challenge_methods_supported': ['S256'], 'response_types_supported': ['code'],
'token_endpoint_auth_methods_supported': ['client_secret_basic']})
if request.url.path == '/keys':
return httpx.Response(200, json={'keys': [jwk]})
assert request.url.path == '/token'
state['exchanges'] += 1
form = parse_qs(request.content.decode())
assert form['code'] == ['one-time-code']
assert form['redirect_uri'] == ['https://hub.example/auth/callback']
assert form['grant_type'] == ['authorization_code']
assert request.headers['authorization'] == 'Basic ' + base64.b64encode(
b'hub-browser:test-client-secret').decode()
challenge = base64.urlsafe_b64encode(hashlib.sha256(form['code_verifier'][0].encode()).digest()).decode().rstrip('=')
assert challenge == state['login']['code_challenge'][0]
now = int(time.time())
claims = dict(iss='https://issuer.example', sub='immutable-root', aud='hub-core',
iat=now, exp=now+300, nbf=now, tenant='tenant:platform',
principal_type='human', groups=[], roles=[], scope='openid',
assurance=dict(level='aal2', methods=['pwd', 'otp'], mfa=True,
source='key-cape', at=now))
claims.update(state['access_changes'])
access = jwt.encode(claims, signing_key, algorithm='RS256', headers={'kid': 'browser-key', 'typ': 'at+jwt'})
identity = dict(iss='https://issuer.example', sub='immutable-root', aud='hub-browser',
iat=now, exp=now+300, nonce=state['login']['nonce'][0], auth_time=now,
at_hash=base64.urlsafe_b64encode(hashlib.sha256(access.encode()).digest()[:16]).decode().rstrip('='))
identity.update(state['id_changes'])
identity = jwt.encode(identity, signing_key, algorithm='RS256', headers={'kid': 'browser-key', 'typ': state['id_type']})
state['access'] = access
return httpx.Response(200, json={'token_type': 'Bearer', 'access_token': access, 'id_token': identity})
upstream = httpx.AsyncClient(transport=httpx.MockTransport(handle))
controller = owners.controller()
controller.identity = OIDCVerifier(issuer='https://issuer.example', audience='hub-core', client=upstream)
app = create_app(settings=RuntimeSettings(environment='test', access_mode='enforce'),
access_controller=controller,
browser_settings=BrowserSettings('https://hub.example', 'hub-browser', secret))
with TestClient(app, base_url='https://hub.example', follow_redirects=False) as client:
yield client, app.state.browser_sessions, owners, state
import asyncio
asyncio.run(upstream.aclose())
def begin(client, state):
result = client.get('/auth/login')
assert result.status_code == 303
state['login'] = parse_qs(httpx.URL(result.headers['location']).query.decode())
assert state['login']['code_challenge_method'] == ['S256']
assert state['login']['redirect_uri'] == ['https://hub.example/auth/callback']
cookie = result.headers['set-cookie']
assert all(s in cookie for s in ['__Host-hub-login=', 'Secure', 'HttpOnly', 'SameSite=lax', 'Path=/'])
return '/auth/callback?' + urlencode({'state': state['login']['state'][0], 'code': 'one-time-code'})
def login(browser):
client, sessions, owners, state = browser
callback = begin(client, state)
result = client.get(callback)
assert result.status_code == 303, result.text
assert result.headers['location'] == '/docs'
assert owners.records[-1]['correlation_id'] == result.headers['x-correlation-id']
assert state['access'] not in str(result.headers)
assert client.cookies.get(SESSION_COOKIE) != state['access']
return callback
def test_login_cookie_session_revocation_and_logout(browser):
client, sessions, owners, state = browser
callback = login(browser)
assert client.get(callback).status_code == 401
assert state['exchanges'] == 1
assert client.get('/docs').status_code == 200
response = client.get('/auth/session')
assert response.status_code == 200
assert state['access'] not in response.text
assert response.headers['cache-control'] == 'no-store'
csrf = response.json()['csrf_token']
assert client.post('/auth/logout').status_code == 403
assert client.post('/auth/logout', headers={'origin': 'https://evil.example', 'x-hub-csrf': csrf}).status_code == 403
owners.facts = replace(owners.facts, root_entitled=False)
assert client.get('/docs').status_code == 403
assert client.get('/auth/session').status_code == 403
owners.audit_down = owners.policy_down = True
result = client.post('/auth/logout', headers={'origin': 'https://hub.example', 'x-hub-csrf': csrf})
assert result.status_code == 204
assert not sessions.sessions
assert client.cookies.get(SESSION_COOKIE) is None
def test_cookie_csrf_enforced_before_policy_and_mixed_credentials_refused(browser):
client, sessions, owners, state = browser
login(browser)
before = len(owners.requests)
assert client.post('/ports/messaging/messages', json={}).status_code == 403
assert len(owners.requests) == before
csrf = client.get('/auth/session').json()['csrf_token']
result = client.post('/ports/messaging/messages', json={}, headers={'origin': 'https://hub.example', 'x-hub-csrf': csrf})
# An invalid business request reaches content validation only with valid CSRF.
assert result.status_code == 422
body = {'schema_version': '0.1.0', 'correlation_id': 'f7cffcab-4c02-419e-89e5-0b463f5b433a',
'from_address': 'agent:root', 'to_addresses': ['agent:reader'], 'body': 'private browser text'}
headers = {'origin': 'https://hub.example', 'x-hub-csrf': csrf}
assert client.post('/ports/messaging/messages', json=body, headers=headers).status_code == 202
body['from_address'] = 'agent:someone-else'
assert client.post('/ports/messaging/messages', json=body, headers=headers).status_code == 403
assert 'private browser text' not in json.dumps(owners.records)
assert client.get('/docs', headers={'Authorization': 'Bearer ' + state['access']}).status_code == 400
assert client.get('/auth/session', headers={'Authorization': 'Bearer ' + state['access']}).status_code == 400
@pytest.mark.parametrize('changes', [
{'nonce': 'wrong'}, {'aud': 'wrong'}, {'azp': 'wrong'}, {'sub': 'different'},
{'aud': ['hub-browser', 'another']}, {'auth_time': 1}, {'iat': 1}, {'exp': 1},
{'auth_time': True}, {'at_hash': 'wrong'}, {'typ': 'Bearer'},
])
def test_invalid_id_token_never_creates_session(browser, changes):
client, sessions, owners, state = browser
state['id_changes'] = changes
assert client.get(begin(client, state)).status_code in {401, 403}
assert not sessions.sessions
assert client.cookies.get(SESSION_COOKIE) is None
def test_access_token_cannot_be_used_as_id_token(browser):
client, sessions, owners, state = browser
state['id_type'] = 'at+jwt'
assert client.get(begin(client, state)).status_code == 401
assert not sessions.sessions
def test_callback_bound_to_browser_and_one_time_state(browser):
client, sessions, owners, state = browser
callback = begin(client, state)
binding = client.cookies.get(LOGIN_COOKIE)
client.cookies.clear()
assert client.get(callback).status_code == 401
assert state['exchanges'] == 0
client.cookies.set(LOGIN_COOKIE, binding, domain='hub.example', path='/')
assert client.get(callback).status_code == 303
assert client.get(callback).status_code == 401
assert state['exchanges'] == 1
@pytest.mark.parametrize('changes', [{'sub': 'ordinary'}, {'tenant': 'tenant:other'},
{'assurance': dict(level='aal1', methods=['pwd'], mfa=False, source='key-cape', at=int(time.time()))}])
def test_login_requires_root_platform_and_mfa(browser, changes):
client, sessions, owners, state = browser
state['access_changes'] = changes
assert client.get(begin(client, state)).status_code == 403
assert not sessions.sessions
def test_session_expiry_and_restart_invalidate_cookies(browser):
client, sessions, owners, state = browser
login(browser)
sid = client.cookies.get(SESSION_COOKIE)
sessions.sessions[sid] = replace(sessions.sessions[sid], expires_at=1)
assert client.get('/docs').status_code == 401
login(browser)
sessions.clear()
assert client.get('/docs').status_code == 401
def test_logout_during_authority_check_prevents_handler(browser):
client, sessions, owners, state = browser
login(browser)
evaluate = owners.evaluate
async def revoke(request):
result = await evaluate(request)
sessions.sessions.clear()
return result
owners.evaluate = revoke
assert client.get('/docs').status_code == 401
def test_origin_redirect_capacity_and_owner_failures(browser):
client, sessions, owners, state = browser
assert client.get('/auth/login?next=https://evil.example').status_code == 400
assert client.get('/auth/login', headers={'host': 'evil.example'}).status_code == 403
callback = begin(client, state)
owners.audit_down = True
assert client.get(callback).status_code == 503
assert not sessions.sessions
sessions.settings = replace(sessions.settings, capacity=1)
begin(client, state)
assert client.get('/auth/login').status_code == 503
def test_browser_cannot_enable_legacy_runtime(tmp_path):
settings = BrowserSettings('https://hub.example', 'browser', tmp_path / 'secret')
with pytest.raises(ValueError, match='enforced access controller'):
create_app(browser_settings=settings)

View file

@ -17,6 +17,7 @@ def discover(root):
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.inbox_projection import create_inbox_projection_router
from hub_core.security.browser import create_browser_router
rows = []
@ -31,7 +32,7 @@ def discover(root):
# Construction only: no lifespan/startup and no requests or DB connection.
app = create_app(settings=RuntimeSettings())
for route in [*routes(app), *routes(create_inbox_projection_router())]:
for route in [*routes(app), *routes(create_inbox_projection_router()), *routes(create_browser_router())]:
endpoint = route.endpoint
source = inspect.getsource(endpoint)
module = endpoint.__module__
@ -39,11 +40,14 @@ def discover(root):
else 'no-identity-check-in-handler')
if route.path != '/healthz':
gate = 'access-profile-v1 in enforcement mode; development: ' + gate
browser = module.endswith("security.browser")
if browser:
gate = "OIDC state/PKCE callback or server-side session; explicit browser composition only"
for method in sorted(route.methods):
rows.append(dict(id=f'http:{method}:{route.path}:{module}.{endpoint.__name__}', kind='runtime-http',
method=method, path=route.path, profile=('minimal-health' if route.path == '/healthz' else 'hub-api'),
method=method, path=route.path, profile=('minimal-health' if route.path == '/healthz' else 'browser-session' if browser else 'hub-api'),
current_gate=gate, source=module,
conditional=module.endswith('inbox_projection'),
conditional=browser or module.endswith('inbox_projection'),
handler=endpoint.__name__))
verbs = {'get', 'post', 'patch', 'put', 'delete', 'head', 'options'}

View file

@ -250,7 +250,8 @@ invented and no live service, grant or public listener was changed.
complete per-service routes or substitute for the named owners' review.
- T02: implemented IAM v0.3 access-token verification with discovery, signature,
audience/type/lifetime/assurance validation and rotating keys. Live root binding,
PKCE sessions/MFA/logout and authoritative account/tenant adapters remain open.
issuer MFA/registration acceptance and authoritative account/tenant adapters remain open.
Local PKCE/session/logout implementation is covered in the continuation below.
- T03: implemented authenticated workload PDP calls, trusted rotating public keys,
signed envelope, submitted request digest, caller/structured binding/lifetime
checks and fail-closed obligations. Real Go fixtures prove interoperability.
@ -302,6 +303,28 @@ wheel build and inventory validation pass.
T01–T04 remain `progress`; live owner acceptance and the later milestones remain
open. No production deployment, entitlement or public listener changed.
## Browser and enforcement continuation — 2026-09-28
Closed two local enforcement gaps: injecting a controller into a development
runtime now fails startup instead of silently ignoring it, and request bodies
have a bounded receive deadline before authority evaluation.
Added explicit confidential OIDC browser composition with S256 PKCE, one-time
browser-bound state, nonce and ID-token checks, fresh MFA/root authorization,
opaque short-lived server-side sessions, CSRF protection and local logout.
Every protected session request retains live owner/policy/audit checks, including
a second session-validity check after authority awaits. Tokens never appear in
browser responses. Session/process lifetime, proxy logging and multiworker limits
are documented in the [integration guide](../docs/owner-access-integration.md).
Browser source tests cover actual RSA signatures and code exchange with mocked
owner endpoints; they are not live owner acceptance. The source inventory now
contains 165 Hub surfaces, including four optional browser protocol routes.
[Validation evidence](../docs/evidence/hub-wp-0012-browser-20260928.md).
T01–T04 remain `progress`; issuer registration/MFA, owner-facts composition,
MCP consumer adoption, operation-outcome auditing and platform conformance remain
open. No production listener or entitlement changed.
## Acceptance checkpoints
- [x] Architecture/source/runtime review captured; new implementation owner is hub-core
@ -310,5 +333,5 @@ open. No production deployment, entitlement or public listener changed.
- [ ] T07: authenticated public exposure separately approved and verified
- [ ] Phase 2: T08 delegated and tenant-scoped access accepted
Planning completion is not implementation completion. No authenticated root
Planning completion is not implementation completion. No live authenticated root
session or entitlement was tested in the 2026-09-28 review.