mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
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
This commit is contained in:
11
.github/workflows/coverage.yml
vendored
11
.github/workflows/coverage.yml
vendored
@@ -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
|
||||
|
||||
17
.github/workflows/reborn-e2e.yml
vendored
17
.github/workflows/reborn-e2e.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<Vec<Arc<dyn OAuthProvider>>> {
|
||||
let mut providers: Vec<Arc<dyn OAuthProvider>> = 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<Vec<Arc<dyn OAuthProvider>>> {
|
||||
// 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<Vec<Arc<dyn OAuthProvider>>> {
|
||||
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<Option<(String, String)>> {
|
||||
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::<std::net::IpAddr>()
|
||||
.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() {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
203
tests/e2e/scenarios/test_reborn_webui_v2_sso.py
Normal file
203
tests/e2e/scenarios/test_reborn_webui_v2_sso.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user