From 8aec6aafcc52ff200c4e44d0cf0bfd6289252b66 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 27 Mar 2026 21:01:52 -0400 Subject: [PATCH] feat: Google OAuth sign-in + per-user Gemini API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users with Google accounts can now sign in without a password. Auth flow: - GET /auth/google → Google consent page (CSRF state cookie) - GET /auth/google/callback → exchange code, lookup user, set JWT - auth.json gains google_sub + google_email fields - set_password() no longer overwrites unrelated auth.json fields Admin setup: python manage_passwords.py google-add # add GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET to .env Per-user Gemini key: - get_user_gemini_key() reads gemini_api_key from auth.json - orchestrator_engine.run() accepts gemini_api_key param - orchestrator router passes user's key, falls back to server key login.html: "Sign in with Google" button above the password form. manage_passwords.py list: now shows auth method columns (pw / google). Co-Authored-By: Claude Sonnet 4.6 --- .env.default | 9 ++ cortex/auth_middleware.py | 4 +- cortex/auth_utils.py | 77 +++++++++++-- cortex/config.py | 6 + cortex/main.py | 5 +- cortex/manage_passwords.py | 32 ++++- cortex/orchestrator_engine.py | 11 +- cortex/routers/auth_google.py | 205 +++++++++++++++++++++++++++++++++ cortex/routers/orchestrator.py | 2 + cortex/static/login.html | 46 ++++++++ 10 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 cortex/routers/auth_google.py diff --git a/.env.default b/.env.default index e41b5bd..23b466c 100644 --- a/.env.default +++ b/.env.default @@ -13,6 +13,15 @@ USER_NAME=Scott # Default: ../home (i.e. Cortex_and_Inara_dev/home/) # HOME_DIR=../home +# ── Google OAuth — "Sign in with Google" ──────────────────────────────────── +# Create credentials at console.cloud.google.com → APIs & Services → Credentials +# Application type: Web Application +# Authorised redirect URI: https://cortex.dgrzone.com/auth/google/callback +# Pre-register users: cd cortex && .venv/bin/python manage_passwords.py google-add +# Per-user Gemini key: add "gemini_api_key": "AIza..." to home/{username}/auth.json +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + # ── Session auth ───────────────────────────────────────────────────────────── # Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" JWT_SECRET=change-me-in-dotenv diff --git a/cortex/auth_middleware.py b/cortex/auth_middleware.py index daa8b5c..2f3caf0 100644 --- a/cortex/auth_middleware.py +++ b/cortex/auth_middleware.py @@ -19,8 +19,8 @@ from auth_utils import COOKIE_NAME, decode_token # Paths that don't require a session cookie _PUBLIC = {"/login", "/logout", "/health"} -# Path prefixes that are always public (setup flow + webhooks) -_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/") +# Path prefixes that are always public (setup flow + webhooks + Google OAuth) +_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google") class SessionAuthMiddleware(BaseHTTPMiddleware): diff --git a/cortex/auth_utils.py b/cortex/auth_utils.py index 77fead6..dc0b986 100644 --- a/cortex/auth_utils.py +++ b/cortex/auth_utils.py @@ -29,33 +29,92 @@ ALGORITHM = "HS256" # --------------------------------------------------------------------------- -# Password helpers +# auth.json helpers — read/write without clobbering unrelated fields # --------------------------------------------------------------------------- def _auth_path(username: str) -> Path: return settings.home_root() / username / "auth.json" +def _read_auth(username: str) -> dict: + path = _auth_path(username) + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +def _write_auth(username: str, data: dict) -> None: + path = _auth_path(username) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + + +# --------------------------------------------------------------------------- +# Password helpers +# --------------------------------------------------------------------------- + def set_password(username: str, password: str) -> None: - """Hash and store a password for a user. Creates auth.json if needed.""" - hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() - _auth_path(username).write_text(json.dumps({"password_hash": hashed}) + "\n") + """Hash and store a password. Preserves any existing fields in auth.json.""" + data = _read_auth(username) + data["password_hash"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + _write_auth(username, data) logger.info("password set for user: %s", username) def check_credentials(username: str, password: str) -> bool: """Return True if username+password are valid, False otherwise.""" - path = _auth_path(username) - if not path.exists(): - return False try: - data = json.loads(path.read_text()) - stored = data.get("password_hash", "").encode() + stored = _read_auth(username).get("password_hash", "").encode() + if not stored: + return False return bcrypt.checkpw(password.encode(), stored) except Exception: return False +# --------------------------------------------------------------------------- +# Google OAuth helpers +# --------------------------------------------------------------------------- + +def find_user_by_google(sub: str, email: str) -> str | None: + """ + Scan all users for one whose auth.json matches the given Google sub or email. + Sub match takes priority (stable); email match is a fallback for first sign-in. + Returns the username, or None if no match. + """ + root = settings.home_root() + if not root.exists(): + return None + for user_dir in sorted(root.iterdir()): + if not user_dir.is_dir(): + continue + data = _read_auth(user_dir.name) + if not data: + continue + if sub and data.get("google_sub") == sub: + return user_dir.name + if email and data.get("google_email", "").lower() == email.lower(): + return user_dir.name + return None + + +def link_google(username: str, sub: str, email: str) -> None: + """Store / update Google sub and email in a user's auth.json.""" + data = _read_auth(username) + data["google_sub"] = sub + data["google_email"] = email + _write_auth(username, data) + logger.info("Google account linked for user: %s (%s)", username, email) + + +def get_user_gemini_key(username: str) -> str | None: + """Return the user's personal Gemini API key, or None to use the server key.""" + return _read_auth(username).get("gemini_api_key") or None + + # --------------------------------------------------------------------------- # JWT helpers # --------------------------------------------------------------------------- diff --git a/cortex/config.py b/cortex/config.py index 9fb69b9..e16eb3a 100644 --- a/cortex/config.py +++ b/cortex/config.py @@ -5,6 +5,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): anthropic_api_key: str | None = None # not used — claude CLI handles auth + # Google OAuth — "Sign in with Google" for all users + # Create credentials at console.cloud.google.com → APIs & Services → Credentials + # Add https:///auth/google/callback as an authorised redirect URI + google_client_id: str | None = None + google_client_secret: str | None = None + # Orchestrator (Gemini API — separate from Gemini CLI) # Get a key at: https://aistudio.google.com/apikey (free tier is sufficient) gemini_api_key: str | None = None diff --git a/cortex/main.py b/cortex/main.py index 90538a2..bc27610 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag from config import settings from auth_middleware import SessionAuthMiddleware from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator -from routers import ui, onboarding, settings, help +from routers import ui, onboarding, settings, help, auth_google @asynccontextmanager @@ -39,6 +39,9 @@ app.include_router(orchestrator.router) # ui.router has a wildcard /{username}/{persona} that would otherwise catch /static/style.css etc. app.mount("/static", StaticFiles(directory="static"), name="static") +# Google OAuth — must be before ui.router (wildcard /{user}/{persona} would swallow it) +app.include_router(auth_google.router) + # Onboarding (invite tokens + persona creation — before ui.router) app.include_router(onboarding.router) diff --git a/cortex/manage_passwords.py b/cortex/manage_passwords.py index 548920a..45d3428 100644 --- a/cortex/manage_passwords.py +++ b/cortex/manage_passwords.py @@ -6,9 +6,10 @@ Usage: python manage_passwords.py set # prompt for password python manage_passwords.py set # set directly (avoid in shell history) python manage_passwords.py check # test a password interactively - python manage_passwords.py list # show users, passwords, and emails + python manage_passwords.py list # show users, auth methods, and emails python manage_passwords.py invite [email] # generate + optionally email invite link python manage_passwords.py email # store/update an email address + python manage_passwords.py google-add # register a user for Google sign-in """ import json @@ -18,7 +19,7 @@ import getpass # Add cortex/ to path so we can import config and auth_utils sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent)) -from auth_utils import set_password, check_credentials, _auth_path, create_invite +from auth_utils import set_password, check_credentials, _auth_path, create_invite, link_google, _read_auth from persona import list_users from config import settings @@ -96,10 +97,14 @@ def cmd_list(_args): if not users: print(" No users found in home/") return + print(f" {'USER':<18} {'PW':<6} {'GOOGLE':<8} {'EMAIL'}") + print(f" {'-'*18} {'-'*6} {'-'*8} {'-'*30}") for user in users: - has_pw = "✓ pw" if _auth_path(user).exists() else "✗ pw" - email = get_email(user) or "—" - print(f" {user:<20} {has_pw} {email}") + auth = _read_auth(user) + has_pw = "✓" if auth.get("password_hash") else "—" + google = auth.get("google_email") or "—" + email = get_email(user) or "—" + print(f" {user:<18} {has_pw:<6} {google:<36} {email}") def cmd_email(args): @@ -149,6 +154,21 @@ def cmd_invite(args): print("Tip: python manage_passwords.py invite to email it next time.\n") +def cmd_google_add(args): + if len(args) < 2: + print("Usage: manage_passwords.py google-add ") + sys.exit(1) + username, email = args[0], args[1].lower().strip() + + # Ensure the user directory exists + (settings.home_root() / username).mkdir(parents=True, exist_ok=True) + + # Store email; google_sub will be filled in on first sign-in + link_google(username, sub="", email=email) + print(f"Google sign-in registered for {username!r}: {email}") + print(f"They can now sign in at {settings.cortex_base_url}/login using that Google account.") + + if __name__ == "__main__": if len(sys.argv) < 2: print(__doc__) @@ -167,6 +187,8 @@ if __name__ == "__main__": cmd_email(rest) elif command == "invite": cmd_invite(rest) + elif command == "google-add": + cmd_google_add(rest) else: print(f"Unknown command: {command}") print(__doc__) diff --git a/cortex/orchestrator_engine.py b/cortex/orchestrator_engine.py index 09f14dc..e1b00f4 100644 --- a/cortex/orchestrator_engine.py +++ b/cortex/orchestrator_engine.py @@ -56,6 +56,7 @@ async def run( system_prompt: str = "", session_messages: list[dict] | None = None, respond_with_claude: bool = True, + gemini_api_key: str | None = None, ) -> OrchestratorResult: """ Run the full orchestration loop for a task. @@ -66,17 +67,19 @@ async def run( session_messages: Prior conversation history for session continuity respond_with_claude: If False, return Gemini's summary as the response (useful for background/cron tasks where a polished reply isn't needed) + gemini_api_key: Per-user Gemini API key (falls back to GEMINI_API_KEY in .env) Returns: OrchestratorResult with response, tool call log, backend used, and Gemini summary """ - if not settings.gemini_api_key: + api_key = gemini_api_key or settings.gemini_api_key + if not api_key: raise RuntimeError( - "GEMINI_API_KEY not set — orchestrator requires Gemini API. " - "Get a free key at https://aistudio.google.com/apikey and add it to .env" + "No Gemini API key available — set GEMINI_API_KEY in .env or add a personal key " + "via: manage_passwords.py gemini-key " ) - client = genai.Client(api_key=settings.gemini_api_key) + client = genai.Client(api_key=api_key) # Seed Gemini with the task — include recent session context if available task_with_context = _build_task_prompt(task, session_messages) diff --git a/cortex/routers/auth_google.py b/cortex/routers/auth_google.py new file mode 100644 index 0000000..813cb2f --- /dev/null +++ b/cortex/routers/auth_google.py @@ -0,0 +1,205 @@ +""" +Google OAuth 2.0 sign-in. + +Flow: + 1. GET /auth/google → redirect to Google's consent page + 2. GET /auth/google/callback → exchange code, look up user, set JWT cookie + +Users must be pre-registered by Scott before they can sign in: + cd cortex && .venv/bin/python manage_passwords.py google-add + +Routes are public (added to _PUBLIC_PREFIXES in auth_middleware.py). +""" + +import json +import logging +import secrets +import urllib.parse +import urllib.request + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response + +from auth_utils import COOKIE_NAME, create_token, find_user_by_google, link_google +from config import settings +from persona import list_user_personas + +logger = logging.getLogger(__name__) +router = APIRouter() + +_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +_GOOGLE_USERINFO = "https://openidconnect.googleapis.com/v1/userinfo" +_STATE_COOKIE = "oauth_state" +_STATE_MAX_AGE = 600 # 10 minutes — plenty of time to complete the flow + + +@router.get("/auth/google", include_in_schema=False) +async def google_login(): + if not settings.google_client_id: + return HTMLResponse("Google sign-in is not configured on this server.", status_code=503) + + state = secrets.token_urlsafe(16) + params = urllib.parse.urlencode({ + "client_id": settings.google_client_id, + "redirect_uri": f"{settings.cortex_base_url}/auth/google/callback", + "response_type": "code", + "scope": "openid email profile", + "state": state, + "access_type": "online", + "prompt": "select_account", + }) + + resp = RedirectResponse(f"{_GOOGLE_AUTH_URL}?{params}", status_code=302) + resp.set_cookie(_STATE_COOKIE, state, max_age=_STATE_MAX_AGE, httponly=True, samesite="lax") + return resp + + +@router.get("/auth/google/callback", include_in_schema=False) +async def google_callback( + request: Request, + code: str = "", + state: str = "", + error: str = "", +): + if error: + return _error_page(f"Google sign-in was cancelled or denied: {error}") + + if not code: + return _error_page("No authorisation code returned by Google.") + + # CSRF check — state must match what we stored in the cookie + stored_state = request.cookies.get(_STATE_COOKIE) + if not stored_state or stored_state != state: + return _error_page("State mismatch — please try signing in again.") + + # Exchange authorisation code for tokens + try: + token_data = _exchange_code(code) + except Exception as e: + logger.error("Google token exchange failed: %s", e) + return _error_page("Could not complete sign-in with Google. Please try again.") + + access_token = token_data.get("access_token") + if not access_token: + return _error_page("No access token returned by Google.") + + # Fetch the user's profile + try: + userinfo = _get_userinfo(access_token) + except Exception as e: + logger.error("Google userinfo fetch failed: %s", e) + return _error_page("Could not retrieve your Google profile. Please try again.") + + google_sub = userinfo.get("sub", "") + google_email = userinfo.get("email", "") + + if not google_sub or not google_email: + return _error_page("Your Google account didn't return a usable email address.") + + # Match to a Cortex user + username = find_user_by_google(google_sub, google_email) + if not username: + logger.warning("Google sign-in rejected: no account for %s (%s)", google_sub, google_email) + return _error_page( + f"Your Google account ({google_email}) isn't registered with Cortex.

" + "Contact Scott to get access." + ) + + # Persist the stable sub so future lookups use it (not just email) + link_google(username, google_sub, google_email) + + personas = list_user_personas(username) + if not personas: + return _error_page("No personas are configured for your account yet. Contact Scott.") + + logger.info("Google sign-in: %s (%s)", username, google_email) + resp = RedirectResponse(f"/{username}/{personas[0]}", status_code=302) + _set_session_cookie(resp, username) + resp.delete_cookie(_STATE_COOKIE) + return resp + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _exchange_code(code: str) -> dict: + body = urllib.parse.urlencode({ + "code": code, + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "redirect_uri": f"{settings.cortex_base_url}/auth/google/callback", + "grant_type": "authorization_code", + }).encode() + req = urllib.request.Request( + _GOOGLE_TOKEN_URL, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + + +def _get_userinfo(access_token: str) -> dict: + req = urllib.request.Request( + _GOOGLE_USERINFO, + headers={"Authorization": f"Bearer {access_token}"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + + +def _set_session_cookie(response: Response, username: str) -> None: + token = create_token(username) + response.set_cookie( + COOKIE_NAME, + token, + max_age=settings.jwt_expire_days * 86400, + httponly=True, + samesite="lax", + secure=False, # set True if terminating TLS at the app layer (not behind a proxy) + ) + + +def _error_page(message: str) -> HTMLResponse: + html = f""" + + + + + Cortex — Sign In Failed + + + + + +
+

