diff --git a/.env.template b/.env.template index 1b633a226..a1f77c796 100644 --- a/.env.template +++ b/.env.template @@ -184,6 +184,17 @@ OLLAMA_HOSTS_LIST=127.0.0.1 # compatible with strict hosted OAuth startup. # GOOGLE_OAUTH_ENABLE_DYNAMIC_REDIRECTS=true +# Optional single Google sign-in behind Cloudflare Access. Disabled by default. +# The public hostname must also be protected by Access and the tunnel connector's +# required Access JWT validation. Raw identity headers do not grant access. +# Existing hasVonLoginEmail bindings are required; this does not provision users +# or grant organisation memberships. FLASK_SECRET_KEY must be non-default, >=32 +# characters. Keep it private. See docs/engineering/cloudflare_access_sso.md. +# VON_CLOUDFLARE_ACCESS_ENABLED=false +# VON_CLOUDFLARE_ACCESS_HOSTNAME=von.example.com +# VON_CLOUDFLARE_ACCESS_TEAM_DOMAIN=your-team.cloudflareaccess.com +# VON_CLOUDFLARE_ACCESS_AUDIENCE= + # ============================================================================ # ROOM DEVICE IDENTITY (Meeting-room voice) # ============================================================================ diff --git a/docs/engineering/cloudflare_access_sso.md b/docs/engineering/cloudflare_access_sso.md new file mode 100644 index 000000000..3f0141e60 --- /dev/null +++ b/docs/engineering/cloudflare_access_sso.md @@ -0,0 +1,41 @@ +# Cloudflare Access single sign-in + +Opt-in deployment support (JVNAUTOSCI-2738). This is not a general replacement +for Von authentication or an invitation to expose its origin directly. + +Protect the **entire public hostname** with a Cloudflare Access application and +an explicit Google-account allow policy. Require Access JWT verification in the +Cloudflare Tunnel connector, retain a catch-all 404, and keep Von's origin on +loopback. Do not enable an Access Bypass policy, including for API paths. + +Set the four `VON_CLOUDFLARE_ACCESS_*` values documented in `.env.template`, using +the application's AUD tag and team domain, plus a strong private +`FLASK_SECRET_KEY` (at least 32 characters). Restart Von. Never copy tokens, +private keys or client secrets into logs or this documentation. + +On that exact hostname, Von independently verifies the RS256 signature with +keys fetched only from the configured team, issuer, audience, expiry, issue +time, optional not-before time and human identity claims. Certificate retrieval +failure denies access. A raw email header is never sufficient. The verified +email must resolve uniquely through `#V#hasVonLoginEmail`; ordinary contact +emails do not grant login. No user or organisation membership is auto-created. + +The resulting actor binding is cached in Von's signed session for the same +assertion, but the assertion is cryptographically checked on every public +request. As with existing OAuth sessions, changing a login-email binding is not +an immediate revocation mechanism for an already bound session: revoke the +Access session as well when immediate removal is needed. Actor changes clear +previous organisation/chat state. A Cloudflare-derived cookie cannot authenticate +on a different hostname or after this feature is disabled. + +The user signs into Google through Access once; Von then recognises the same +governed identity. Logout clears Von state and takes the browser through Access +logout. Direct localhost/Tailscale routes keep their existing authentication +policy and must not be advertised as public bypasses. + +Rollback: set `VON_CLOUDFLARE_ACCESS_ENABLED=false` and restart. Keep Access and +connector validation enabled. The public route returns to its original separate +Von sign-in, provided its Google callback remains configured. Do not overwrite +other `.env` settings. Validate both successful normal Google issuance and +denials in the actual deployment before claiming single-sign-in delivery; +synthetic JWT tests alone are not that proof. diff --git a/src/backend/security/cloudflare_access.py b/src/backend/security/cloudflare_access.py new file mode 100644 index 000000000..0acd43803 --- /dev/null +++ b/src/backend/security/cloudflare_access.py @@ -0,0 +1,175 @@ +"""Opt-in Cloudflare Access SSO; raw identity headers never grant authority.""" + +from __future__ import annotations + +import hashlib +import os +import re +import threading +import time + +from flask import g, jsonify, redirect, request, session +from google.auth import jwt +import requests + +from .authentication_assurance import ( + AUTHENTICATION_ASSURANCE_SESSION_KEY, + GOOGLE_OAUTH_LOGIN_EMAIL_ASSURANCE, + session_has_required_authentication_assurance, +) +from ..services.von_user_authentication_service import ( + find_user_concept_by_login_email, + normalise_von_login_email, +) + +PROVIDER = "cloudflare_access" + + +class AccessVerifier: + """Validate signatures with rotating, deployment-pinned Cloudflare keys.""" + + def __init__(self, team_domain: str, audience: str): + self.issuer = f"https://{team_domain}" + self.audience = audience + self._keys: dict[str, str] = {} + self._fetched_at = 0.0 + self._lock = threading.Lock() + + def _certificates(self, kid: str) -> dict[str, str]: + with self._lock: + age = time.monotonic() - self._fetched_at + # Refresh unknown keys for rotation, but not on every forged kid. + if not self._keys or age >= 300 or (kid not in self._keys and age >= 10): + response = requests.get( + f"{self.issuer}/cdn-cgi/access/certs", + timeout=(3, 5), + allow_redirects=False, + ) + response.raise_for_status() + if response.status_code != 200: + raise ValueError("access_keys_unavailable") + rows = response.json().get("public_certs", []) + keys = { + row["kid"]: row["cert"] + for row in rows + if isinstance(row, dict) + and isinstance(row.get("kid"), str) + and isinstance(row.get("cert"), str) + } + if not keys: + raise ValueError("access_keys_unavailable") + self._keys = keys + self._fetched_at = time.monotonic() + return dict(self._keys) + + def verify(self, token: str) -> dict: + if not token or len(token) > 32768: + raise ValueError("access_token_missing_or_invalid") + header = jwt.decode_header(token) + if header.get("alg") != "RS256" or not isinstance(header.get("kid"), str): + raise ValueError("access_token_invalid") + # google-auth verifies signature, iat and exp. Access uses a list-valued + # audience, so validate that explicitly after signature verification. + claims = jwt.decode(token, certs=self._certificates(header["kid"])) + aud = claims.get("aud") + if not isinstance(aud, list) or self.audience not in aud: + raise ValueError("access_audience_invalid") + if claims.get("iss") != self.issuer or claims.get("type") != "app": + raise ValueError("access_issuer_or_type_invalid") + for name in ("iat", "exp"): + if type(claims.get(name)) is not int: + raise ValueError("access_time_invalid") + if claims["exp"] <= time.time() or claims["exp"] <= claims["iat"]: + raise ValueError("access_token_expired") + if "nbf" in claims and ( + type(claims["nbf"]) is not int or claims["nbf"] > time.time() + ): + raise ValueError("access_token_not_yet_valid") + if not isinstance(claims.get("sub"), str) or not claims["sub"]: + raise ValueError("access_subject_missing") + # Service tokens lack a human email; they must never become a user. + if not isinstance(claims.get("email"), str): + raise ValueError("access_email_missing") + claims["email"] = normalise_von_login_email(claims["email"]) + return claims + + +def install_cloudflare_access(app) -> None: + """Install before identity-consuming middleware; disabled by default.""" + enabled = os.getenv("VON_CLOUDFLARE_ACCESS_ENABLED", "").lower() in { + "1", "true", "yes", "on" + } + hostname = os.getenv("VON_CLOUDFLARE_ACCESS_HOSTNAME", "").strip().lower() + team = os.getenv("VON_CLOUDFLARE_ACCESS_TEAM_DOMAIN", "").strip().lower() + audience = os.getenv("VON_CLOUDFLARE_ACCESS_AUDIENCE", "").strip() + if enabled and ( + not re.fullmatch(r"[a-z0-9-]+\.cloudflareaccess\.com", team) + or not re.fullmatch(r"[a-z0-9]+(?:[.-][a-z0-9-]+)+", hostname) + or not audience + or len(str(app.secret_key or "")) < 32 + or app.secret_key == "von-dev-secret-key-change-in-production" + ): + raise RuntimeError("Cloudflare Access SSO requires a hostname, team domain, audience and non-default Flask secret of at least 32 characters") + verifier = AccessVerifier(team, audience) if enabled else None + app.extensions["cloudflare_access_verifier"] = verifier + + @app.before_request + def authenticate_access_request(): + # Do not let an SSO-derived Von cookie authenticate a direct/Tailscale + # request or survive disabling SSO without its original issuer proof. + public_request = enabled and request.host.lower() == hostname + if not public_request: + if session.get("auth_provider") == PROVIDER: + session.clear() + return None + + try: + token = request.headers.get("Cf-Access-Jwt-Assertion", "") + claims = verifier.verify(token) + fingerprint = hashlib.sha256(token.encode()).hexdigest() + email = claims["email"] + if not ( + session.get("auth_provider") == PROVIDER + and session.get("cloudflare_assertion_hash") == fingerprint + and session.get("user_email") == email + and session.get("user_concept_id") + and session_has_required_authentication_assurance(session) + ): + user = find_user_concept_by_login_email(email) + if not user or not user.get("concept_id"): + raise ValueError("von_login_email_not_authorised") + actor = user["concept_id"] + if session.get("user_concept_id") != actor: + session.clear() + session.update( + user_email=email, + user_concept_id=actor, + user_id=actor.removeprefix("#V#").lower().replace(" ", "_"), + google_user_info={"email": email, "name": user.get("name", "")}, + auth_provider=PROVIDER, + cloudflare_assertion_hash=fingerprint, + ) + session[AUTHENTICATION_ASSURANCE_SESSION_KEY] = GOOGLE_OAUTH_LOGIN_EMAIL_ASSURANCE + session.pop("browser_test_fixture_id", None) + g.cloudflare_access_authenticated = True + except Exception as exc: + session.clear() + # Never log token, claims, email or provider response bodies. + app.logger.warning("Cloudflare SSO denied (%s)", type(exc).__name__) + response = jsonify( + authenticated=False, + error="Cloudflare identity could not be verified or is not bound to a Von user.", + error_code="cloudflare_access_denied", + ) + response.status_code = 403 + response.headers["Cache-Control"] = "no-store" + return response + + # Existing login endpoints cannot replace this request's verified actor. + if request.endpoint == "auth_bp.login": + return redirect("/von/") + if request.endpoint in { + "auth_bp.callback", "auth_bp.exchange_auth_token", "auth_bp.browser_test_login" + }: + return jsonify(error_code="cloudflare_identity_authoritative"), 403 + return None diff --git a/src/backend/server/routes/auth_routes.py b/src/backend/server/routes/auth_routes.py index f13844816..b95d25e84 100644 --- a/src/backend/server/routes/auth_routes.py +++ b/src/backend/server/routes/auth_routes.py @@ -622,6 +622,7 @@ def browser_test_login(): @auth_bp.route("/api/auth/logout", methods=["POST"]) def logout(): """Log out the current user by clearing their session.""" + cloudflare_logout = session.get("auth_provider") == "cloudflare_access" user_email = session.get("user_email") user_id = ( session.get("user_concept_id") @@ -661,6 +662,7 @@ def logout(): "success": True, "message": "Logged out successfully", "window_context_cleanup": ("deferred" if cleanup_deferred else "completed"), + "redirect_url": "/cdn-cgi/access/logout" if cloudflare_logout else None, } ) diff --git a/src/backend/server/utils_flask.py b/src/backend/server/utils_flask.py index c09ecca31..30e16bc69 100644 --- a/src/backend/server/utils_flask.py +++ b/src/backend/server/utils_flask.py @@ -1544,6 +1544,9 @@ def create_flask_app( _install_request_timing_middleware(app) _configure_flask_app_core(app, list_models_func, generate_func) + from ..security.cloudflare_access import install_cloudflare_access + + install_cloudflare_access(app) _install_local_browser_origin_canonicalisation(app) _register_default_blueprints(app) diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index d03659770..bf7f368e6 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -1509,6 +1509,13 @@

