feat: add OIDC browser sessions with live access enforcement
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
fdea2f5192
commit
a15fe032b0
14 changed files with 807 additions and 31 deletions
|
|
@ -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.
|
returns 401 for missing credentials and 503 for credential-bearing requests.
|
||||||
The current deployed image and its release configuration have not been changed.
|
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
|
ASGI boundary protects docs, readiness, native ports, projections, compatibility
|
||||||
aliases and subsequently attached routes. Unknown method/route/handler combinations,
|
aliases and subsequently attached routes. Unknown method/route/handler combinations,
|
||||||
WebSockets, mounts without admission, and slash redirects without catalog entries
|
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
|
inventory generation does **not** auto-admit a new route. Duplicate docs handlers
|
||||||
remain separately inventoried; the boundary selects the first effective route.
|
remain separately inventoried; the boundary selects the first effective route.
|
||||||
These technical action names require flex-auth/owner review before policy delivery.
|
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
|
## 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,
|
The PDP request carries actor, target tenant, action, concrete resource path,
|
||||||
assurance, root entitlement, authoritative evidence reference, and a digest of
|
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
|
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.
|
policy/audit chain has a ten-second timeout; individual HTTP calls have three seconds.
|
||||||
A separate refusal-audit attempt is bounded at three seconds.
|
A separate refusal-audit attempt is bounded at three seconds.
|
||||||
|
|
||||||
|
|
|
||||||
43
docs/evidence/hub-wp-0012-browser-20260928.md
Normal file
43
docs/evidence/hub-wp-0012-browser-20260928.md
Normal 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.
|
||||||
|
|
@ -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
|
policy decision, receiver custody and native Hub write, followed by entitlement
|
||||||
withdrawal denial. Real root enrollment/login, service deployment, live key and
|
withdrawal denial. Real root enrollment/login, service deployment, live key and
|
||||||
sender custody, and operational acceptance remain open.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,23 @@
|
||||||
"audience": "none: minimal process liveness",
|
"audience": "none: minimal process liveness",
|
||||||
"test_owner": "hub-core",
|
"test_owner": "hub-core",
|
||||||
"enforcement": "No identity required; no sensitive details"
|
"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": {
|
"acceptance_cases": {
|
||||||
|
|
@ -399,6 +416,39 @@
|
||||||
"conditional": false,
|
"conditional": false,
|
||||||
"handler": "list_widgets"
|
"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",
|
"id": "http:GET:/console:hub_core.runtime.compat.console",
|
||||||
"kind": "runtime-http",
|
"kind": "runtime-http",
|
||||||
|
|
@ -982,6 +1032,17 @@
|
||||||
"conditional": false,
|
"conditional": false,
|
||||||
"handler": "create_widget"
|
"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",
|
"id": "http:POST:/decision-records:hub_core.runtime.compat.accept_deferred",
|
||||||
"kind": "runtime-http",
|
"kind": "runtime-http",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Platform-root access inventory — 2026-09-28
|
# Platform-root access inventory — 2026-09-28
|
||||||
|
|
||||||
This is the coverage baseline for HUB-WP-0012-T01, not an access grant or a
|
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
|
registrations, 42 embedded router operations, 31 MCP tools), 39 observed cluster
|
||||||
namespaces and nine additional extension/native-management boundaries. All 250
|
namespaces and nine additional extension/native-management boundaries. All 250
|
||||||
observed Deployment/StatefulSet/DaemonSet/CronJob/Service/Ingress objects map to
|
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
|
Hub source revision is recorded in the JSON. Runtime routes are constructed
|
||||||
locally without running startup, sending requests or connecting to a database.
|
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
|
FastAPI built-in documentation endpoints are included even when absent from
|
||||||
OpenAPI; disabled compatibility groups still belong in the coverage contract.
|
OpenAPI; disabled compatibility groups still belong in the coverage contract.
|
||||||
Embedded factories are scanned with their default prefixes and include optional
|
Embedded factories are scanned with their default prefixes and include optional
|
||||||
|
|
|
||||||
|
|
@ -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.runtime.workload_projection_routes import create_workload_projection_router
|
||||||
from hub_core.security.boundary import AccessBoundary, AccessController, FactSource
|
from hub_core.security.boundary import AccessBoundary, AccessController, FactSource
|
||||||
from hub_core.security.config import SecuritySettings
|
from hub_core.security.config import SecuritySettings
|
||||||
|
from hub_core.security.browser import BrowserSettings, BrowserSessions, create_browser_router
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
|
|
@ -42,8 +43,11 @@ def create_app(
|
||||||
access_controller: AccessController | None = None,
|
access_controller: AccessController | None = None,
|
||||||
access_facts: FactSource | None = None,
|
access_facts: FactSource | None = None,
|
||||||
security_settings: SecuritySettings | None = None,
|
security_settings: SecuritySettings | None = None,
|
||||||
|
browser_settings: BrowserSettings | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
resolved_settings = settings or RuntimeSettings.from_env()
|
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
|
# Importing this module also constructs the standalone app. Environment
|
||||||
# configuration is activated only by an explicit owner-facts composition;
|
# configuration is activated only by an explicit owner-facts composition;
|
||||||
# without that adapter the default app stays closed, not import-broken.
|
# 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")
|
raise ValueError("security composition requires configuration and authoritative owner facts")
|
||||||
security_client = httpx.AsyncClient(trust_env=False)
|
security_client = httpx.AsyncClient(trust_env=False)
|
||||||
access_controller = security_settings.compose(facts=access_facts, client=security_client)
|
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)
|
resolved_store = port_store or _create_store(resolved_settings)
|
||||||
owns_store = port_store is None
|
owns_store = port_store is None
|
||||||
resolved_repo_projection_client = repo_projection_client
|
resolved_repo_projection_client = repo_projection_client
|
||||||
|
|
@ -107,6 +114,8 @@ def create_app(
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
if browser is not None:
|
||||||
|
browser.clear()
|
||||||
if refresh_task is not None:
|
if refresh_task is not None:
|
||||||
refresh_task.cancel()
|
refresh_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
|
|
@ -135,8 +144,11 @@ def create_app(
|
||||||
app.state.repository_navigation = repository_navigation
|
app.state.repository_navigation = repository_navigation
|
||||||
app.state.workload_projection = workload_projection
|
app.state.workload_projection = workload_projection
|
||||||
app.state.access_controller = access_controller
|
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:
|
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"])
|
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
|
||||||
async def healthz() -> HealthResponse:
|
async def healthz() -> HealthResponse:
|
||||||
|
|
|
||||||
|
|
@ -179,8 +179,12 @@ class AccessBoundary:
|
||||||
routers. Routes added without catalog admission remain denied.
|
routers. Routes added without catalog admission remain denied.
|
||||||
"""
|
"""
|
||||||
def __init__(self, app, *, host, controller: AccessController | None,
|
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.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(
|
self.catalog = catalog if catalog is not None else json.loads(
|
||||||
files("hub_core.security").joinpath("routes.json").read_text()
|
files("hub_core.security").joinpath("routes.json").read_text()
|
||||||
)["routes"]
|
)["routes"]
|
||||||
|
|
@ -196,12 +200,28 @@ class AccessBoundary:
|
||||||
if scope["method"] == "GET" and scope["path"] == "/healthz":
|
if scope["method"] == "GET" and scope["path"] == "/healthz":
|
||||||
await JSONResponse({"status": "ok"})(scope, receive, send)
|
await JSONResponse({"status": "ok"})(scope, receive, send)
|
||||||
return
|
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())
|
correlation = str(uuid4())
|
||||||
context = None
|
context = None
|
||||||
|
cookie_auth = False
|
||||||
action = None
|
action = None
|
||||||
digest = None
|
digest = None
|
||||||
try:
|
try:
|
||||||
headers = Request(scope).headers.getlist("authorization")
|
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 "):
|
if len(headers) != 1 or not headers[0].startswith("Bearer "):
|
||||||
raise AccessFailure(401, "bearer_required")
|
raise AccessFailure(401, "bearer_required")
|
||||||
token = headers[0][7:]
|
token = headers[0][7:]
|
||||||
|
|
@ -218,11 +238,15 @@ class AccessBoundary:
|
||||||
# Bind policy to the exact request without exposing content to PDP/audit.
|
# Bind policy to the exact request without exposing content to PDP/audit.
|
||||||
request = Request(scope, receive)
|
request = Request(scope, receive)
|
||||||
chunks, size = [], 0
|
chunks, size = [], 0
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(self.body_timeout):
|
||||||
async for chunk in request.stream():
|
async for chunk in request.stream():
|
||||||
size += len(chunk)
|
size += len(chunk)
|
||||||
if size > 1024 * 1024:
|
if size > 1024 * 1024:
|
||||||
raise AccessFailure(413, "request_too_large")
|
raise AccessFailure(413, "request_too_large")
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise AccessFailure(408, "request_body_timeout") from exc
|
||||||
body = b"".join(chunks)
|
body = b"".join(chunks)
|
||||||
digest = hashlib.sha256(b"\0".join([
|
digest = hashlib.sha256(b"\0".join([
|
||||||
scope["method"].encode(), scope["path"].encode(),
|
scope["method"].encode(), scope["path"].encode(),
|
||||||
|
|
@ -241,6 +265,8 @@ class AccessBoundary:
|
||||||
for field in ("from_address", "from_agent", "author"):
|
for field in ("from_address", "from_agent", "author"):
|
||||||
if field in payload and payload[field] not in context.facts.producer_addresses:
|
if field in payload and payload[field] not in context.facts.producer_addresses:
|
||||||
raise AccessFailure(403, "producer_identity_mismatch")
|
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
|
scope.setdefault("state", {})["hub_access"] = context
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")
|
failure = exc if isinstance(exc, AccessFailure) else AccessFailure(503, "access_unavailable")
|
||||||
|
|
|
||||||
288
hub_core/security/browser.py
Normal file
288
hub_core/security/browser.py
Normal 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
|
||||||
|
|
@ -94,19 +94,22 @@ class OIDCVerifier:
|
||||||
except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as exc:
|
except (httpx.HTTPError, ValueError, KeyError, TypeError, jwt.PyJWTError) as exc:
|
||||||
raise AccessFailure(503, "identity_unavailable") from exc
|
raise AccessFailure(503, "identity_unavailable") from exc
|
||||||
|
|
||||||
async def authenticate(self, token: str) -> Actor:
|
async def signing_key(self, token: str):
|
||||||
try:
|
|
||||||
header = jwt.get_unverified_header(token)
|
header = jwt.get_unverified_header(token)
|
||||||
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
|
if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str):
|
||||||
raise ValueError("unsupported token")
|
raise ValueError("unsupported token")
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
# At most one unknown-key refresh per second, to bound random-kid traffic.
|
|
||||||
age = time.monotonic() - self._loaded
|
age = time.monotonic() - self._loaded
|
||||||
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
|
if age >= self.key_ttl or (header["kid"] not in self._keys and age >= 1):
|
||||||
await self._refresh()
|
await self._refresh()
|
||||||
key = self._keys.get(header["kid"])
|
key = self._keys.get(header["kid"])
|
||||||
if key is None:
|
if key is None:
|
||||||
raise ValueError("unknown key")
|
raise ValueError("unknown key")
|
||||||
|
return header, key
|
||||||
|
|
||||||
|
async def authenticate(self, token: str) -> Actor:
|
||||||
|
try:
|
||||||
|
header, key = await self.signing_key(token)
|
||||||
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
|
claims = jwt.decode(token, key, algorithms=["RS256"], issuer=self.issuer,
|
||||||
audience=self.audience, leeway=0,
|
audience=self.audience, leeway=0,
|
||||||
options={"require": ["iss", "sub", "aud", "exp", "iat",
|
options={"require": ["iss", "sub", "aud", "exp", "iat",
|
||||||
|
|
|
||||||
|
|
@ -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]['subject'] == 'ordinary'
|
||||||
assert owners.records[-1]['action']
|
assert owners.records[-1]['action']
|
||||||
assert owners.records[-1]['request_digest']
|
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'
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,8 @@ def test_environment_composition_requires_explicit_owner_adapter(monkeypatch):
|
||||||
with TestClient(composed) as client:
|
with TestClient(composed) as client:
|
||||||
assert client.get('/healthz').status_code == 200
|
assert client.get('/healthz').status_code == 200
|
||||||
assert composed.state.access_controller is not None
|
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())
|
||||||
|
|
|
||||||
231
tests/test_browser_access.py
Normal file
231
tests/test_browser_access.py
Normal 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)
|
||||||
|
|
@ -17,6 +17,7 @@ def discover(root):
|
||||||
from hub_core.runtime.app import create_app
|
from hub_core.runtime.app import create_app
|
||||||
from hub_core.runtime.config import RuntimeSettings
|
from hub_core.runtime.config import RuntimeSettings
|
||||||
from hub_core.runtime.inbox_projection import create_inbox_projection_router
|
from hub_core.runtime.inbox_projection import create_inbox_projection_router
|
||||||
|
from hub_core.security.browser import create_browser_router
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
|
|
@ -31,7 +32,7 @@ def discover(root):
|
||||||
|
|
||||||
# Construction only: no lifespan/startup and no requests or DB connection.
|
# Construction only: no lifespan/startup and no requests or DB connection.
|
||||||
app = create_app(settings=RuntimeSettings())
|
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
|
endpoint = route.endpoint
|
||||||
source = inspect.getsource(endpoint)
|
source = inspect.getsource(endpoint)
|
||||||
module = endpoint.__module__
|
module = endpoint.__module__
|
||||||
|
|
@ -39,11 +40,14 @@ def discover(root):
|
||||||
else 'no-identity-check-in-handler')
|
else 'no-identity-check-in-handler')
|
||||||
if route.path != '/healthz':
|
if route.path != '/healthz':
|
||||||
gate = 'access-profile-v1 in enforcement mode; development: ' + gate
|
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):
|
for method in sorted(route.methods):
|
||||||
rows.append(dict(id=f'http:{method}:{route.path}:{module}.{endpoint.__name__}', kind='runtime-http',
|
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,
|
current_gate=gate, source=module,
|
||||||
conditional=module.endswith('inbox_projection'),
|
conditional=browser or module.endswith('inbox_projection'),
|
||||||
handler=endpoint.__name__))
|
handler=endpoint.__name__))
|
||||||
|
|
||||||
verbs = {'get', 'post', 'patch', 'put', 'delete', 'head', 'options'}
|
verbs = {'get', 'post', 'patch', 'put', 'delete', 'head', 'options'}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
complete per-service routes or substitute for the named owners' review.
|
||||||
- T02: implemented IAM v0.3 access-token verification with discovery, signature,
|
- T02: implemented IAM v0.3 access-token verification with discovery, signature,
|
||||||
audience/type/lifetime/assurance validation and rotating keys. Live root binding,
|
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,
|
- T03: implemented authenticated workload PDP calls, trusted rotating public keys,
|
||||||
signed envelope, submitted request digest, caller/structured binding/lifetime
|
signed envelope, submitted request digest, caller/structured binding/lifetime
|
||||||
checks and fail-closed obligations. Real Go fixtures prove interoperability.
|
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
|
T01–T04 remain `progress`; live owner acceptance and the later milestones remain
|
||||||
open. No production deployment, entitlement or public listener changed.
|
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
|
## Acceptance checkpoints
|
||||||
|
|
||||||
- [x] Architecture/source/runtime review captured; new implementation owner is hub-core
|
- [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
|
- [ ] T07: authenticated public exposure separately approved and verified
|
||||||
- [ ] Phase 2: T08 delegated and tenant-scoped access accepted
|
- [ ] 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.
|
session or entitlement was tested in the 2026-09-28 review.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue