From 8fdbfa81ad2e96764485c975f4783307fb7a208c Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:00:30 -0800 Subject: [PATCH] Let the owner sign out all browsers at once A browser session is a signed cookie with no server-side list, valid for the configured lifetime (seven days by default). There was no way to end a session early, for example after a phone is lost. That is needed before a Windows host can accept a browser on another device. `amplifier-unified browsers sign-out` saves a time in the owner's private configuration. It sends nothing over the network and uses no credential: the ability to write that private folder is the authority, so it also works while the host is stopped. A running host notices the record within about two seconds. It then refuses every session from before that time, drops pending sign-in tickets, and ends open state streams. All browser session checks now go through one function, `auth.valid_session`: the middleware and the two handlers that verified the cookie themselves. The saved time never moves backward, a failed save is an error, and a damaged record signs everyone out once and is repaired. --- .github/workflows/platform-mechanisms.yml | 6 + amplifier_web/auth.py | 145 ++++++++++++- amplifier_web/browser_entry.py | 5 + amplifier_web/cli.py | 19 +- amplifier_web/device_connections.py | 4 +- amplifier_web/device_pairing.py | 4 +- amplifier_web/server.py | 9 + docs/DEPLOYMENT.md | 24 +++ docs/design/native-windows-installation.md | 4 + tests/test_server_auth.py | 35 ++++ tests/test_session_sign_out.py | 232 +++++++++++++++++++++ 11 files changed, 474 insertions(+), 13 deletions(-) create mode 100644 tests/test_session_sign_out.py diff --git a/.github/workflows/platform-mechanisms.yml b/.github/workflows/platform-mechanisms.yml index 0e8514a5e0..9dc7465999 100644 --- a/.github/workflows/platform-mechanisms.yml +++ b/.github/workflows/platform-mechanisms.yml @@ -89,6 +89,7 @@ on: - tests/test_update_checks.py - tests/test_windows_tls.py - tests/test_windows_trust.py + - tests/test_session_sign_out.py - tests/test_windows_service.py - tests/test_windows_task_runtime.py - tests/test_windows_installation.py @@ -250,6 +251,11 @@ jobs: run: | .\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -s -p no:cacheprovider tests/test_windows_trust.py if ($LASTEXITCODE -ne 0) { throw 'Windows local-root trust checks failed.' } + - name: Check sign-out of all browsers on the real private store + shell: pwsh + run: | + .\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -p no:cacheprovider tests/test_session_sign_out.py + if ($LASTEXITCODE -ne 0) { throw 'Sign-out of all browsers checks failed.' } windows-user-trial: if: ${{ inputs.storage_only || (!inputs.bootstrap_only && !inputs.foreground_only) }} # Candidate-only combined boundary, not a universal suite/release gate. diff --git a/amplifier_web/auth.py b/amplifier_web/auth.py index 31b2571c10..30bda501ea 100644 --- a/amplifier_web/auth.py +++ b/amplifier_web/auth.py @@ -161,22 +161,155 @@ def _signed(secret: str, payload: dict) -> str: return encoded + "." + signature -def _verified(secret: str, value: str | None, ttl: int, kind: str) -> bool: +def _issued(secret: str, value: str | None, ttl: int, kind: str) -> int | None: + """Return the signed issue second of a valid, unexpired value, else None.""" if not value or "." not in value: - return False + return None encoded, signature = value.rsplit(".", 1) expected = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(signature, expected): - return False + return None try: payload = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))) issued = payload["issued"] if type(issued) is not int: - return False + return None age = time.time() - issued - return payload["kind"] == kind and 0 <= age <= ttl + return issued if payload["kind"] == kind and 0 <= age <= ttl else None except (OverflowError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return None + + +def _verified(secret: str, value: str | None, ttl: int, kind: str) -> bool: + return _issued(secret, value, ttl, kind) is not None + + +def valid_session(app, value: str | None) -> bool: + """The one check for a browser session cookie. + + A session from before the owner's last "sign out all browsers" is refused, + whatever its remaining lifetime. A session issued in the same second as + that sign-out is refused as well. + """ + issued = _issued(app["session_secret"], value, app["server_config"]["session_ttl_seconds"], "session") + return issued is not None and issued > app.get("session_epoch", 0) + + +_EPOCH = "session-epoch" + + +def _epoch_path(data_dir: Path) -> Path: + if _WINDOWS: + from . import windows_files + return windows_files.address(data_dir) / "config" / "auth" / _EPOCH + return Path(data_dir).expanduser().resolve() / "config" / "auth" / _EPOCH + + +def _read_epoch(data_dir: Path) -> int: + """Read the saved sign-out second. Missing means no sign-out yet (0).""" + try: + if _WINDOWS: + from . import windows_files + root = windows_files.address(data_dir) + with windows_files.directory(root / "config" / "auth", owned_root=root) as pinned: + content = pinned.read_private(_EPOCH, limit=32) + else: + content = _epoch_path(data_dir).read_bytes()[:33] + except FileNotFoundError: + return 0 + if not re.fullmatch(rb"[0-9]{1,12}\n?", content): + raise ValueError("The saved sign-out time is malformed.") + return int(content) + + +def _write_epoch(data_dir: Path, value: int) -> None: + if _WINDOWS: + write_private(_epoch_path(data_dir), f"{value}\n", owned_root=validated_data_dir(data_dir, create=True)) + else: + auth_dir(data_dir) + write_private(_epoch_path(data_dir), f"{value}\n") + + +def session_epoch(data_dir: Path) -> int: + """The second before which every browser session is refused. + + An unreadable or malformed record never revives a session: it is replaced + by the present moment, once, so that everyone signs in again. A missing + record means no sign-out has happened. Anyone who could delete it can also + read the session secret, so its absence is not treated as an attack. + """ + try: + return _read_epoch(data_dir) + except (OSError, ValueError): + now = int(time.time()) + try: + _write_epoch(data_dir, now) + except OSError: + pass # Still refused in memory; the next read repeats this. + return now + + +def sign_out_all(data_dir: Path) -> int: + """Sign out every browser of this installation; return the saved second. + + Authority is the owner's own private folder: no network request and no + credential are involved. A running host applies the change within a few + seconds. The time never moves backward, and a write failure is an error, + never a silent success. + """ + try: + saved = _read_epoch(data_dir) + except (OSError, ValueError): + saved = 0 + value = max(int(time.time()), saved) + _write_epoch(data_dir, value) + if _read_epoch(data_dir) != value: + raise OSError("The sign-out time was not saved.") + return value + + +def _epoch_hint(data_dir: Path): + """A cheap change hint for the saved record; never its value.""" + try: + info = os.stat(_epoch_path(data_dir)) + return info.st_mtime_ns, info.st_size + except OSError: + return None + + +def refresh_sessions(app, streams=()) -> bool: + """Apply a newer saved sign-out to the running host; True when applied. + + Pending sign-in tickets are dropped, and open state streams are ended so + that a signed-out browser cannot keep watching. A stream that reconnects + must pass the session check again. + """ + data_dir = app["service"].data_dir + hint = _epoch_hint(data_dir) + if hint == app.get("session_epoch_hint", ...): return False + app["session_epoch_hint"] = hint + value = session_epoch(data_dir) + if value <= app.get("session_epoch", 0): + return False + app["session_epoch"] = value + store = app.get("browser_entry") + if store is not None: + store.clear() + for task in list(streams): + task.cancel() + return True + + +async def watch_sessions(app, streams, *, interval=2.0): + """Notice the owner's sign-out while the host runs.""" + import asyncio + while True: + await asyncio.sleep(interval) + try: + refresh_sessions(app, streams) + except Exception: # A watcher fault must never stop the host. + continue def new_session(secret: str) -> str: @@ -329,7 +462,7 @@ async def authenticated(credential): return web.json_response({'error': 'Device access was removed.'}, status=401) return await handler(request) return web.json_response({'error': 'Device access was removed or is invalid.'}, status=401) - if _verified(request.app["session_secret"], request.cookies.get(SESSION_COOKIE), request.app["server_config"]["session_ttl_seconds"], "session"): + if valid_session(request.app, request.cookies.get(SESSION_COOKIE)): return await authenticated(request.cookies[SESSION_COOKIE]) if is_api_request(request): return web.json_response({"error": "Authentication required."}, status=401) diff --git a/amplifier_web/browser_entry.py b/amplifier_web/browser_entry.py index 9811b30b2a..58a47e04dd 100644 --- a/amplifier_web/browser_entry.py +++ b/amplifier_web/browser_entry.py @@ -157,6 +157,11 @@ def issue(self, binding: Binding, next_path="/") -> str: now + self.ttl, binding, validate_next_path(next_path)) return ticket + def clear(self) -> None: + """Drop every pending ticket: a sign-out must not be followed by an old one.""" + with self._lock: + self._grants.clear() + def consume(self, ticket: str, binding: Binding) -> str | None: if not isinstance(ticket, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", ticket): return None diff --git a/amplifier_web/cli.py b/amplifier_web/cli.py index 0682e20ae4..6197826aed 100644 --- a/amplifier_web/cli.py +++ b/amplifier_web/cli.py @@ -97,6 +97,9 @@ def _parse() -> argparse.Namespace: runtime = subcommands.add_parser("runtime", help="Prepare or inspect the default installation runtime without a conversation") runtime.add_argument("runtime_command", choices=["prepare", "status"]) runtime.add_argument("--json", action="store_true", help=argparse.SUPPRESS) + browsers = subcommands.add_parser("browsers", help="Manage signed-in browsers") + browsers.add_argument("browsers_command", choices=["sign-out"], + help="sign-out: sign out every browser on every device") setup_tls = subcommands.add_parser("setup-tls", help="Create or inspect the app-owned local CA") setup_tls.add_argument("mode", nargs="?", choices=["status", "default", "force", "export"], default="default") reset = subcommands.add_parser("reset", help="Reinstall a clean app and runtime while preserving user data") @@ -146,13 +149,13 @@ def _server_overrides(args: argparse.Namespace) -> dict: def _print_completion(shell: str) -> None: - words = "serve open tui acp run continue tool access doctor reset config service setup-tls completion --port --workspace --data-dir --no-open --bind --host --public-origin --tls-cert --tls-key --session-ttl --version" + words = "serve open tui acp run continue tool access doctor reset config service setup-tls browsers completion --port --workspace --data-dir --no-open --bind --host --public-origin --tls-cert --tls-key --session-ttl --version" if shell == "bash": print('complete -W "' + words + '" amplifier-unified') elif shell == "zsh": - print('#compdef amplifier-unified\n_arguments "1:command:(serve open tui acp run continue tool access doctor reset config service setup-tls)"') + print('#compdef amplifier-unified\n_arguments "1:command:(serve open tui acp run continue tool access doctor reset config service setup-tls browsers)"') else: - print('complete -c amplifier-unified -f -a "serve open tui acp run continue tool access doctor reset config service setup-tls"') + print('complete -c amplifier-unified -f -a "serve open tui acp run continue tool access doctor reset config service setup-tls browsers"') def _config(args, data_dir: Path) -> None: @@ -503,6 +506,16 @@ def main(): if args.command == "config": _config(args, data_dir) return + if args.command == "browsers": + from .auth import sign_out_all + try: + sign_out_all(data_dir) + except (OSError, ValueError): + raise SystemExit("Browsers were NOT signed out: the sign-out time could not be saved in " + f"{data_dir}. Nothing was changed.") from None + print("All browsers on all devices are signed out. A running Amplifier applies this within a few seconds.") + print("To use Amplifier in a browser again, open it from this computer.") + return if args.command == "doctor": from .installation_health import doctor, display result = doctor(data_dir) diff --git a/amplifier_web/device_connections.py b/amplifier_web/device_connections.py index d8a7abf8bf..a1b78760ca 100644 --- a/amplifier_web/device_connections.py +++ b/amplifier_web/device_connections.py @@ -13,7 +13,7 @@ from aiohttp import web -from .auth import SESSION_COOKIE, _verified +from .auth import SESSION_COOKIE, valid_session from .deployment import write_private from .device_capabilities import GRANTS, action, grants, transient from .terminal_devices import digest @@ -420,7 +420,7 @@ def rotate(self, identity, rotation_id): def browser_cookie(request): value = request.cookies.get(SESSION_COOKIE) - if not _verified(request.app['session_secret'], value, request.app['server_config']['session_ttl_seconds'], 'session'): + if not valid_session(request.app, value): raise web.HTTPForbidden(text='Sign in with your browser to approve connections.') return value diff --git a/amplifier_web/device_pairing.py b/amplifier_web/device_pairing.py index 7a0a4f68f2..1591cfe69e 100644 --- a/amplifier_web/device_pairing.py +++ b/amplifier_web/device_pairing.py @@ -12,7 +12,7 @@ from aiohttp import web -from .auth import SESSION_COOKIE, _verified +from .auth import SESSION_COOKIE, valid_session from .terminal_devices import MAX_DEVICES, digest from .terminal_setup import require_safe_transport @@ -160,7 +160,7 @@ def setup_routes(app): def browser_session(request): cookie = request.cookies.get(SESSION_COOKIE) - if not _verified(app['session_secret'], cookie, app['server_config']['session_ttl_seconds'], 'session'): + if not valid_session(app, cookie): raise web.HTTPForbidden(text='Sign in with your browser to manage devices.') return cookie diff --git a/amplifier_web/server.py b/amplifier_web/server.py index 22b5206db5..2a82cabc7b 100644 --- a/amplifier_web/server.py +++ b/amplifier_web/server.py @@ -108,6 +108,9 @@ async def create_app(data_dir, workspace=None, runtime=None, voice=True, backgro for host in ("localhost", "127.0.0.1", "[::1]") ) app["session_secret"] = session_secret(data_dir) + from .auth import _epoch_hint, session_epoch + app["session_epoch_hint"] = _epoch_hint(data_dir) + app["session_epoch"] = session_epoch(data_dir) app["control_token"] = control_token(data_dir) service = AppService(data_dir, runtime=runtime, workspace=workspace) service.port = config["port"] @@ -838,6 +841,10 @@ async def index(request): async def shutdown(app): service.begin_shutdown() + watch = app.get("session_watch") + if watch is not None: + watch.cancel() + await asyncio.gather(watch, return_exceptions=True) waiting = list(streams | call_waiters | action_waiters) for task in waiting: task.cancel() @@ -846,6 +853,8 @@ async def shutdown(app): app.on_shutdown.append(shutdown) async def confirm_started(app): + from .auth import watch_sessions + app["session_watch"] = asyncio.create_task(watch_sessions(app, streams)) if background_work: # Resume only durable, positively unsubmitted shared-room work. # Configuration and runtime ownership are installed before attach. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 415f8e8fca..cbd8222d85 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -233,6 +233,30 @@ References: [Ubuntu trust-store guide](https://ubuntu.com/server/docs/how-to/sec and [Edge root-store policy history](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-policies/microsoftrootstoreenabled). The obsolete Edge root-store policy is not a workaround. +## Sign out all browsers + +To end every browser session on every device at once, for example after a +phone is lost, run this on the host as the account that owns Unified: + +```sh +amplifier-unified browsers sign-out +``` + +Pass the same `--data-dir` value if Unified uses a non-default data directory. +The command saves a time in the owner's private configuration +(`config/auth/session-epoch`). It needs no running host, no password and no +network request: the ability to write that private folder is the authority. +A running host applies the change within a few seconds. It then refuses every +session from before that time, drops pending sign-in tickets, and ends open +live-update connections so that a signed-out browser cannot keep watching. +Sign in again in the usual way for your platform. + +Limits: this signs out **all** browsers; there is no sign-out for one device. +Connected terminals, apps and devices with their own access are not browser +sessions and stay connected; remove those in the app. The saved time never +moves backward. If the record is damaged, the host signs everyone out once and +repairs it. + ## Keep the host running On Linux, including a WSL distribution with systemd enabled, install and manage diff --git a/docs/design/native-windows-installation.md b/docs/design/native-windows-installation.md index a413dc8437..00c59b35b4 100644 --- a/docs/design/native-windows-installation.md +++ b/docs/design/native-windows-installation.md @@ -1474,6 +1474,10 @@ This delegates existing OS-session authority; it does not prove a fresh Hello challenge. A new native Hello challenge is a different product/security promise. Keep PAM authentication unchanged for existing POSIX deployments. +The owner can end every browser session with `amplifier-unified browsers sign-out` +(see the deployment guide). All browser session checks go through one function, +`auth.valid_session`, so that no check can ignore that sign-out. + Remote browsers need an explicit owner-approved browser-pairing design over trusted configured HTTPS. Do not silently reuse terminal/companion grants with broader authority. Until that path is qualified, report its limitation rather diff --git a/tests/test_server_auth.py b/tests/test_server_auth.py index 16bc859191..fdd69705a7 100644 --- a/tests/test_server_auth.py +++ b/tests/test_server_auth.py @@ -402,3 +402,38 @@ async def test_pwa_metadata_is_public_but_conversations_and_canvas_stay_private( login = await client.get('/login?error=1') assert login.headers['Cache-Control'] == 'no-store' assert 'Could not sign in.' in await login.text() + + +async def test_real_host_applies_sign_out_all_browsers_to_requests_and_open_streams(aiohttp_client, tmp_path, monkeypatch): + import asyncio + from amplifier_web import auth + app = await create_app(tmp_path, preload_providers=False, workspace=tmp_path, runtime=Runtime(), + voice=False, background_updates=False) + client = await aiohttp_client(app) + assert app["session_epoch"] == 0 and not app["session_watch"].done() + cookie = {"Cookie": f"amplifier_unified_session={new_session(app['session_secret'])}"} + assert (await client.get("/api/state", headers=cookie)).status == 200 + stream = await client.get("/api/events", headers=cookie) + assert stream.status == 200 + assert await stream.content.readany() # The stream is open and delivering. + # The owner's command writes the private record; it sends the host nothing. + real = time.time + monkeypatch.setattr(auth.time, "time", lambda: real() + 5) + saved = auth.sign_out_all(tmp_path) + # The running host notices by itself (its watcher looks every two seconds). + for _ in range(100): + if app["session_epoch"] == saved: + break + await asyncio.sleep(0.1) + assert app["session_epoch"] == saved + # The same cookie is refused, for the API and for a page. + assert (await client.get("/api/state", headers=cookie)).status == 401 + page = await client.get("/", headers=cookie, allow_redirects=False) + assert page.status == 307 and page.headers["Location"].startswith("/login") + # The open stream was ended by the host. + await asyncio.wait_for(stream.content.read(), 5) + assert stream.content.at_eof() + # A sign-in after the sign-out works. + monkeypatch.setattr(auth.time, "time", lambda: real() + 10) + fresh = {"Cookie": f"amplifier_unified_session={new_session(app['session_secret'])}"} + assert (await client.get("/api/state", headers=fresh)).status == 200 diff --git a/tests/test_session_sign_out.py b/tests/test_session_sign_out.py new file mode 100644 index 0000000000..1cad41a28d --- /dev/null +++ b/tests/test_session_sign_out.py @@ -0,0 +1,232 @@ +"""Sign out all browsers: one saved time that every session check honors. + +Runs on Linux and, in the focused Windows job, on the real private store. +Synchronous on purpose: that job installs no async pytest plugin. +""" +import asyncio +import os +from pathlib import Path +import re +import shutil +import ssl +import sys +from types import SimpleNamespace +from unittest.mock import Mock +import uuid + +import aiohttp +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +import pytest + +from amplifier_web import auth, browser_entry, cli + +ROOT = Path(__file__).resolve().parents[1] +SECRET = "s" * 64 + + +@pytest.fixture +def home(tmp_path_factory): + # Native Windows refuses a runner temp folder with a broad inherited ACL; + # use a private profile child there instead of repairing or skipping. + if os.name != "nt": + yield tmp_path_factory.mktemp("home") + return + from amplifier_web.windows_files import address, directory + root = address(Path.home() / (".unified-signout-test-" + uuid.uuid4().hex)) + with directory(root, owned_root=root, create=True): + pass + try: + yield root + finally: + shutil.rmtree(root) + + +def application(home, **values): + app = {"session_secret": SECRET, "server_config": {"session_ttl_seconds": 600}, + "service": SimpleNamespace(data_dir=home), "session_epoch": 0, **values} + return app + + +def session(issued): + return auth._signed(SECRET, {"kind": "session", "issued": issued, "nonce": "n"}) + + +def test_session_from_before_the_sign_out_is_refused_and_a_later_one_accepted(monkeypatch): + now = 1_900_000_000 + monkeypatch.setattr(auth.time, "time", lambda: now) + app = application(None, session_epoch=now - 10) + assert not auth.valid_session(app, session(now - 11)) + assert not auth.valid_session(app, session(now - 10)) # The same second is refused too. + assert auth.valid_session(app, session(now - 9)) + # Lifetime, signature and kind still apply. + assert not auth.valid_session(app, session(now - 601)) + assert not auth.valid_session(app, session(now - 9)[:-1] + "0") + assert not auth.valid_session(app, auth._signed(SECRET, {"kind": "csrf", "issued": now, "nonce": "n"})) + assert not auth.valid_session(app, None) + # No sign-out yet: every valid session is accepted, as before this change. + assert auth.valid_session(application(None), session(now - 500)) + + +def test_every_session_check_in_the_package_uses_the_one_shared_check(): + # A separate cookie check would silently ignore "sign out all browsers". + offenders = [] + for path in (ROOT / "amplifier_web").glob("*.py"): + text = path.read_text(encoding="utf-8") + if path.name != "auth.py" and re.search(r"_(verified|issued)\([^)]*['\"]session['\"]", text): + offenders.append(path.name) + assert offenders == [] + for name in ("device_pairing.py", "device_connections.py"): + assert "valid_session(" in (ROOT / "amplifier_web" / name).read_text(encoding="utf-8") + body = (ROOT / "amplifier_web/auth.py").read_text(encoding="utf-8") + assert body.count('"session")') == 1 # Only inside valid_session itself. + + +def test_sign_out_saves_a_time_that_never_moves_backward(home, monkeypatch): + assert auth.session_epoch(home) == 0 # No sign-out yet. + clock = [1_900_000_000] + monkeypatch.setattr(auth.time, "time", lambda: clock[0]) + assert auth.sign_out_all(home) == 1_900_000_000 + assert auth.session_epoch(home) == 1_900_000_000 + clock[0] = 1_900_000_050 + assert auth.sign_out_all(home) == 1_900_000_050 + clock[0] = 1_800_000_000 # The PC clock was set back. + assert auth.sign_out_all(home) == 1_900_000_050 + assert auth.session_epoch(home) == 1_900_000_050 + + +def test_saved_time_is_in_the_owner_private_store(home): + auth.sign_out_all(home) + path = auth._epoch_path(home) + assert path.name == "session-epoch" and path.parent.name == "auth" + if os.name == "nt": + from amplifier_web.windows_files import directory + with directory(path.parent, owned_root=home) as pinned: + assert re.fullmatch(rb"[0-9]+\n", pinned.read_private("session-epoch", limit=32)) + else: + assert path.stat().st_mode & 0o777 == 0o600 + + +def test_malformed_record_signs_everyone_out_once_and_is_repaired(home, monkeypatch): + auth.sign_out_all(home) + monkeypatch.setattr(auth.time, "time", lambda: 1_950_000_000) + auth._write_epoch(home, 5) + path = auth._epoch_path(home) + if os.name == "nt": + from amplifier_web.windows_files import directory + with directory(path.parent, owned_root=home) as pinned: + pinned.publish_private("session-epoch", b"not a time\n", replace=True) + else: + path.write_bytes(b"not a time\n") + # Never revives a session: the present moment replaces the record. + assert auth.session_epoch(home) == 1_950_000_000 + assert auth._read_epoch(home) == 1_950_000_000 + # Repaired once: a later read does not move the time again. + monkeypatch.setattr(auth.time, "time", lambda: 1_950_000_500) + assert auth.session_epoch(home) == 1_950_000_000 + + +def test_failed_save_is_an_error_never_a_silent_success(home, monkeypatch): + monkeypatch.setattr(auth, "_write_epoch", lambda *a: (_ for _ in ()).throw(PermissionError("denied"))) + with pytest.raises(OSError): + auth.sign_out_all(home) + monkeypatch.undo() + monkeypatch.setattr(auth, "_write_epoch", lambda *a: None) # Reports success but saves nothing. + with pytest.raises(OSError, match="not saved"): + auth.sign_out_all(home) + + +def test_running_host_applies_the_sign_out_and_ends_open_streams(home, monkeypatch): + monkeypatch.setattr(auth.time, "time", lambda: 1_900_000_100) + store = browser_entry.GrantStore() + ticket = store.issue(browser_entry.Binding("i", "d", "https://127.0.0.1:1", "S-1-5-21-1-2-3-1001")) + cancelled = [] + streams = [SimpleNamespace(cancel=lambda: cancelled.append("stream"))] + app = application(home, browser_entry=store, session_epoch_hint=auth._epoch_hint(home)) + before = session(1_900_000_050) + assert auth.valid_session(app, before) + assert auth.refresh_sessions(app, streams) is False and cancelled == [] # Nothing changed yet. + auth.sign_out_all(home) # The owner's command, in another process. + assert auth.refresh_sessions(app, streams) is True + assert app["session_epoch"] == 1_900_000_100 and cancelled == ["stream"] + assert not auth.valid_session(app, before) + # A ticket issued before the sign-out cannot produce a session after it. + assert store.consume(ticket, browser_entry.Binding("i", "d", "https://127.0.0.1:1", "S-1-5-21-1-2-3-1001")) is None + # Applied once: no repeated stream cancellation while nothing changes. + assert auth.refresh_sessions(app, streams) is False and cancelled == ["stream"] + monkeypatch.setattr(auth.time, "time", lambda: 1_900_000_200) + assert auth.valid_session(app, session(1_900_000_150)) # A new sign-in works. + + +def test_watcher_keeps_running_after_a_fault_and_stops_when_cancelled(home, monkeypatch): + calls = [] + def refresh(app, streams): + calls.append(len(calls)) + if len(calls) == 1: + raise RuntimeError("one fault") + monkeypatch.setattr(auth, "refresh_sessions", refresh) + async def run(): + task = asyncio.create_task(auth.watch_sessions(application(home), set(), interval=0.01)) + while len(calls) < 3: + await asyncio.sleep(0.01) + task.cancel() + await asyncio.gather(task, return_exceptions=True) + return task.cancelled() + assert asyncio.run(run()) is True and len(calls) >= 3 + + +def test_real_middleware_refuses_a_signed_out_cookie(home, monkeypatch): + monkeypatch.setattr(auth.time, "time", lambda: 1_900_000_100) + app = web.Application() + app.update(application(home, session_epoch=1_900_000_050, control_token="c" * 64, + allowed_origins=frozenset({"https://127.0.0.1:8941"}))) + async def handler(request): + return web.json_response({"ok": True}) + def call(cookie): + async def run(): + protocol = Mock(_reading_paused=False) + payload = aiohttp.StreamReader(protocol, 8192) + payload.feed_eof() + request = make_mocked_request( + "GET", "/api/protected", app=app, payload=payload, + sslcontext=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER), + headers={"Host": "127.0.0.1:8941", "Cookie": f"{auth.SESSION_COOKIE}={cookie}"}) + return await auth.auth_required(request, handler) + return asyncio.run(run()) + assert call(session(1_900_000_040)).status == 401 # From before the sign-out. + assert call(session(1_900_000_060)).status == 200 # Signed in again afterwards. + + +def run_command(home, monkeypatch): + from amplifier_web import application_generations, host_dependencies + monkeypatch.setattr(host_dependencies, "prepare_environment", lambda: None) + monkeypatch.setattr(application_generations, "delegate", lambda: None) + monkeypatch.setattr(sys, "argv", ["amplifier-unified", "--data-dir", str(home), "browsers", "sign-out"]) + return cli.main() + + +def test_real_command_signs_out_without_a_running_host_or_a_credential(home, monkeypatch, capsys): + monkeypatch.setattr(auth.time, "time", lambda: 1_900_000_300) + assert auth.session_epoch(home) == 0 + run_command(home, monkeypatch) + assert auth.session_epoch(home) == 1_900_000_300 + output = capsys.readouterr().out + assert "All browsers on all devices are signed out" in output and "within a few seconds" in output + assert "open it from this computer" in output + + +def test_real_command_reports_a_failed_save_plainly(home, monkeypatch, capsys): + monkeypatch.setattr(auth, "_write_epoch", lambda *a: (_ for _ in ()).throw(PermissionError("denied"))) + with pytest.raises(SystemExit) as stopped: + run_command(home, monkeypatch) + assert "NOT signed out" in str(stopped.value) and "Nothing was changed" in str(stopped.value) + assert capsys.readouterr().out == "" + + +def test_command_is_explicit_and_names_its_one_operation(home, monkeypatch): + monkeypatch.setattr(sys, "argv", ["amplifier-unified", "--data-dir", str(home), "browsers", "sign-out"]) + args = cli._parse() + assert (args.command, args.browsers_command) == ("browsers", "sign-out") + monkeypatch.setattr(sys, "argv", ["amplifier-unified", "browsers"]) + with pytest.raises(SystemExit): + cli._parse()