From e35ba16434cd70ebf1924fa280faee8cf791672f Mon Sep 17 00:00:00 2001 From: jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:22:15 +0800 Subject: [PATCH] test(webui): add standalone SSO session and multi-user isolation coverage (#6849) * test(webui): add guarded SSO provider E2E seam * test(e2e): cover WebUI SSO multi-user isolation * fix(test): address SSO E2E review feedback --- .github/workflows/coverage.yml | 11 +- .github/workflows/reborn-e2e.yml | 17 +- crates/ironclaw_reborn_cli/Cargo.toml | 7 + .../src/commands/serve_sso.rs | 221 +++++++++++++++++- tests/e2e/CLAUDE.md | 2 + tests/e2e/README.md | 1 + tests/e2e/conftest.py | 26 +++ tests/e2e/fixtures/mock_oauth_idp.py | 97 +++++++- tests/e2e/reborn_coverage_tests.txt | 1 + tests/e2e/reborn_webui_harness.py | 54 ++++- .../e2e/scenarios/test_reborn_webui_v2_sso.py | 203 ++++++++++++++++ 11 files changed, 619 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/scenarios/test_reborn_webui_v2_sso.py diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1de246113b..c9efec5c29 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -237,10 +237,15 @@ jobs: # Pre-build the reborn binary under the same llvm-cov env so the E2E # fixtures find it cached instead of doing a cold instrumented build # inside a pytest timeout. The WebChat v2 and OpenAI-compatible route - # surfaces are both unconditional, so one binary covers every scenario - # selected below. + # surfaces are both unconditional. The test-support feature adds only + # the guarded loopback SSO-provider seam used by the selected SSO E2E. - name: Build instrumented ironclaw-reborn - run: cargo build -p ironclaw --bin ironclaw + run: | + set -euo pipefail + cargo build -p ironclaw --bin ironclaw + cargo build -p ironclaw --bin ironclaw \ + --features test-support \ + --target-dir "${CARGO_TARGET_DIR:-target}/e2e-sso" - name: Mark OpenAI-compatible binary build run: touch target/debug/.ironclaw-reborn-openai-compat.stamp diff --git a/.github/workflows/reborn-e2e.yml b/.github/workflows/reborn-e2e.yml index b688ba6a75..5ad4bcbb5e 100644 --- a/.github/workflows/reborn-e2e.yml +++ b/.github/workflows/reborn-e2e.yml @@ -212,10 +212,12 @@ jobs: redis-password: ${{ secrets.SCCACHE_REDIS_PASSWORD }} - name: Build ironclaw-reborn with WebChat v2 surface - run: >- - cargo build - -p ironclaw - --bin ironclaw + run: | + set -euo pipefail + cargo build -p ironclaw --bin ironclaw + cargo build -p ironclaw --bin ironclaw \ + --features test-support \ + --target-dir "${CARGO_TARGET_DIR:-target}/e2e-sso" - name: Mark OpenAI-compatible binary build run: touch target/debug/.ironclaw-reborn-openai-compat.stamp @@ -272,7 +274,12 @@ jobs: retention-days: 14 - name: Run Reborn WebUI v2 smoke - run: pytest tests/e2e/scenarios/test_reborn_webui_v2_smoke.py -v --timeout=120 + run: >- + pytest + tests/e2e/scenarios/test_reborn_webui_v2_smoke.py + tests/e2e/scenarios/test_reborn_webui_v2_sso.py + -v + --timeout=120 - name: Run harvested QA replay and provider contracts with Emulate env: diff --git a/crates/ironclaw_reborn_cli/Cargo.toml b/crates/ironclaw_reborn_cli/Cargo.toml index d41b1032ec..1c23a6cfa6 100644 --- a/crates/ironclaw_reborn_cli/Cargo.toml +++ b/crates/ironclaw_reborn_cli/Cargo.toml @@ -32,6 +32,13 @@ eula = false memory-mem0 = [ "ironclaw_reborn_composition/memory-mem0", ] +# Feature bar 4 (dev-only seam): compile the loopback-only OAuth provider +# endpoint constructor used by hermetic standalone E2E tests. Activation also +# requires a debug build plus paired endpoint env vars; release builds fail +# closed if the vars are present. +test-support = [ + "ironclaw_webui/test-support", +] [dependencies] anyhow = "1" async-trait = { version = "0.1" } diff --git a/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs b/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs index 5533892154..0990cbcd3b 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs @@ -26,6 +26,8 @@ use ironclaw_webui::{ use secrecy::SecretString; const WEBUI_BASE_URL_ENV: &str = "IRONCLAW_REBORN_WEBUI_BASE_URL"; +const TEST_GOOGLE_AUTH_ENDPOINT_ENV: &str = "IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_AUTH_ENDPOINT"; +const TEST_GOOGLE_TOKEN_ENDPOINT_ENV: &str = "IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_TOKEN_ENDPOINT"; /// Resolved SSO startup config: the providers to mount plus the public /// base URL their callback URLs are built from. Constructed by @@ -195,6 +197,7 @@ pub(crate) fn is_cleartext_http_scheme(base_url: &str) -> bool { /// different registered redirect URIs.) fn oauth_providers_from_env() -> anyhow::Result>> { let mut providers: Vec> = Vec::new(); + let google_test_endpoints = google_test_endpoints_from_env()?; // Optional operator override for the provider HTTP timeout, applied to // every configured provider. Useful on a slow / cross-border path to // the provider (e.g. `github.com`) where the default times out. @@ -234,14 +237,33 @@ fn oauth_providers_from_env() -> anyhow::Result>> { // authorization succeeds almost always means the secret does not // match this client id. log_provider_config("google", &client_id, client_secret.len()); - let provider = GoogleProvider::new(GoogleOAuthConfig { + let config = GoogleOAuthConfig { client_id, client_secret: SecretString::from(client_secret), allowed_hd, http_timeout, - }) - .context("failed to build Google OAuth provider")?; + }; + #[cfg(feature = "test-support")] + let test_config = config.clone(); + let provider = GoogleProvider::new(config); + #[cfg(feature = "test-support")] + let provider = if let Some((auth_endpoint, token_endpoint)) = google_test_endpoints.as_ref() + { + GoogleProvider::with_endpoints( + test_config, + auth_endpoint.clone(), + token_endpoint.clone(), + ) + } else { + provider + }; + let provider = provider.context("failed to build Google OAuth provider")?; providers.push(Arc::new(provider)); + } else if google_test_endpoints.is_some() { + anyhow::bail!( + "{TEST_GOOGLE_AUTH_ENDPOINT_ENV} and {TEST_GOOGLE_TOKEN_ENDPOINT_ENV} require \ + IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID" + ); } if let Some(client_id) = non_empty_env("IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_ID") { @@ -265,6 +287,73 @@ fn oauth_providers_from_env() -> anyhow::Result>> { Ok(providers) } +/// Resolve the paired, loopback-only Google endpoint override used by the +/// standalone-binary E2E harness. +/// +/// This is deliberately stricter than an ordinary provider URL setting: +/// either both endpoints are absent (the production default), or both must be +/// present in a `test-support` debug build and point at literal loopback +/// IP addresses over HTTP. A partial or production activation fails startup +/// rather than falling through to a real provider mid-test. +fn google_test_endpoints_from_env() -> anyhow::Result> { + let auth_endpoint = non_empty_env(TEST_GOOGLE_AUTH_ENDPOINT_ENV); + let token_endpoint = non_empty_env(TEST_GOOGLE_TOKEN_ENDPOINT_ENV); + + let (auth_endpoint, token_endpoint) = match (auth_endpoint, token_endpoint) { + (None, None) => return Ok(None), + (Some(auth_endpoint), Some(token_endpoint)) => (auth_endpoint, token_endpoint), + _ => { + anyhow::bail!( + "{TEST_GOOGLE_AUTH_ENDPOINT_ENV} and {TEST_GOOGLE_TOKEN_ENDPOINT_ENV} \ + must be set together" + ) + } + }; + + if !cfg!(feature = "test-support") { + anyhow::bail!( + "{TEST_GOOGLE_AUTH_ENDPOINT_ENV} is test-only and requires the \ + `test-support` feature" + ); + } + if !cfg!(debug_assertions) { + anyhow::bail!( + "{TEST_GOOGLE_AUTH_ENDPOINT_ENV} is test-only and unavailable in release builds" + ); + } + + validate_test_google_endpoint(TEST_GOOGLE_AUTH_ENDPOINT_ENV, &auth_endpoint)?; + validate_test_google_endpoint(TEST_GOOGLE_TOKEN_ENDPOINT_ENV, &token_endpoint)?; + + tracing::warn!( + auth_endpoint = %auth_endpoint, + token_endpoint = %token_endpoint, + "test-only WebUI Google OAuth endpoints are ACTIVE" + ); + Ok(Some((auth_endpoint, token_endpoint))) +} + +fn validate_test_google_endpoint(name: &str, raw: &str) -> anyhow::Result<()> { + let endpoint = + reqwest::Url::parse(raw).with_context(|| format!("{name} must be a valid URL"))?; + if endpoint.scheme() != "http" { + anyhow::bail!("{name} must use http:// for the local E2E provider"); + } + if !endpoint.username().is_empty() || endpoint.password().is_some() { + anyhow::bail!("{name} must not contain URL credentials"); + } + let host = endpoint + .host_str() + .ok_or_else(|| anyhow!("{name} must include a loopback IP host"))?; + let ip = host + .parse::() + .with_context(|| format!("{name} host must be a loopback IP literal"))?; + if !ip.is_loopback() { + anyhow::bail!("{name} host must be a loopback IP literal"); + } + Ok(()) +} + /// Log a redacted view of a configured OAuth provider at startup. The /// secret value is never logged — only its length — so a misconfigured /// (empty / truncated / wrong) secret is diagnosable from boot logs @@ -430,6 +519,130 @@ mod tests { assert!(require_admission_allowlist(&["example.com".to_string()]).is_ok()); } + #[test] + fn test_google_endpoint_overrides_must_be_paired() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + clear_sso_env(); + // SAFETY: the shared process-env lock serializes this mutation. + unsafe { + std::env::set_var( + TEST_GOOGLE_AUTH_ENDPOINT_ENV, + "http://127.0.0.1:1234/authorize", + ) + }; + + let error = google_test_endpoints_from_env() + .expect_err("a partial endpoint override must fail closed"); + assert!( + error.to_string().contains("must be set together"), + "unexpected error: {error}" + ); + clear_sso_env(); + } + + #[test] + fn test_google_endpoints_require_loopback_ip_literals() { + let cases = [ + ("https://127.0.0.1:1234/authorize", "must use http://"), + ("http://localhost:1234/authorize", "loopback IP literal"), + ("http://192.0.2.1:1234/authorize", "loopback IP literal"), + ("http://user@127.0.0.1:1234/authorize", "URL credentials"), + ]; + for (raw, expected) in cases { + let error = validate_test_google_endpoint(TEST_GOOGLE_AUTH_ENDPOINT_ENV, raw) + .expect_err("unsafe test endpoint must be rejected"); + assert!( + error.to_string().contains(expected), + "{raw}: expected `{expected}` in `{error}`" + ); + } + validate_test_google_endpoint( + TEST_GOOGLE_AUTH_ENDPOINT_ENV, + "http://127.0.0.1:1234/authorize", + ) + .expect("loopback HTTP endpoint"); + } + + #[cfg(not(feature = "test-support"))] + #[test] + fn test_google_endpoints_require_explicit_cargo_feature() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + clear_sso_env(); + // SAFETY: the shared process-env lock serializes these mutations. + unsafe { + std::env::set_var( + TEST_GOOGLE_AUTH_ENDPOINT_ENV, + "http://127.0.0.1:1234/authorize", + ); + std::env::set_var( + TEST_GOOGLE_TOKEN_ENDPOINT_ENV, + "http://127.0.0.1:1234/token", + ); + } + + let error = + google_test_endpoints_from_env().expect_err("default builds must reject the test seam"); + assert!( + error.to_string().contains("test-support"), + "unexpected error: {error}" + ); + clear_sso_env(); + } + + #[cfg(feature = "test-support")] + #[test] + fn test_google_endpoints_resolve_in_feature_enabled_debug_build() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + clear_sso_env(); + // SAFETY: the shared process-env lock serializes these mutations. + unsafe { + std::env::set_var( + TEST_GOOGLE_AUTH_ENDPOINT_ENV, + "http://127.0.0.1:1234/authorize", + ); + std::env::set_var( + TEST_GOOGLE_TOKEN_ENDPOINT_ENV, + "http://127.0.0.1:1234/token", + ); + } + + let endpoints = google_test_endpoints_from_env() + .expect("feature-enabled debug build accepts loopback endpoints") + .expect("paired endpoints"); + assert_eq!(endpoints.0, "http://127.0.0.1:1234/authorize"); + assert_eq!(endpoints.1, "http://127.0.0.1:1234/token"); + clear_sso_env(); + } + + #[cfg(feature = "test-support")] + #[test] + fn test_google_endpoints_without_client_id_fail_through_startup_caller() { + let _guard = crate::runtime::test_env::lock_runtime_env(); + clear_sso_env(); + // SAFETY: the shared process-env lock serializes these mutations. + unsafe { + std::env::set_var( + TEST_GOOGLE_AUTH_ENDPOINT_ENV, + "http://127.0.0.1:1234/authorize", + ); + std::env::set_var( + TEST_GOOGLE_TOKEN_ENDPOINT_ENV, + "http://127.0.0.1:1234/token", + ); + } + + let Err(error) = sso_startup_config_from_env(addr("127.0.0.1:3000")) else { + panic!("test endpoints without a Google client id must abort startup"); + }; + assert!( + error + .to_string() + .contains("IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID"), + "unexpected error: {error}" + ); + clear_sso_env(); + } + const SSO_ENV_VARS: &[&str] = &[ "IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID", "IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_SECRET", @@ -438,6 +651,8 @@ mod tests { "IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_SECRET", WEBUI_BASE_URL_ENV, "IRONCLAW_REBORN_WEBUI_ALLOWED_EMAIL_DOMAINS", + TEST_GOOGLE_AUTH_ENDPOINT_ENV, + TEST_GOOGLE_TOKEN_ENDPOINT_ENV, ]; fn clear_sso_env() { diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index e17df65723..d81970f8e5 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -115,6 +115,7 @@ from `tests/e2e/` for the full, current set. | File | What it tests | |------|--------------| | `test_reborn_webui_v2_smoke.py` | Canonical v2 smoke: serve boots, SPA renders authed shell, bearer auth + `?token=` shim scope, text turn persists/streams, thread list/delete, timeline pagination, composer-while-running, approval-gate send block, **new-chat-while-a-run-is-active (the #5256 `submitBusyRef` deadlock regression)** | +| `test_reborn_webui_v2_sso.py` | Google-shaped SSO login through a local mock OIDC provider, one-time ticket exchange, two-user thread/timeline isolation, and logout revocation against the standalone `ironclaw serve` binary | | `test_reborn_webui_v2_tool_gates.py` | Served capability smoke: tool-result persistence and final reply, in-flight cancellation, approval approve/decline outcomes, and manual-token auth-gate resume with SSE/artifact redaction | | `test_reborn_gateway_smoke.py` | Legacy `ironclaw` web channel (`/api/chat/*`) under `ENGINE_V2` — NOT the reborn binary | | `test_reborn_v2_file_download.py` | Agent-produced workspace files are downloadable from the v2 UI | @@ -171,6 +172,7 @@ All fixtures are defined in `tests/e2e/conftest.py`. Running `pytest scenarios/` | `ironclaw_binary` | Legacy gateway binary. Checks `target/debug/ironclaw`; if absent, runs `cargo build -p ironclaw` (timeout 600s). | | `ironclaw_reborn_binary` | Reborn v2 binary. Builds `target/debug/ironclaw` with default features when stale/missing. Used by the v2 SPA and full-path fixture scenarios. | | `reborn_v2_server` | Starts `ironclaw serve` (v2 SPA at `/`, `local-dev` profile) against `mock_llm_server`; config written via `_write_config_toml` (selects the `openai` provider pointed at the mock). Waits for `/api/health`; SIGINT teardown. (Module-scoped, defined in `test_reborn_webui_v2_smoke.py`.) | +| `reborn_v2_sso_server` | Starts the same standalone binary with the guarded debug-only Google endpoint seam pointed at `mock_oauth_idp`; queues Alice and Bob OIDC profiles for full SSO and scope-isolation coverage. (Module-scoped, defined in `reborn_webui_harness.py`.) | | `reborn_v2_browser` | Chromium instance for the v2 scenarios, independent of the legacy `browser` fixture (generous launch timeout + retry). | | `mock_llm_server` | Starts `mock_llm.py --port 0`, reads the assigned port from stdout, waits for `/v1/models` to return 200. Yields the base URL. Serves canned responses including delayed ones (e.g. `"editable composer slow response"` → ~5s) so tests can act while a run is in flight. | | `emulate_google_server` | Starts the Emulate CLI selected by `IRONCLAW_EMULATE_CLI`, or the `emulate@0.7.0` fallback, with `fixtures/emulate/google_gmail.yaml`; waits for the Gmail messages endpoint; and yields the base URL for HTTP rewrite maps. The pinned CI fork covers Gmail, Calendar, Drive, Docs, Sheets, and Slides. Local runs skip if neither the selected CLI nor `npx` is available; CI fails. | diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 29140d41ff..2c8066fbdd 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -59,6 +59,7 @@ Then Playwright drives a headless Chromium browser against the gateway, making D | `test_html_injection.py` | HTML injection security | | `test_extensions.py` | Extensions tab: install, remove, configure, OAuth, auth card, activate | | `test_oauth_refresh.py` | Hosted Gmail/MCP OAuth refresh; the Gmail path refreshes through the proxy and reads seeded Gmail data from Emulate | +| `test_reborn_webui_v2_sso.py` | Standalone Reborn SSO login, ticket exchange, logout revocation, and two-user thread/timeline isolation through a local mock OIDC provider | | `test_emulate_reborn_provider_contracts.py` | Emulate provider contracts for Reborn-backed Google Gmail/Calendar/Drive reads, writes, missing resources, and account isolation; Slack QA 9/10 channel/thread/DM routing, strict-scope failures, profiles, mentions, and identity shapes; and GitHub identity, negative-result, repo/issue/PR/search/branch/git-object/release/fork/action-route surfaces | | `test_provider_fault_proxy.py` | Self-tests the transparent provider fault proxy, reusable status/transport/response profiles, safe request ledger, reset behavior, and commit-then-disconnect semantics | | `test_reborn_emulate_full_path.py` | Full-path IronClaw + Emulate coverage: install/auth extensions, drive scripted Gmail/Calendar/Drive/GitHub/Slack calls, assert provider-side state, and exercise GitHub→Slack, Calendar+Drive→Slack, Gmail→Slack, and Slack→Drive→Slack dispatch | diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 57b0f201a4..ae25cc3dac 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -500,6 +500,32 @@ def ironclaw_reborn_binary(): return str(binary) +@pytest.fixture(scope="session") +def ironclaw_reborn_sso_binary(): + """Build the debug-only Reborn binary variant used by the SSO mock.""" + target_dir = _cargo_target_dir() / "e2e-sso" + binary = target_dir / "debug" / "ironclaw" + if _binary_needs_rebuild(binary): + print("Building Reborn ironclaw with test support (this may take a while)...") + subprocess.run( + [ + "cargo", "build", + "-p", "ironclaw", + "--bin", "ironclaw", + "--features", "test-support", + "--target-dir", str(target_dir), + ], + cwd=ROOT, + check=True, + timeout=600, + ) + assert binary.exists(), ( + f"Binary not found at {binary}. " + f"Cargo target dir resolved to: {target_dir}" + ) + return str(binary) + + @pytest.fixture(scope="session") def ironclaw_reborn_openai_compat_binary(): """Ensure Reborn `ironclaw` is built for the OpenAI-compatible scenarios. diff --git a/tests/e2e/fixtures/mock_oauth_idp.py b/tests/e2e/fixtures/mock_oauth_idp.py index cf3fd1f51b..f3497118ab 100644 --- a/tests/e2e/fixtures/mock_oauth_idp.py +++ b/tests/e2e/fixtures/mock_oauth_idp.py @@ -1,8 +1,10 @@ -"""Reusable mock OAuth 2.0 authorization server with PKCE support. +"""Reusable mock OAuth 2.0 / OIDC authorization server with PKCE support. Implements the minimum surface needed for Reborn product-auth E2E tests: - GET /authorize — redirects to callback URL with ?code=&state= - POST /token — issues a fake access_token + refresh_token + - optional queued OIDC profiles — adds a Google-shaped ``id_token`` so + WebUI SSO tests can log in distinct users through the real provider path Security assertions this fixture supports: - PKCE S256 challenge round-trip (can be toggled off for negative tests) @@ -33,7 +35,10 @@ from __future__ import annotations import hashlib import base64 +import json import secrets +import time +from collections import deque from dataclasses import dataclass, field from typing import AsyncIterator from urllib.parse import parse_qs, urlparse @@ -62,6 +67,16 @@ class MockOAuthCodeGrant: state: str +@dataclass(frozen=True) +class MockOidcProfile: + """One identity returned by the next authorization-code exchange.""" + + subject: str + email: str + display_name: str + hosted_domain: str | None = None + + @dataclass class MockOAuthIdpHandle: base_url: str @@ -70,6 +85,8 @@ class MockOAuthIdpHandle: # Maps refresh_token → client_id for RFC 6749 §10.4 binding validation. issued_refresh_tokens: dict[str, str] = field(default_factory=dict) _pending_codes: dict[str, dict] = field(default_factory=dict) + _initial_oidc_profiles: tuple[MockOidcProfile, ...] = field(default_factory=tuple) + _oidc_profiles: deque[MockOidcProfile] = field(default_factory=deque) @property def authorize_url(self) -> str: @@ -84,6 +101,7 @@ class MockOAuthIdpHandle: self.issued_tokens.clear() self.issued_refresh_tokens.clear() self._pending_codes.clear() + self._oidc_profiles = deque(self._initial_oidc_profiles) def make_authorization_url( self, @@ -110,6 +128,36 @@ class MockOAuthIdpHandle: return f"{self.authorize_url}?{urlencode(params)}" +def _google_id_token(profile: MockOidcProfile, client_id: str) -> str: + """Build a non-secret JWT-shaped ID token for insecure test decoding. + + The production Google provider validates the claims and algorithm after + receiving the token over the configured token endpoint, but deliberately + does not verify this response token's signature. The mock still includes a + non-empty signature segment so the fixture has a valid JWT wire shape. + """ + + def encode_json(value: dict[str, object]) -> str: + raw = json.dumps(value, separators=(",", ":"), sort_keys=True).encode() + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + claims: dict[str, object] = { + "sub": profile.subject, + "aud": client_id, + "iss": "https://accounts.google.com", + "email": profile.email, + "email_verified": True, + "name": profile.display_name, + "exp": int(time.time()) + 3600, + } + if profile.hosted_domain: + claims["hd"] = profile.hosted_domain + header = encode_json({"alg": "RS256", "typ": "JWT"}) + payload = encode_json(claims) + signature = base64.urlsafe_b64encode(b"mock-oidc-signature").rstrip(b"=").decode() + return f"{header}.{payload}.{signature}" + + async def issue_oauth_code( handle: MockOAuthIdpHandle, *, @@ -145,9 +193,17 @@ async def issue_oauth_code( ) -async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHandle]: +async def start_mock_oauth_idp( + *, + port: int = 0, + oidc_profiles: tuple[MockOidcProfile, ...] = (), +) -> AsyncIterator[MockOAuthIdpHandle]: """Context manager that starts the mock IDP and yields a handle.""" - handle = MockOAuthIdpHandle(base_url="") # filled after bind + handle = MockOAuthIdpHandle( + base_url="", + _initial_oidc_profiles=oidc_profiles, + _oidc_profiles=deque(oidc_profiles), + ) # base_url filled after bind async def authorize(request: web.Request) -> web.Response: """Simulate the IdP authorization endpoint. @@ -160,15 +216,27 @@ async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHa state = qs.get("state", "") code_challenge = qs.get("code_challenge") code_challenge_method = qs.get("code_challenge_method", "S256") + client_id = qs.get("client_id", "") - if not redirect_uri or not state: - return web.Response(status=400, text="missing redirect_uri or state") + if not redirect_uri or not state or not client_id: + return web.Response( + status=400, + text="missing client_id, redirect_uri, or state", + ) + + oidc_profile = None + if handle._initial_oidc_profiles: + if not handle._oidc_profiles: + return web.Response(status=409, text="no mock OIDC profiles remain") + oidc_profile = handle._oidc_profiles.popleft() code = f"fake_code_{secrets.token_urlsafe(12)}" handle._pending_codes[code] = { + "client_id": client_id, "redirect_uri": redirect_uri, "code_challenge": code_challenge, "code_challenge_method": code_challenge_method, + "oidc_profile": oidc_profile, } from urllib.parse import urlencode @@ -194,6 +262,16 @@ async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHa {"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, status=400, ) + submitted_client_id = body.get("client_id", "") + if submitted_client_id and submitted_client_id != pending["client_id"]: + return web.json_response( + {"error": "invalid_grant", "error_description": "client_id mismatch"}, + status=400, + ) + # Some product-auth fixtures model a public client and omit + # client_id at exchange. Retain the authorization request's + # binding in that case; reject only an explicitly different id. + client_id = submitted_client_id or pending["client_id"] # PKCE S256: verifier required when challenge was registered. expected_challenge = pending.get("code_challenge") @@ -213,16 +291,19 @@ async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHa handle.received_codes.append(code) access_token = f"fake_access_{secrets.token_urlsafe(16)}" refresh_token = f"fake_refresh_{secrets.token_urlsafe(16)}" - client_id = body.get("client_id", "") handle.issued_tokens.append(access_token) handle.issued_refresh_tokens[refresh_token] = client_id - return web.json_response({ + response = { "access_token": access_token, "refresh_token": refresh_token, "token_type": "Bearer", "expires_in": 3600, "scope": "openid email", - }) + } + oidc_profile = pending.get("oidc_profile") + if oidc_profile is not None: + response["id_token"] = _google_id_token(oidc_profile, client_id) + return web.json_response(response) if grant_type == "refresh_token": refresh_token = body.get("refresh_token", "") diff --git a/tests/e2e/reborn_coverage_tests.txt b/tests/e2e/reborn_coverage_tests.txt index dd9b3f8e62..2338e59d3d 100644 --- a/tests/e2e/reborn_coverage_tests.txt +++ b/tests/e2e/reborn_coverage_tests.txt @@ -14,6 +14,7 @@ # provider-fixture or model-tool-choice coverage that should stay in the normal # E2E suite but not in this Reborn binary coverage gate. tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +tests/e2e/scenarios/test_reborn_webui_v2_sso.py tests/e2e/scenarios/test_reborn_webui_v2_tool_gates.py tests/e2e/scenarios/test_reborn_webui_v2_streaming_run_control_api.py::test_reborn_v2_sse_reconnect_resumes_without_gap_or_duplicate_served tests/e2e/scenarios/test_reborn_webui_v2_streaming_run_control_api.py::test_reborn_v2_websocket_origin_projection_and_shared_capacity_served diff --git a/tests/e2e/reborn_webui_harness.py b/tests/e2e/reborn_webui_harness.py index cd46bed89b..dc6d091d92 100644 --- a/tests/e2e/reborn_webui_harness.py +++ b/tests/e2e/reborn_webui_harness.py @@ -21,6 +21,7 @@ import httpx import pytest from playwright.async_api import Error as PlaywrightError +from fixtures.mock_oauth_idp import MockOidcProfile, start_mock_oauth_idp from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2, wait_for_ready USER_ID = "reborn-v2-e2e-user" @@ -29,6 +30,7 @@ YOLO_PROFILE = "local-dev-yolo" DEFAULT_MODEL = "mock-model" VISION_MODEL = "gpt-4o" ACCEPTED_SEND_OUTCOMES = {"submitted", "already_submitted"} +SSO_GOOGLE_CLIENT_ID = "reborn-v2-e2e-google-client" DEFAULT_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024 MAX_SERVER_LOG_BYTES = 16 * 1024 * 1024 _ARTIFACT_PENDING_SENTINEL = ".pytest-outcome-pending" @@ -440,6 +442,7 @@ async def start_reborn_webui_v2_server( model: str = DEFAULT_MODEL, log_prefix: str = "reborn-v2", extra_env: dict[str, str] | None = None, + use_listener_as_webui_base_url: bool = False, ) -> tuple[object, str]: """Start ``ironclaw serve`` and return ``(process, base_url)``.""" configured_artifact_root = os.environ.get( @@ -472,6 +475,7 @@ async def start_reborn_webui_v2_server( for attempt in range(1, 4): port = find_free_port() last_port = port + base_url = f"http://127.0.0.1:{port}" if artifact_root: log_dir = artifact_root / "server-logs" / home_dir.name log_dir.mkdir(parents=True, exist_ok=True) @@ -495,6 +499,8 @@ async def start_reborn_webui_v2_server( } if extra_env: env.update(extra_env) + if use_listener_as_webui_base_url: + env["IRONCLAW_REBORN_WEBUI_BASE_URL"] = base_url forward_coverage_env(env) args = [ @@ -550,8 +556,6 @@ async def start_reborn_webui_v2_server( env=env, cwd=workspace_dir, ) - base_url = f"http://127.0.0.1:{port}" - try: await wait_for_ready(f"{base_url}/api/health", timeout=60) return proc, base_url @@ -618,6 +622,52 @@ async def reborn_v2_server(ironclaw_reborn_binary, mock_llm_server, tmp_path_fac await close_reborn_server(proc) +@pytest.fixture(scope="module") +async def reborn_v2_sso_server( + ironclaw_reborn_sso_binary, mock_llm_server, tmp_path_factory +): + """Start ``ironclaw serve`` with Google SSO backed by a local mock IDP.""" + profiles = ( + MockOidcProfile( + subject="alice-subject", + email="alice@example.com", + display_name="Alice E2E", + ), + MockOidcProfile( + subject="bob-subject", + email="bob@example.com", + display_name="Bob E2E", + ), + ) + async for provider in start_mock_oauth_idp(oidc_profiles=profiles): + home_dir = tmp_path_factory.mktemp("ironclaw-reborn-v2-sso-home") + proc, base_url = await start_reborn_webui_v2_server( + ironclaw_reborn_binary=ironclaw_reborn_sso_binary, + mock_llm_server=mock_llm_server, + home_dir=home_dir, + profile=DEFAULT_PROFILE, + log_prefix="reborn-v2-sso", + use_listener_as_webui_base_url=True, + extra_env={ + "IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID": SSO_GOOGLE_CLIENT_ID, + "IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_SECRET": "mock-google-secret", + "IRONCLAW_REBORN_WEBUI_ALLOWED_EMAIL_DOMAINS": "example.com", + # Defeat ambient repo/user .env values so this fixture never + # advertises or contacts a provider it did not start itself. + "IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_ID": "", + "IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_SECRET": "", + "IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_AUTH_ENDPOINT": ( + provider.authorize_url + ), + "IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_TOKEN_ENDPOINT": provider.token_url, + }, + ) + try: + yield {"base_url": base_url, "provider": provider} + finally: + await close_reborn_server(proc) + + @pytest.fixture(scope="module") async def reborn_v2_yolo_server(ironclaw_reborn_binary, mock_llm_server, tmp_path_factory): """Start ``ironclaw serve`` with auto-approval local-dev-yolo profile.""" diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_sso.py b/tests/e2e/scenarios/test_reborn_webui_v2_sso.py new file mode 100644 index 0000000000..8393e34367 --- /dev/null +++ b/tests/e2e/scenarios/test_reborn_webui_v2_sso.py @@ -0,0 +1,203 @@ +"""Standalone Reborn WebUI v2 SSO and multi-user isolation smoke (#4636). + +This scenario starts the shipping ``ironclaw serve`` process in session-auth +mode, drives the full Google-shaped OAuth redirect/callback/ticket exchange +against a local mock provider, and proves two admitted users retain distinct +thread and timeline scopes in one tenant. +""" + +import json +from urllib.parse import parse_qs, urljoin, urlparse + +import httpx + +from helpers import sse_stream, wait_for_sse_line +from reborn_webui_harness import create_thread, reborn_bearer_headers + +pytest_plugins = ["reborn_webui_harness"] + + +async def _login_with_next_mock_user(client: httpx.AsyncClient, base_url: str) -> str: + login = await client.get( + f"{base_url}/auth/login/google", + params={"redirect_after": "/"}, + timeout=15, + ) + assert login.status_code == 307, login.text + + authorize = await client.get( + urljoin(base_url, login.headers["location"]), + timeout=15, + ) + assert authorize.status_code == 302, authorize.text + + callback = await client.get( + urljoin(base_url, authorize.headers["location"]), + timeout=15, + ) + assert callback.status_code == 303, callback.text + landing = urljoin(base_url, callback.headers["location"]) + ticket = parse_qs(urlparse(landing).query).get("login_ticket", [""])[0] + assert ticket, callback.headers["location"] + + exchange = await client.post( + f"{base_url}/auth/session/exchange", + json={"ticket": ticket}, + timeout=15, + ) + exchange.raise_for_status() + token = exchange.json()["token"] + assert token + return token + + +async def _session(base_url: str, token: str) -> httpx.Response: + async with httpx.AsyncClient( + headers=reborn_bearer_headers(token), + trust_env=False, + ) as client: + return await client.get( + f"{base_url}/api/webchat/v2/session", + timeout=15, + ) + + +async def _assert_cross_user_stream_denied( + base_url: str, + thread_id: str, + token: str, +) -> None: + # EventSource requires a successful HTTP open. Authorization failures are + # therefore delivered as one redacted stream_error frame before close. + async with sse_stream( + base_url, + path=f"/api/webchat/v2/threads/{thread_id}/events", + token=token, + timeout=15, + ) as response: + assert response.status == 200 + assert response.headers["content-type"].startswith("text/event-stream") + event_line = await wait_for_sse_line( + response, + predicate=lambda line: line.startswith("event:"), + timeout=10, + ) + data_line = await wait_for_sse_line( + response, + predicate=lambda line: line.startswith("data:"), + timeout=10, + ) + + assert event_line.partition(":")[2].strip() == "stream_error" + assert json.loads(data_line.partition(":")[2].strip()) == { + "error": "not_found", + "kind": "not_found", + "retryable": False, + } + + +async def test_reborn_v2_sso_login_logout_and_multi_user_scope_isolation( + reborn_v2_sso_server, +): + base_url = reborn_v2_sso_server["base_url"] + provider = reborn_v2_sso_server["provider"] + + async with httpx.AsyncClient(follow_redirects=False, trust_env=False) as public: + providers = await public.get(f"{base_url}/auth/providers", timeout=15) + providers.raise_for_status() + assert providers.json() == {"providers": ["google"]} + + alice_token = await _login_with_next_mock_user(public, base_url) + bob_token = await _login_with_next_mock_user(public, base_url) + + assert alice_token != bob_token + assert len(provider.received_codes) == 2 + + alice_session = await _session(base_url, alice_token) + bob_session = await _session(base_url, bob_token) + alice_session.raise_for_status() + bob_session.raise_for_status() + alice_identity = alice_session.json() + bob_identity = bob_session.json() + assert alice_identity["tenant_id"] == bob_identity["tenant_id"] == "reborn-v2-e2e" + assert alice_identity["user_id"] != bob_identity["user_id"] + assert alice_identity["capabilities"]["operator_webui_config"] is False + assert bob_identity["capabilities"]["operator_webui_config"] is False + + async with ( + httpx.AsyncClient( + headers=reborn_bearer_headers(alice_token), + trust_env=False, + ) as alice, + httpx.AsyncClient( + headers=reborn_bearer_headers(bob_token), + trust_env=False, + ) as bob, + ): + alice_thread = await create_thread(alice, base_url) + bob_thread = await create_thread(bob, base_url) + assert alice_thread != bob_thread + + alice_threads = await alice.get( + f"{base_url}/api/webchat/v2/threads", + timeout=15, + ) + bob_threads = await bob.get( + f"{base_url}/api/webchat/v2/threads", + timeout=15, + ) + alice_threads.raise_for_status() + bob_threads.raise_for_status() + alice_thread_ids = { + thread["thread_id"] for thread in alice_threads.json()["threads"] + } + bob_thread_ids = { + thread["thread_id"] for thread in bob_threads.json()["threads"] + } + assert alice_thread in alice_thread_ids + assert bob_thread not in alice_thread_ids + assert bob_thread in bob_thread_ids + assert alice_thread not in bob_thread_ids + + alice_own_timeline = await alice.get( + f"{base_url}/api/webchat/v2/threads/{alice_thread}/timeline", + timeout=15, + ) + bob_own_timeline = await bob.get( + f"{base_url}/api/webchat/v2/threads/{bob_thread}/timeline", + timeout=15, + ) + alice_own_timeline.raise_for_status() + bob_own_timeline.raise_for_status() + assert alice_own_timeline.json()["messages"] == [] + assert bob_own_timeline.json()["messages"] == [] + + alice_reads_bob = await alice.get( + f"{base_url}/api/webchat/v2/threads/{bob_thread}/timeline", + timeout=15, + ) + bob_reads_alice = await bob.get( + f"{base_url}/api/webchat/v2/threads/{alice_thread}/timeline", + timeout=15, + ) + assert alice_reads_bob.status_code == 404 + assert bob_reads_alice.status_code == 404 + + await _assert_cross_user_stream_denied( + base_url, + bob_thread, + alice_token, + ) + await _assert_cross_user_stream_denied( + base_url, + alice_thread, + bob_token, + ) + + logout = await alice.post(f"{base_url}/auth/logout", timeout=15) + assert logout.status_code == 204 + + assert (await _session(base_url, alice_token)).status_code == 401 + bob_still_authenticated = await _session(base_url, bob_token) + bob_still_authenticated.raise_for_status() + assert bob_still_authenticated.json()["user_id"] == bob_identity["user_id"]