Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ACCESS-APPLICATION-AUD-TAG>

# ============================================================================
# ROOM DEVICE IDENTITY (Meeting-room voice)
# ============================================================================
Expand Down
41 changes: 41 additions & 0 deletions docs/engineering/cloudflare_access_sso.md
Original file line number Diff line number Diff line change
@@ -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.
175 changes: 175 additions & 0 deletions src/backend/security/cloudflare_access.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/backend/server/routes/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
}
)

Expand Down
3 changes: 3 additions & 0 deletions src/backend/server/utils_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions src/frontend/web/von_interface/templates/settings_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,13 @@ <h2>Server Control</h2>
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 });

Expand Down
Loading
Loading