feat: Google OAuth sign-in + per-user Gemini API key

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 <username> <email>
  # 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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-27 21:01:52 -04:00
parent 62fde62653
commit 8aec6aafcc
10 changed files with 376 additions and 21 deletions

View File

@@ -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 <user> <email>
# 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

View File

@@ -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):

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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://<your-domain>/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

View File

@@ -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)

View File

@@ -6,9 +6,10 @@ Usage:
python manage_passwords.py set <username> # prompt for password
python manage_passwords.py set <username> <pass> # set directly (avoid in shell history)
python manage_passwords.py check <username> # 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 <username> [email] # generate + optionally email invite link
python manage_passwords.py email <username> <email> # store/update an email address
python manage_passwords.py google-add <username> <email> # 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 <username> <email> to email it next time.\n")
def cmd_google_add(args):
if len(args) < 2:
print("Usage: manage_passwords.py google-add <username> <google_email>")
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__)

View File

@@ -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 <username> <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)

View File

@@ -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 <username> <email>
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 (<strong>{google_email}</strong>) isn't registered with Cortex.<br><br>"
"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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Sign In Failed</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: #0f1117; font-family: 'Inter', system-ui; font-weight: 450;
-webkit-font-smoothing: antialiased; color: #e2e8f0;
}}
.card {{
background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px;
padding: 2.5rem 2rem; width: 100%; max-width: 420px; text-align: center;
}}
h1 {{ font-size: 1.25rem; font-weight: 700; color: #f87171; margin-bottom: 1rem; }}
p {{ font-size: 0.9rem; color: #94a3b8; margin-bottom: 1.75rem; line-height: 1.65; }}
a {{
display: inline-block; padding: 0.6rem 1.5rem;
background: #7c3aed; border-radius: 6px; color: #fff;
text-decoration: none; font-size: 0.9rem; font-weight: 600;
transition: background 0.15s;
}}
a:hover {{ background: #6d28d9; }}
</style>
</head>
<body>
<div class="card">
<h1>Sign In Failed</h1>
<p>{message}</p>
<a href="/login">← Back to Sign In</a>
</div>
</body>
</html>"""
return HTMLResponse(html, status_code=403)

View File

@@ -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

View File

@@ -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 @@
<!-- ERROR -->
<a href="/auth/google" class="google-btn">
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Sign in with Google
</a>
<div class="divider">or</div>
<form method="POST" action="/login">
<div class="field">
<label for="username">Username</label>