diff --git a/docs/access-profile-v1.md b/docs/access-profile-v1.md index 20e0151..1b337b3 100644 --- a/docs/access-profile-v1.md +++ b/docs/access-profile-v1.md @@ -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. diff --git a/docs/evidence/hub-wp-0012-browser-20260928.md b/docs/evidence/hub-wp-0012-browser-20260928.md new file mode 100644 index 0000000..b0773fb --- /dev/null +++ b/docs/evidence/hub-wp-0012-browser-20260928.md @@ -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. diff --git a/docs/owner-access-integration.md b/docs/owner-access-integration.md index 3bc5b7f..f1219fb 100644 --- a/docs/owner-access-integration.md +++ b/docs/owner-access-integration.md @@ -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. diff --git a/docs/platform-access-inventory.json b/docs/platform-access-inventory.json index e8172a8..69fe7d4 100644 --- a/docs/platform-access-inventory.json +++ b/docs/platform-access-inventory.json @@ -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", diff --git a/docs/platform-access-inventory.md b/docs/platform-access-inventory.md index 953a00c..d4f0966 100644 --- a/docs/platform-access-inventory.md +++ b/docs/platform-access-inventory.md @@ -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 diff --git a/hub_core/runtime/app.py b/hub_core/runtime/app.py index ba6f850..2afd63b 100644 --- a/hub_core/runtime/app.py +++ b/hub_core/runtime/app.py @@ -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: diff --git a/hub_core/security/boundary.py b/hub_core/security/boundary.py index 63eee0a..a3a61ef 100644 --- a/hub_core/security/boundary.py +++ b/hub_core/security/boundary.py @@ -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") diff --git a/hub_core/security/browser.py b/hub_core/security/browser.py new file mode 100644 index 0000000..023d266 --- /dev/null +++ b/hub_core/security/browser.py @@ -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 diff --git a/hub_core/security/identity.py b/hub_core/security/identity.py index 6121609..a3c2ad0 100644 --- a/hub_core/security/identity.py +++ b/hub_core/security/identity.py @@ -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", diff --git a/tests/test_access_boundary.py b/tests/test_access_boundary.py index 3340a62..1c7e6e9 100644 --- a/tests/test_access_boundary.py +++ b/tests/test_access_boundary.py @@ -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' diff --git a/tests/test_access_config.py b/tests/test_access_config.py index da74b35..07527ff 100644 --- a/tests/test_access_config.py +++ b/tests/test_access_config.py @@ -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()) diff --git a/tests/test_browser_access.py b/tests/test_browser_access.py new file mode 100644 index 0000000..5adebbe --- /dev/null +++ b/tests/test_browser_access.py @@ -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) diff --git a/tools/build_access_inventory.py b/tools/build_access_inventory.py index 2304a13..aad7b1d 100644 --- a/tools/build_access_inventory.py +++ b/tools/build_access_inventory.py @@ -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'} diff --git a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md index d851c69..6e823c1 100644 --- a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md +++ b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md @@ -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.