Sign In Failed

+

{message}

+ ← Back to Sign In +
+ +""" + return HTMLResponse(html, status_code=403) diff --git a/cortex/routers/orchestrator.py b/cortex/routers/orchestrator.py index e493196..536f9d9 100644 --- a/cortex/routers/orchestrator.py +++ b/cortex/routers/orchestrator.py @@ -18,6 +18,7 @@ from datetime import datetime, timezone from fastapi import APIRouter from pydantic import BaseModel +from auth_utils import get_user_gemini_key from config import settings from context_loader import load_context from persona import set_context, validate as validate_persona @@ -161,6 +162,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest) -> None: system_prompt=system_prompt, session_messages=session_messages, respond_with_claude=req.respond_with_claude, + gemini_api_key=get_user_gemini_key(user), ) # Save the turn to the session store so it survives a page refresh diff --git a/cortex/static/login.html b/cortex/static/login.html index b598458..7a3de95 100644 --- a/cortex/static/login.html +++ b/cortex/static/login.html @@ -90,6 +90,40 @@ button[type="submit"]:hover { background: #6d28d9; } + .divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.25rem 0; + color: #475569; + font-size: 0.78rem; + } + .divider::before, .divider::after { + content: ''; + flex: 1; + border-top: 1px solid #2d3148; + } + + .google-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + width: 100%; + padding: 0.65rem; + background: #fff; + border: 1px solid #dadce0; + border-radius: 6px; + color: #3c4043; + font-size: 0.95rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + text-decoration: none; + transition: background 0.15s, box-shadow 0.15s; + } + .google-btn:hover { background: #f8f9fa; box-shadow: 0 1px 4px rgba(0,0,0,0.2); } + .error { color: #f87171; font-size: 0.85rem; @@ -107,6 +141,18 @@ + + + + + + + + Sign in with Google + + +
or
+