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

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