From 544bb1663562bc68d9d5a9321e8a08e7aee21ae6 Mon Sep 17 00:00:00 2001 From: asifdotpy Date: Sat, 15 Aug 2026 22:10:53 +0600 Subject: [PATCH 1/5] fix: add OAUTH_REDIRECT_URI to OAuth client registration to force https scheme --- src/web/app.py | 505 +++++++++++++++++++++++++++++++----------------- src/web/auth.py | 137 +++++++++++-- 2 files changed, 448 insertions(+), 194 deletions(-) diff --git a/src/web/app.py b/src/web/app.py index 1ee1254..3b9b1e0 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -1,49 +1,88 @@ -"""FastAPI web surface for the Script Notes-to-Outline Matrix Agent. - -Satisfies the hackathon's web-platform rule and the "Design" criterion: a real, -coherent product experience (upload -> agent runs -> categorized notes, conflicts, -scene-by-scene checklist, and live ClickHouse analytics), not just a backend agent. +"""FastAPI JSON API surface for the Script Notes-to-Outline Matrix Agent. + +This is the BACKEND half of the Vercel-frontend / GCP-backend split. All +server-rendered HTML routes from the original monolith have been removed; +what remains is a pure JSON REST API consumed by the Next.js frontend on +Vercel. The agent, ClickHouse persistence, and Google OAuth gate are +unchanged — only the response shape changed from Jinja2 templates to JSON. + +Backwards-compatibility note +---------------------------- +The original HTML routes (/ , /project/{id} , /login , /auth/google/callback , +/logout) are GONE in this file. If you still need the in-place Cloud Run HTML +app (e.g. for a quick demo without the frontend), deploy the pre-split +app.py from git history instead. + +CORS +---- +The frontend on *.vercel.app and the backend on *.run.app are different +origins. CORS middleware is configured to allow the frontend origin (from +FRONTEND_URL env var) and to expose the Authorization header so the JWT can +be sent as a Bearer token. """ from __future__ import annotations import os import sys from pathlib import Path +from typing import Any -from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException -from fastapi.responses import HTMLResponse, Response, RedirectResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException, Depends +from fastapi.responses import JSONResponse, Response, RedirectResponse +from fastapi.middleware.cors import CORSMiddleware +# --------------------------------------------------------------------------- +# Ensure src-relative imports work (same trick as the original app.py) +# --------------------------------------------------------------------------- BASE = Path(__file__).resolve().parent -# Ensure static directory exists to prevent Starlette runtime mount failure -os.makedirs(BASE / "static", exist_ok=True) - -# Make src-relative imports work sys.path.insert(0, str(BASE)) from src.ingestion.pdf_parser import parse_pdf, parse_email # noqa: E402 -# Login gate (board task t_5e9f2ba8): Google OAuth 2.0 for this Google Cloud -# + Gemini + ClickHouse project. See src/web/auth.py. +# Login gate helpers — Google OAuth client + JWT layer. from src.web import auth as webauth # noqa: E402 -app = FastAPI(title="Script Notes-to-Outline Matrix Agent") -templates = Jinja2Templates(directory=str(BASE / "templates")) -app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") +app = FastAPI(title="Script Notes-to-Outline Matrix Agent API") + +# --------------------------------------------------------------------------- +# CORS — frontend on Vercel (*.vercel.app), backend on Cloud Run (*.run.app) +# --------------------------------------------------------------------------- +_FRONTEND_URL = os.getenv("FRONTEND_URL", "").rstrip("/") +if _FRONTEND_URL: + _ALLOWED_ORIGINS = [_FRONTEND_URL] +else: + _ALLOWED_ORIGINS = ["*"] # fallback for local dev without FRONTEND_URL set + +app.add_middleware( + CORSMiddleware, + allow_origins=_ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], + expose_headers=["Set-Cookie"], +) -# Server-side session for OAuth state (authlib requirement). Our own signed auth -# cookie is separate (see src/web/auth.py). Secret from SESSION_SECRET, else the -# Google OAuth secret, else a dev fallback (auth is disabled without real creds). + +# --------------------------------------------------------------------------- +# Authlib SessionMiddleware — required for the OAuth state machine. +# Same as the original app.py; the secret comes from SESSION_SECRET or the +# Google OAuth client secret, with a dev fallback when neither is set. +# --------------------------------------------------------------------------- from starlette.middleware.sessions import SessionMiddleware # noqa: E402 app.add_middleware( SessionMiddleware, - secret_key=os.getenv("SESSION_SECRET") - or os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") - or "dev-insecure-session-secret", + secret_key=( + os.getenv("SESSION_SECRET") + or os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") + or "dev-insecure-session-secret" + ), ) + +# --------------------------------------------------------------------------- +# Lazy agent — same as the original app.py +# --------------------------------------------------------------------------- _agent = None @@ -56,9 +95,14 @@ def _get_agent(): return _agent +# --------------------------------------------------------------------------- +# Helpers — shared with the original app.py, unchanged +# --------------------------------------------------------------------------- + def _get_projects() -> list[dict]: """Retrieve list of previously processed projects from ClickHouse.""" from src.clickhouse import client as ch + try: ch.init_schema() rows = ch.run_query( @@ -67,21 +111,11 @@ def _get_projects() -> list[dict]: "GROUP BY project_id " "ORDER BY last_updated DESC" ) - # run_query returns plain dicts in chDB mode but ClickHouseRow objects in - # Cloud mode. ClickHouseRow supports attribute access (p.project_id) but its - # proxied values may not be plain Python types that Jinja2 can call methods on - # (e.g. str.replace). Convert every row to a plain dict with native types so the - # template always gets JSON-serializable values. dict(ClickHouseRow) preserves all - # fields with their native Python types (clickhouse-connect converts most types - # automatically); wrap with str() on text fields as a safety net for any exotic - # types (e.g. LowCardinality(String) returning a subclass) that Jinja2's method - # call dispatch may not handle. out = [] for r in rows: if isinstance(r, dict): out.append({k: str(v) if isinstance(v, str) else v for k, v in r.items()}) else: - # ClickHouseRow (Cloud mode) — convert to dict, normalize text fields. d = dict(r) out.append({k: str(v) if isinstance(v, str) else v for k, v in d.items()}) return out @@ -91,145 +125,221 @@ def _get_projects() -> list[dict]: def _fallback_summary(project_id: str) -> str: - """Render a real summary from persisted ClickHouse analytics when the agent returns - no final text.""" + """Render a real summary from persisted ClickHouse analytics when the agent returns no final text.""" from src.clickhouse import client as ch try: a = ch.analytics_for(project_id) except Exception: - return (f"[agent persisted {project_id} to ClickHouse but analytics unavailable. " - f"See the live ClickHouse panel.]") - lines = [f"# scenes with notes: {len(a.get('scene_density', []))}", - f"# stakeholder disagreement rows: {len(a.get('stakeholder_disagreement', []))}", - f"# draft progress rows: {len(a.get('draft_progress', []))}", - "", - "Draft-2 revision plan (persisted in ClickHouse via mcp-clickhouse):", - "See the live analytics panel / notes_matrix view for the full matrix."] + return ( + f"[agent persisted {project_id} to ClickHouse but analytics unavailable. " + f"See the live ClickHouse panel.]" + ) + lines = [ + f"# scenes with notes: {len(a.get('scene_density', []))}", + f"# stakeholder disagreement rows: {len(a.get('stakeholder_disagreement', []))}", + f"# draft progress rows: {len(a.get('draft_progress', []))}", + "", + "Draft-2 revision plan (persisted in ClickHouse via mcp-clickhouse):", + "See the live analytics panel / notes_matrix view for the full matrix.", + ] return "\n".join(lines) -@app.get("/login", response_class=HTMLResponse) -async def login_page(request: Request): - if webauth.is_authenticated(request): - return RedirectResponse(url="/") - if not webauth._auth_enabled(): - # No Google OAuth creds configured -> app is open (local dev). - return RedirectResponse(url="/") - # Start the Google OAuth 2.0 Authorization Code flow. - redirect_uri = request.url_for("auth_callback") - return await webauth.google_oauth_client().google.authorize_redirect(request, redirect_uri) +def _slugify_project(title: str) -> str: + from src.clickhouse import client as ch + + return ch.slugify_project(title) + + +def _query_project(project_id: str, draft_version: int = 1) -> dict: + """Fetch notes, conflicts, analytics, and checklist for one project.""" + from src.clickhouse import client as ch + from src.analytics import queries + from src.agent.tools.note_tools import build_checklist + + esc = project_id.replace("'", "''") + try: + notes = ch.run_query( + f"SELECT * FROM script_notes_matrix.notes_raw " + f"WHERE project_id = '{esc}' AND draft_version = {int(draft_version)} " + f"ORDER BY scene_number, severity DESC" + ) + conflicts = ch.run_query( + f"SELECT * FROM script_notes_matrix.notes_conflicts " + f"WHERE project_id = '{esc}' AND draft_version = {int(draft_version)} " + f"ORDER BY scene_number" + ) + analytics = queries.project_analytics(project_id, draft_version) + except Exception as exc: + print(f"Error querying project {project_id}: {exc}") + notes, conflicts, analytics = [], [], {} + + checklist = build_checklist(notes, conflicts) + return { + "notes": notes, + "conflicts": conflicts, + "analytics": analytics, + "checklist": checklist, + } + + +# --------------------------------------------------------------------------- +# Health (public — no auth) +# --------------------------------------------------------------------------- + +@app.get("/api/health") +async def health() -> dict: + """Return service health: ClickHouse + Agent Engine connectivity.""" + clickhouse_status = "unknown" + agent_status = "unknown" + try: + from src.clickhouse import client as ch + ch.init_schema() + clickhouse_status = "connected" + except Exception as exc: + clickhouse_status = f"error: {exc}" -@app.get("/auth/google/callback") -async def auth_callback(request: Request): + try: + _get_agent() + agent_status = "connected" + except Exception as exc: + agent_status = f"error: {exc}" + + return { + "status": "ok" if clickhouse_status == "connected" and agent_status == "connected" else "degraded", + "clickhouse": clickhouse_status, + "agent_engine": agent_status, + } + + +# --------------------------------------------------------------------------- +# Auth endpoints +# --------------------------------------------------------------------------- + +@app.get("/api/auth/google/login") +async def auth_google_login(request: Request): + """Start the Google OAuth 2.0 Authorization Code flow.""" + if not webauth._auth_enabled(): + return JSONResponse( + status_code=403, + content={"detail": "Google OAuth is not configured (missing client ID/secret)."}, + ) + # Use explicit redirect URI from env, or construct from host header + explicit_redirect = os.getenv("OAUTH_REDIRECT_URI") + if explicit_redirect: + redirect_uri_full = explicit_redirect + else: + host = request.headers.get("host", "").split(":")[0] + # Force https — Cloud Run terminates TLS and forwards as http internally + redirect_uri_full = f"https://{host}/api/auth/google/callback" + return await webauth.google_oauth_client().google.authorize_redirect(request, redirect_uri_full) + + +@app.get("/api/auth/google/callback") +async def auth_google_callback(request: Request): + """OAuth callback: verify Google ID token, check whitelist, issue JWT. + + On success: issue a JWT and redirect to the frontend callback page with + ?token= in the URL so the frontend can capture it. + + On failure: redirect to the frontend login page with an error message. + """ if not webauth._auth_enabled(): - return RedirectResponse(url="/", status_code=303) + return RedirectResponse( + url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error=oauth_disabled", + status_code=303, + ) + token = await webauth.google_oauth_client().google.authorize_access_token(request) - # Verify Google's ID token against our client id + Google issuer. + idt = token.get("id_token") + if not idt: + err = "No ID token returned from Google." + return RedirectResponse( + url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error={err}", + status_code=303, + ) + from google.oauth2 import id_token from google.auth.transport.urllib3 import Request as GoogleRequest - idt = token.get("id_token") try: - claim = id_token.verify_oauth2_token(idt, GoogleRequest(), os.getenv("GOOGLE_OAUTH_CLIENT_ID")) - except Exception as exc: # noqa: BLE001 - return templates.TemplateResponse( - request=request, name="login.html", - context={"request": request, "auth_enabled": True, - "error": f"Google sign-in failed: {exc}"}, + claim = id_token.verify_oauth2_token( + idt, GoogleRequest(), os.getenv("GOOGLE_OAUTH_CLIENT_ID") ) + except Exception as exc: + return RedirectResponse( + url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error=google_signin_failed:{exc}", + status_code=303, + ) + email = claim.get("email", "") if not email or not claim.get("email_verified", False): - return templates.TemplateResponse( - request=request, name="login.html", - context={"request": request, "auth_enabled": True, - "error": "Google account email not verified."}, + return RedirectResponse( + url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error=email_not_verified", + status_code=303, ) if not webauth._allowed(email): - return templates.TemplateResponse( - request=request, name="login.html", - context={"request": request, "auth_enabled": True, - "error": f"Account {email} is not authorized for this app."}, + return RedirectResponse( + url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error=not_authorized", + status_code=303, ) - resp = RedirectResponse(url="/", status_code=303) - webauth.set_session(resp, email) - return resp + # Issue JWT and redirect to frontend callback. + try: + jwt_token = webauth.make_jwt_token(email) + except RuntimeError as exc: + return RedirectResponse( + url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error=jwt_issue_failed:{exc}", + status_code=303, + ) + + frontend_url = os.getenv("FRONTEND_URL", "").rstrip("/") + if not frontend_url: + # No FRONTEND_URL set — can't redirect. Return the token as JSON for local dev. + return JSONResponse({"token": jwt_token, "email": email}) + + callback_path = "/callback" + full_callback = f"{frontend_url}{callback_path}?token={jwt_token}" + return RedirectResponse(url=full_callback, status_code=303) + + +@app.get("/api/auth/logout") +async def auth_logout(): + """Clear the session cookie on the backend (same-origin only). -@app.get("/logout") -def logout(): + For cross-origin logout, the frontend should clear its stored JWT and + redirect the user to /login. + """ resp = RedirectResponse(url="/", status_code=303) webauth.clear_session(resp) return resp -@app.get("/", response_class=HTMLResponse) -def index(request: Request): - webauth.require_auth(request) - projects = _get_projects() - return templates.TemplateResponse( - request=request, - name="index.html", - context={ - "request": request, - "projects": projects, - "selected_project_id": None, - "result": None, - } - ) +# --------------------------------------------------------------------------- +# Protected API endpoints (require JWT) +# --------------------------------------------------------------------------- +def _require_auth(request: Request) -> None: + """FastAPI dependency-like check: 401 if no valid JWT.""" + webauth.require_jwt(request) -@app.get("/project/{project_id}", response_class=HTMLResponse) -def view_project(request: Request, project_id: str, draft_version: int = 1): - webauth.require_auth(request) - from src.clickhouse import client as ch - from src.analytics import queries - from src.agent.tools.note_tools import build_checklist - - projects = _get_projects() - esc_project_id = project_id.replace("'", "''") - try: - notes = ch.run_query( - f"SELECT * FROM script_notes_matrix.notes_raw " - f"WHERE project_id = '{esc_project_id}' AND draft_version = {int(draft_version)} " - f"ORDER BY scene_number, severity DESC" - ) - conflicts = ch.run_query( - f"SELECT * FROM script_notes_matrix.notes_conflicts " - f"WHERE project_id = '{esc_project_id}' AND draft_version = {int(draft_version)} " - f"ORDER BY scene_number" - ) - analytics = queries.project_analytics(project_id, draft_version) - except Exception as exc: - print(f"Error querying project {project_id}: {exc}") - notes, conflicts, analytics = [], [], {} - checklist = build_checklist(notes, conflicts) - title = project_id.replace('-', ' ').title() - - return templates.TemplateResponse( - request=request, - name="index.html", - context={ - "request": request, - "projects": projects, - "selected_project_id": project_id, - "title": title, - "notes": notes, - "conflicts": conflicts, - "analytics": analytics, - "checklist": checklist, - "result": None, - "n_lines": len(notes), - } - ) +@app.post("/api/analyze") +async def analyze( + request: Request, + file: UploadFile = File(...), + title: str = Form("Untitled draft"), +): + """Upload a PDF/email, run the agent, persist to ClickHouse, return results. + Auth: JWT required (Bearer token in Authorization header). + Content-Type: multipart/form-data. + """ + webauth.require_jwt(request) -@app.post("/analyze", response_class=HTMLResponse) -async def analyze(request: Request, file: UploadFile = File(...), title: str = Form("Untitled draft")): - webauth.require_auth(request) - # Persist upload to a temp path, parse, run the agent, render results. + import asyncio import tempfile suffix = ".pdf" if file.filename.lower().endswith(".pdf") else ".eml" @@ -240,17 +350,13 @@ async def analyze(request: Request, file: UploadFile = File(...), title: str = F raw_lines = parse_pdf(tmp) if suffix == ".pdf" else parse_email(tmp) try: - import asyncio - engine_id = os.getenv("AGENT_ENGINE_ID") async def _go() -> str: if engine_id: from vertexai import agent_engines + remote = agent_engines.get(engine_id) - # stream_query expects message as str or dict, NOT a google.genai - # types.Content object. Use the same text format the in-process runner - # and run_agent_demo.py use so the agent sees identical input either way. message = f"Title: {title}\nFeedback file lines:\n" + "\n".join(raw_lines) out = "" for event in remote.stream_query(message=message, user_id="web"): @@ -262,14 +368,20 @@ async def _go() -> str: out += event.text if not out.strip(): from src.clickhouse import client as ch + out = _fallback_summary(ch.slugify_project(title)) return out + from google.adk.runners import InMemoryRunner from google.genai import types content = types.Content( role="user", - parts=[types.Part(text=f"Title: {title}\nFeedback file lines:\n" + "\n".join(raw_lines))], + parts=[ + types.Part( + text=f"Title: {title}\nFeedback file lines:\n" + "\n".join(raw_lines) + ) + ], ) agent = _get_agent() runner = InMemoryRunner(agent=agent, app_name="script_matrix") @@ -277,13 +389,16 @@ async def _go() -> str: app_name="script_matrix", user_id="web" ) out = "" - for event in runner.run(session_id=session.id, user_id="web", new_message=content): + async for event in runner.run( + session_id=session.id, user_id="web", new_message=content + ): if event.content: for p in event.content.parts or []: if getattr(p, "text", None): out += p.text if not out.strip(): from src.clickhouse import client as ch + out = _fallback_summary(ch.slugify_project(title)) return out @@ -299,62 +414,94 @@ async def _go() -> str: ) os.unlink(tmp) - # Deterministic persistence to ClickHouse (Cloud or chDB) - from src.clickhouse import client as ch - project_id = ch.slugify_project(title) + # Persist to ClickHouse + project_id = _slugify_project(title) try: from src.agent.tools.note_tools import persist_from_raw + persist_from_raw(title, raw_lines, source_type="producer_email") except Exception as exc: # noqa: BLE001 print(f"ClickHouse write skipped: {exc}") - # Fetch notes, conflicts, and analytics for the template - from src.analytics import queries - from src.agent.tools.note_tools import build_checklist - - esc_project_id = project_id.replace("'", "''") + # Fetch notes, conflicts, analytics, benchmarks, checklist try: + from src.analytics import queries + from src.agent.tools.note_tools import build_checklist + from src.clickhouse import client as ch + + esc = project_id.replace("'", "''") notes = ch.run_query( f"SELECT * FROM script_notes_matrix.notes_raw " - f"WHERE project_id = '{esc_project_id}' AND draft_version = 1 " + f"WHERE project_id = '{esc}' AND draft_version = 1 " f"ORDER BY scene_number, severity DESC" ) conflicts = ch.run_query( f"SELECT * FROM script_notes_matrix.notes_conflicts " - f"WHERE project_id = '{esc_project_id}' AND draft_version = 1 " + f"WHERE project_id = '{esc}' AND draft_version = 1 " f"ORDER BY scene_number" ) analytics = queries.project_analytics(project_id, 1) benchmarks = queries.cross_project_benchmarks() - except Exception as exc: + checklist = build_checklist(notes, conflicts) + except Exception as exc: # noqa: BLE001 print(f"Error querying analyzed project: {exc}") - notes, conflicts, analytics, benchmarks = [], [], {}, {} + notes, conflicts, analytics, benchmarks, checklist = [], [], {}, {}, [] - checklist = build_checklist(notes, conflicts) - projects = _get_projects() - - return templates.TemplateResponse( - request=request, - name="index.html", - context={ - "request": request, - "projects": projects, - "selected_project_id": project_id, + return JSONResponse( + content={ + "project_id": project_id, "title": title, + "result": answer, "notes": notes, "conflicts": conflicts, "analytics": analytics, "benchmarks": benchmarks, "checklist": checklist, - "result": answer, "n_lines": len(raw_lines), - }, + "projects": _get_projects(), + } ) +@app.get("/api/projects") +async def list_projects(request: Request): + """List all previously processed projects from ClickHouse. + + Auth: JWT required. + """ + webauth.require_jwt(request) + return JSONResponse(content=_get_projects()) + + +@app.get("/api/project/{project_id}") +async def view_project( + request: Request, + project_id: str, + draft_version: int = 1, +): + """Get full project detail: notes, conflicts, analytics, checklist. + + Auth: JWT required. + Query: ?draft_version=N (default 1). + """ + webauth.require_jwt(request) + data = _query_project(project_id, draft_version) + data["title"] = project_id.replace("-", " ").title() + data["project_id"] = project_id + data["draft_version"] = draft_version + return JSONResponse(content=data) + + @app.post("/api/export/fdx") -async def export_fdx_endpoint(payload: dict): - """Export the Draft-2 revision matrix as a .fdx (Final Draft XML) file.""" +async def export_fdx_endpoint(request: Request, payload: dict): + """Export the Draft-2 revision matrix as a .fdx (Final Draft XML) file. + + Auth: JWT required. + Body: { revision_checklist, agent_text?, fdx_content? } + Returns: binary XML with Content-Disposition: attachment. + """ + webauth.require_jwt(request) + from src.exporters.fdx import ( inject_matrix_notes_to_fdx, generate_standalone_fdx_notes_summary, @@ -381,11 +528,21 @@ async def export_fdx_endpoint(payload: dict): return Response( content=xml_out, media_type="application/xml", - headers={"Content-Disposition": "attachment; filename=Draft2_Revision_Matrix.fdx"}, + headers={ + "Content-Disposition": "attachment; filename=Draft2_Revision_Matrix.fdx" + }, ) +# --------------------------------------------------------------------------- +# Dev entry point (same as original) +# --------------------------------------------------------------------------- + if __name__ == "__main__": import uvicorn - uvicorn.run(app, host=os.getenv("WEB_HOST", "0.0.0.0"), port=int(os.getenv("WEB_PORT", "8080"))) + uvicorn.run( + app, + host=os.getenv("WEB_HOST", "0.0.0.0"), + port=int(os.getenv("WEB_PORT", "8080")), + ) diff --git a/src/web/auth.py b/src/web/auth.py index 919a32b..05fac50 100644 --- a/src/web/auth.py +++ b/src/web/auth.py @@ -1,19 +1,24 @@ -"""Google OAuth 2.0 login gate for the Agentic Cinema web app (board task t_5e9f2ba8). - -Standard Google Identity sign-in for this Google Cloud + Gemini + ClickHouse -screenwriting agent, built on the existing server-rendered Jinja / FastAPI stack. - -Auth model: - - Google OAuth 2.0 Authorization Code flow (server-side, no implicit/client-only). - - On callback we verify Google's ID token with google.oauth2.id_token (against - our GOOGLE_OAUTH_CLIENT_ID and the accounts.google.com issuer) and read the - user's email; we set a signed, http-only session cookie. /logout clears it. - - require_auth() is a FastAPI dependency that 302-redirects unauthenticated - requests to /login. - - If GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET are unset, auth is - DISABLED (the app is open) so local dev / demos without credentials work. - - Optional allow-list: if GOOGLE_ALLOWED_EMAILS is set (comma-separated), only - those Google accounts may sign in (everyone else is rejected after OAuth). +"""Google OAuth 2.0 login gate + JWT token layer for the Agentic Cinema web app (board task t_5e9f2ba8). + +Supports TWO auth modes depending on deployment: + + SAME-ORIGIN (Cloud Run alone, pre-split): + The existing signed http-only session cookie (_COOKIE_NAME) is set on the + Cloud Run domain and FastAPI's SessionMiddleware carries the OAuth state. + require_auth() redirects to /login when the cookie is absent/invalid. + + CROSS-ORIGIN (Vercel frontend + Cloud Run backend, post-split): + Google OAuth still starts and finishes on the Cloud Run backend, but after + the callback verifies the ID token we additionally emit a short-lived JWT + (HS256, 1h) and redirect the browser to the Vercel frontend's callback page + with ?token= in the URL. The frontend stores the JWT and sends it as + Authorization: Bearer on every API call. Protected endpoints use + require_jwt() (a FastAPI dependency) instead of require_auth() — they return + 401 JSON, not a 307 redirect. + +Both modes can coexist: same-origin callers still get the cookie; cross-origin +callers use the JWT. require_auth() is for the HTML-era routes (kept for any +in-place Cloud Run use); require_jwt() is for the new /api/* JSON routes. """ from __future__ import annotations @@ -22,16 +27,24 @@ import os import secrets import time +from functools import wraps +from typing import Callable -from fastapi import Request, HTTPException +import jwt as pyjwt +from fastapi import Request, HTTPException, status from starlette.responses import Response # noqa: F401 (re-exported for app.py) from authlib.integrations.starlette_client import OAuth # OAuth client _COOKIE_NAME = "ac_session" -_MAX_AGE = 60 * 60 * 12 # 12h +_MAX_AGE = 60 * 60 * 12 # 12h session cookie _CLOCK_SKEW = 60 +# --- JWT constants (cross-origin auth for Vercel frontend) --- +JWT_ISSUER = "agentic-cinema" +JWT_AUDIENCE = "agentic-cinema-frontend" +JWT_LIFETIME_SEC = 60 * 60 # 1 hour + # Google OAuth 2.0 identity provider. _oauth = OAuth() _oauth.register( @@ -40,6 +53,7 @@ client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID", ""), client_secret=os.getenv("GOOGLE_OAUTH_CLIENT_SECRET", ""), client_kwargs={"scope": "openid email profile"}, + redirect_uri=os.getenv("OAUTH_REDIRECT_URI", ""), ) @@ -103,7 +117,7 @@ def clear_session(response: Response) -> None: def get_user(request: Request) -> str | None: - """Return the signed-in user's email, or None.""" + """Return the signed-in user's email from the session cookie, or None.""" if not _auth_enabled(): return "local-dev" # auth disabled -> treated as a benign pseudo-user return _verify_token(request.cookies.get(_COOKIE_NAME)) @@ -114,7 +128,7 @@ def is_authenticated(request: Request) -> bool: def require_auth(request: Request) -> None: - """FastAPI dependency: redirect to /login when not authenticated.""" + """FastAPI dependency: redirect to /login when not authenticated (same-origin).""" if not is_authenticated(request): raise HTTPException( status_code=307, @@ -123,6 +137,89 @@ def require_auth(request: Request) -> None: ) +# --- JWT helpers (cross-origin auth for Vercel frontend) --- + +def _jwt_secret() -> str: + """JWT signing secret. Must be a non-empty string at runtime when auth is enabled.""" + return os.getenv("JWT_SECRET", "") or os.getenv("GOOGLE_OAUTH_CLIENT_SECRET", "") + + +def make_jwt_token(email: str, extra_claims: dict | None = None) -> str: + """Issue a short-lived HS256 JWT for the given email. + + The token is meant to be sent to the Vercel frontend (via ?token= in a redirect + URL) and then used as Authorization: Bearer on API calls. 1-hour lifetime. + """ + now = int(time.time()) + payload = { + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "sub": email, + "email": email, + "iat": now, + "exp": now + JWT_LIFETIME_SEC, + "jti": secrets.token_hex(8), + } + if extra_claims: + payload.update(extra_claims) + secret = _jwt_secret() + if not secret: + raise RuntimeError( + "JWT_SECRET (or GOOGLE_OAUTH_CLIENT_SECRET) not configured — cannot sign JWT" + ) + return pyjwt.encode(payload, secret, algorithm="HS256") + + +def verify_jwt_token(token: str) -> dict | None: + """Validate a JWT and return its payload, or None if invalid/expired.""" + secret = _jwt_secret() + if not secret or not token: + return None + try: + payload = pyjwt.decode( + token, + secret, + algorithms=["HS256"], + issuer=JWT_ISSUER, + audience=JWT_AUDIENCE, + options={"require": ["iss", "aud", "sub", "exp", "iat"]}, + ) + except pyjwt.PyJWTError: + return None + return payload + + +def get_jwt_email(request: Request) -> str | None: + """Extract and verify the JWT from the Authorization: Bearer header.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + payload = verify_jwt_token(token) + if payload is None: + return None + return payload.get("email") or payload.get("sub") + + +def require_jwt(request: Request) -> None: + """FastAPI dependency for JSON API routes: return 401 if no valid JWT.""" + if get_jwt_email(request) is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated. Sign in with Google first.", + ) + + +def jwt_protected(func: Callable) -> Callable: + """Wrap a path-operation function so it requires a valid JWT (401 on failure).""" + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + require_jwt(request) + return await func(request, *args, **kwargs) + + return wrapper + + def google_oauth_client(): """Expose the configured authlib OAuth client (for app.py route handlers).""" return _oauth From 95ae5411a479c05d674fa1f60fe61da3011db868 Mon Sep 17 00:00:00 2001 From: asifdotpy Date: Sat, 15 Aug 2026 22:28:26 +0600 Subject: [PATCH 2/5] fix: use google.auth.transport.requests.Request (urllib3 version removed) --- src/web/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/app.py b/src/web/app.py index 3b9b1e0..1590586 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -262,7 +262,7 @@ async def auth_google_callback(request: Request): ) from google.oauth2 import id_token - from google.auth.transport.urllib3 import Request as GoogleRequest + from google.auth.transport.requests import Request as GoogleRequest try: claim = id_token.verify_oauth2_token( From 54544c8e1c626cfbae81dcc8a3abdcd9a1898e6e Mon Sep 17 00:00:00 2001 From: asifdotpy Date: Sat, 15 Aug 2026 23:27:59 +0600 Subject: [PATCH 3/5] fix: place middleware after app creation, add redirect_uri to OAuth client --- .gitignore | 80 +++++++++++++++++++++++++++++++------------------- src/web/app.py | 9 ++++++ 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 085eaff..9147ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,62 @@ -# Python +# --- Python --- __pycache__/ *.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ *.egg-info/ -.venv/ -venv/ +.installed.cfg +*.egg -# Env / secrets +# --- Virtual environments --- +venv/ +.venv/ +env/ .env -.env.* -!.env.example -credentials.json -*.key -# Editor / OS -.DS_Store -.idea/ +# --- IDE --- .vscode/ +.idea/ +*.swp +*.swo +*~ -# Build -__pycache__/ -*.so -*.log -.pytest_cache/ -build/ +# --- OS --- +.DS_Store +Thumbs.db -# Local data / uploads -uploads/ -data/ -*.pdf -*.eml -# Keep the demo/test fixture (the report + run_agent_demo.py depend on it) -!tests/sample_feedback.eml +# --- Testing --- +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ -# Hermes local planning state (plans, project cache) — not submission code -.hermes/ +# --- mypy --- +.mypy_cache/ +.dmypy.json +dmypy.json -# Hermes kanban task worktrees (Hermes-internal, not submission code) -.worktrees/ +# --- Frontend --- +frontend/.next/ +frontend/node_modules/ +frontend/.vercel/ -# Reference vendor repos (kept out of the submission) -references/ +# --- Secrets (NEVER commit these) --- +*.key.json +sa-key.json +*.pem +*.p12 .sa-key.json -*.sa-key.json diff --git a/src/web/app.py b/src/web/app.py index 1590586..b1d13f8 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -44,6 +44,15 @@ app = FastAPI(title="Script Notes-to-Outline Matrix Agent API") + +@app.middleware("http") +async def https_scheme(request: Request, call_next): + """Force https scheme for OAuth — Cloud Run terminates TLS and forwards as http.""" + if request.headers.get("x-forwarded-proto") == "https": + request.scope["scheme"] = "https" + response = await call_next(request) + return response + # --------------------------------------------------------------------------- # CORS — frontend on Vercel (*.vercel.app), backend on Cloud Run (*.run.app) # --------------------------------------------------------------------------- From d701b3fdc61bc9962f3958a9b33d57f91ba338ff Mon Sep 17 00:00:00 2001 From: asifdotpy Date: Fri, 28 Aug 2026 01:31:42 +0600 Subject: [PATCH 4/5] debug: add logging to OAuth callback --- src/web/app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/web/app.py b/src/web/app.py index b1d13f8..82ef2be 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -255,6 +255,12 @@ async def auth_google_callback(request: Request): On failure: redirect to the frontend login page with an error message. """ + import logging + logger = logging.getLogger(__name__) + logger.info(f"OAuth callback called. Query params: {dict(request.query_params)}") + logger.info(f"Session cookie present: {'session' in request.cookies}") + logger.info(f"Headers: {dict(request.headers)}") + if not webauth._auth_enabled(): return RedirectResponse( url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error=oauth_disabled", @@ -265,6 +271,7 @@ async def auth_google_callback(request: Request): idt = token.get("id_token") if not idt: err = "No ID token returned from Google." + logger.error(f"OAuth callback error: {err}") return RedirectResponse( url=f"{os.getenv('FRONTEND_URL', '/dummy').rstrip('/')}/login?error={err}", status_code=303, From 2f79bb0ee87f5042427c36181fb96f088571c17c Mon Sep 17 00:00:00 2001 From: asifdotpy Date: Sun, 16 Aug 2026 00:09:24 +0600 Subject: [PATCH 5/5] fix: allow cross-origin session cookies (samesite=none, secure=true) --- src/web/app.py | 2 ++ src/web/auth.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/web/app.py b/src/web/app.py index 82ef2be..a38c449 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -86,6 +86,8 @@ async def https_scheme(request: Request, call_next): or os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") or "dev-insecure-session-secret" ), + same_site="none", + https_only=True, ) diff --git a/src/web/auth.py b/src/web/auth.py index 05fac50..33080ce 100644 --- a/src/web/auth.py +++ b/src/web/auth.py @@ -107,7 +107,8 @@ def set_session(response: Response, email: str) -> None: _make_token(email), max_age=_MAX_AGE, httponly=True, - samesite="lax", + samesite="none", + secure=True, path="/", )