Server Control

if (response.ok) { console.log('Logout successful'); + const logoutResult = await response.json(); + if (logoutResult.redirect_url === '/cdn-cgi/access/logout') { + clearIdentityScopeMirrors(); + getSettingsHostWindow().location.assign('/cdn-cgi/access/logout'); + return; + } + clearIdentityScopeMirrors(); await updateAuthenticationDisplay({ authenticated: false }); diff --git a/tests/backend/test_cloudflare_access.py b/tests/backend/test_cloudflare_access.py new file mode 100644 index 000000000..ae2184907 --- /dev/null +++ b/tests/backend/test_cloudflare_access.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import time +from unittest.mock import Mock + +import pytest +from flask import Flask, jsonify, session +from google.auth import crypt, jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization + +from src.backend.security import cloudflare_access as access +from src.backend.server.routes import auth_routes + +HOST = "https://von.example.test" +TEAM = "team.cloudflareaccess.com" + + +@pytest.fixture +def signing_key(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + public = key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode() + return crypt.RSASigner.from_string(pem, key_id="key1"), public + + +def token(signer, **overrides): + now = int(time.time()) + claims = dict(iss=f"https://{TEAM}", aud=["aud1"], type="app", sub="subject1", email="user@example.test", iat=now, exp=now + 3600) + claims.update(overrides) + return jwt.encode(signer, claims).decode() + + +@pytest.fixture +def app(monkeypatch, signing_key): + monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_ENABLED", "true") + monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_HOSTNAME", "von.example.test") + monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_TEAM_DOMAIN", TEAM) + monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_AUDIENCE", "aud1") + app = Flask(__name__) + app.secret_key = "test-only-secret-not-for-deployment" + app.testing = True + access.install_cloudflare_access(app) + app.extensions["cloudflare_access_verifier"]._keys = {"key1": signing_key[1]} + app.extensions["cloudflare_access_verifier"]._fetched_at = time.monotonic() + monkeypatch.setattr(access, "find_user_concept_by_login_email", lambda email: {"concept_id": "#V#user", "name": "User"}) + app.register_blueprint(auth_routes.auth_bp, url_prefix="/von") + + @app.get("/private") + def private(): + if not session.get("user_concept_id"): + return jsonify(error="no actor"), 401 + return jsonify(actor=session.get("user_concept_id"), org=session.get("organisation_concept_id"), provider=session.get("auth_provider")) + + return app + + +def test_normal_issuance_binding_and_scope_retention(app, signing_key): + client = app.test_client() + headers = {"Cf-Access-Jwt-Assertion": token(signing_key[0])} + first = client.get("/von/api/auth/status", base_url=HOST, headers=headers) + assert first.json["authenticated"] is True + assert first.json["user_concept_id"] == "#V#user" + assert first.json["auth_provider"] == "cloudflare_access" + with client.session_transaction(base_url=HOST) as s: + s["organisation_concept_id"] = "#V#permitted_org" + assert client.get("/private", base_url=HOST, headers=headers).json["org"] == "#V#permitted_org" + assert client.get("/von/api/auth/google/login", base_url=HOST, headers=headers).location == "/von/" + + +@pytest.mark.parametrize("overrides", [ + {"iss": "https://evil.cloudflareaccess.com"}, {"aud": ["other"]}, + {"aud": None}, {"aud": "aud1"}, {"exp": int(time.time()) - 1}, + {"iat": int(time.time()) + 200}, {"nbf": int(time.time()) + 200}, + {"email": None}, {"email": "invalid"}, {"sub": ""}, {"type": "service"}, + {"exp": "tomorrow"}, +]) +def test_invalid_claims_fail_closed(app, signing_key, overrides): + response = app.test_client().get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0], **overrides)}) + assert response.status_code == 403 + assert "user@example.test" not in response.get_data(as_text=True) + + +@pytest.mark.parametrize("raw", ["", "garbage", "a.b.c"]) +def test_missing_and_forged_headers_cannot_reuse_session(app, signing_key, raw): + client = app.test_client() + assert client.get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0])}).status_code == 200 + denied = client.get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": raw, "Cf-Access-Authenticated-User-Email": "user@example.test", "X-User-Concept-ID": "#V#user"}) + assert denied.status_code == 403 + with client.session_transaction(base_url=HOST) as s: + assert not s.get("user_concept_id") + + +def test_wrong_signing_key(app): + other = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = other.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + signer = crypt.RSASigner.from_string(pem, key_id="key1") + assert app.test_client().get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signer)}).status_code == 403 + + +@pytest.mark.parametrize("ambiguous", [False, True]) +def test_unbound_or_ambiguous_identity_never_inherits_actor(app, signing_key, monkeypatch, ambiguous): + def resolve(email): + if ambiguous: + raise auth_routes.MultipleUsersForEmailError(email, ["#V#a", "#V#b"]) + return None + monkeypatch.setattr(access, "find_user_concept_by_login_email", resolve) + client = app.test_client() + with client.session_transaction(base_url=HOST) as s: + s.update(user_concept_id="#V#previous", organisation_concept_id="#V#private_org") + assert client.get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0])}).status_code == 403 + + +def test_identity_change_clears_org_and_chat_scope(app, signing_key, monkeypatch): + client = app.test_client() + headers = {"Cf-Access-Jwt-Assertion": token(signing_key[0])} + client.get("/private", base_url=HOST, headers=headers) + with client.session_transaction(base_url=HOST) as s: + s.update(organisation_concept_id="#V#old_org", namespace="old", session_id="old-chat") + monkeypatch.setattr(access, "find_user_concept_by_login_email", lambda email: {"concept_id": "#V#other"}) + response = client.get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0], email="other@example.test", sub="other")}) + assert response.json == {"actor": "#V#other", "org": None, "provider": "cloudflare_access"} + with client.session_transaction(base_url=HOST) as s: + assert not s.get("session_id") and not s.get("namespace") + + +def test_sso_cookie_cannot_authenticate_direct_host(app, signing_key): + client = app.test_client() + client.get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0])}) + cookie = client.get_cookie("session", domain="von.example.test") + direct = app.test_client() + direct.set_cookie("session", cookie.value, domain="localhost") + assert direct.get("/private").status_code == 401 + + +def test_sso_cookie_requires_feature_and_assertion_after_disable(app, signing_key, monkeypatch): + client = app.test_client() + client.get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0])}) + cookie = client.get_cookie("session", domain="von.example.test") + monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_ENABLED", "false") + disabled = Flask("disabled") + disabled.secret_key = app.secret_key + access.install_cloudflare_access(disabled) + disabled.register_blueprint(auth_routes.auth_bp, url_prefix="/von") + other = disabled.test_client() + other.set_cookie("session", cookie.value, domain="von.example.test") + assert other.get("/von/api/auth/status", base_url=HOST).json["authenticated"] is False + + +def test_public_auth_routes_cannot_replace_verified_identity(app, signing_key): + headers = {"Cf-Access-Jwt-Assertion": token(signing_key[0])} + client = app.test_client() + assert client.post("/von/api/auth/exchange-token", base_url=HOST, headers=headers, json={"token": "other"}).status_code == 403 + assert client.post("/von/api/auth/browser-test-login", base_url=HOST, headers=headers).status_code == 403 + + +def test_logout_redirects_to_access_logout(app, signing_key, monkeypatch): + from src.backend.services import window_session_context_service + monkeypatch.setattr(window_session_context_service, "delete_window_context_if_owned", lambda *args: None) + response = app.test_client().post("/von/api/auth/logout", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0])}) + assert response.json["redirect_url"] == "/cdn-cgi/access/logout" + + +def test_key_rotation_and_fixed_fetch_destination(signing_key, monkeypatch): + response = Mock(status_code=200) + response.json.return_value = {"public_certs": [{"kid": "key1", "cert": signing_key[1]}]} + fetch = Mock(return_value=response) + monkeypatch.setattr(access.requests, "get", fetch) + verifier = access.AccessVerifier(TEAM, "aud1") + assert verifier.verify(token(signing_key[0]))["email"] == "user@example.test" + verifier.verify(token(signing_key[0])) + assert fetch.call_count == 1 + assert fetch.call_args.args[0] == f"https://{TEAM}/cdn-cgi/access/certs" + assert fetch.call_args.kwargs["allow_redirects"] is False + + +def test_incomplete_config_fails_startup(app, monkeypatch): + monkeypatch.setenv("VON_CLOUDFLARE_ACCESS_TEAM_DOMAIN", "https://evil.test/path") + with pytest.raises(RuntimeError): + access.install_cloudflare_access(Flask("bad")) + + +@pytest.mark.parametrize("secret", [None, "short", "von-dev-secret-key-change-in-production"]) +def test_unsafe_session_secret_fails_startup(app, secret): + candidate = Flask("unsafe-secret") + candidate.secret_key = secret + with pytest.raises(RuntimeError, match="non-default Flask secret"): + access.install_cloudflare_access(candidate) + + +def test_key_fetch_failure_fails_closed(app, signing_key, monkeypatch): + verifier = app.extensions["cloudflare_access_verifier"] + verifier._keys = {} + monkeypatch.setattr(access.requests, "get", Mock(side_effect=access.requests.ConnectionError())) + response = app.test_client().get("/private", base_url=HOST, headers={"Cf-Access-Jwt-Assertion": token(signing_key[0])}) + assert response.status_code == 403