Accounts Phase 2: Google sign-in (OAuth 2.0 / OIDC)
- oauth_google.py (stdlib): PKCE, auth URL, code exchange, ID-token claim validation (iss/aud/exp/email_verified — token comes straight from Google's token endpoint over TLS, so no signature re-verify / JWKS needed). - API: GET /api/auth/google/start (302 to Google, PKCE + signed state cookie binding the flow to the browser) and /callback (CSRF-checked state, exchange, find-or-create by verified email → links to an existing magic-link account, session cookie, redirect home). Errors land on /auth/verify?error=google. - SignIn modal: "Continue with Google" + an "or email link" divider. - 112 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+73
-1
@@ -13,9 +13,12 @@ so the API and CLI always read the same file.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from contextlib import contextmanager
|
||||
@@ -24,10 +27,11 @@ from pathlib import Path
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, email_send, feeds, queries
|
||||
from . import auth, email_send, feeds, oauth_google, queries
|
||||
from .db import connect
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .hero import safe_to_lead
|
||||
@@ -52,12 +56,31 @@ def db_path() -> Path:
|
||||
|
||||
PUBLIC_BASE_URL = os.environ.get("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com").rstrip("/")
|
||||
SESSION_COOKIE = "ub_session"
|
||||
OAUTH_COOKIE = "ub_oauth"
|
||||
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds())
|
||||
SESSION_SECRET = os.environ.get("GOODNEWS_SESSION_SECRET", "dev-insecure-secret")
|
||||
# Secure cookies in production (https); off for http (local/test) so they round-trip.
|
||||
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _sign(value: str) -> str:
|
||||
sig = hmac.new(SESSION_SECRET.encode(), value.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{value}.{sig}"
|
||||
|
||||
|
||||
def _unsign(signed: str | None) -> str | None:
|
||||
if not signed or "." not in signed:
|
||||
return None
|
||||
value, _, sig = signed.rpartition(".")
|
||||
expected = hmac.new(SESSION_SECRET.encode(), value.encode(), hashlib.sha256).hexdigest()
|
||||
return value if hmac.compare_digest(sig, expected) else None
|
||||
|
||||
|
||||
def _google_redirect_uri() -> str:
|
||||
return f"{PUBLIC_BASE_URL}/api/auth/google/callback"
|
||||
|
||||
|
||||
def _session_token_from_request(request: Request) -> str | None:
|
||||
"""Web sends the session as an httpOnly cookie; the app sends a bearer token."""
|
||||
cookie = request.cookies.get(SESSION_COOKIE)
|
||||
@@ -348,6 +371,55 @@ def create_app() -> FastAPI:
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
# --- Auth: Google (OAuth 2.0 / OIDC) ----------------------------------
|
||||
|
||||
@app.get("/api/auth/google/start")
|
||||
def google_start() -> RedirectResponse:
|
||||
if not oauth_google.configured():
|
||||
raise HTTPException(status_code=503, detail="Google sign-in isn't configured.")
|
||||
state = secrets.token_urlsafe(24)
|
||||
verifier, challenge = oauth_google.new_pkce()
|
||||
url = oauth_google.auth_url(_google_redirect_uri(), state, challenge)
|
||||
resp = RedirectResponse(url, status_code=302)
|
||||
# Bind the flow to this browser; read back (and CSRF-checked) on callback.
|
||||
resp.set_cookie(
|
||||
OAUTH_COOKIE, _sign(f"{state}:{verifier}"), max_age=600,
|
||||
httponly=True, secure=_COOKIE_SECURE, samesite="lax", path="/",
|
||||
)
|
||||
return resp
|
||||
|
||||
@app.get("/api/auth/google/callback")
|
||||
def google_callback(
|
||||
request: Request,
|
||||
code: str | None = None,
|
||||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
fail = RedirectResponse(f"{PUBLIC_BASE_URL}/auth/verify?error=google", status_code=302)
|
||||
if error or not code or not state:
|
||||
return fail
|
||||
saved = _unsign(request.cookies.get(OAUTH_COOKIE))
|
||||
if not saved:
|
||||
return fail
|
||||
saved_state, _, verifier = saved.partition(":")
|
||||
if not hmac.compare_digest(saved_state, state):
|
||||
return fail
|
||||
try:
|
||||
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
|
||||
info = oauth_google.verify_id_token(tokens["id_token"])
|
||||
except Exception:
|
||||
return fail
|
||||
with get_conn() as conn:
|
||||
user_id = auth.find_or_create_user(
|
||||
conn, info["email"], "google", info["sub"], display_name=info.get("name")
|
||||
)
|
||||
token = auth.create_session(conn, user_id, user_agent=request.headers.get("User-Agent"))
|
||||
conn.commit()
|
||||
ok = RedirectResponse(f"{PUBLIC_BASE_URL}/", status_code=302)
|
||||
_set_session_cookie(ok, token)
|
||||
ok.delete_cookie(OAUTH_COOKIE, path="/")
|
||||
return ok
|
||||
|
||||
@app.get("/api/categories", response_model=CategoriesResponse)
|
||||
def categories() -> CategoriesResponse:
|
||||
return CategoriesResponse(
|
||||
|
||||
Reference in New Issue
Block a user