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:
jay
2026-06-03 01:31:52 +00:00
parent 28dc79d0b7
commit b635d8f574
5 changed files with 280 additions and 5 deletions
+48 -3
View File
@@ -35,10 +35,21 @@
{:else}
<h2>Sign in to Upbeat Bytes</h2>
<p class="sub">
Save articles and keep your history across devices. We'll email you a one-time
link — no password to remember.
Save articles and keep your history across devices.
</p>
<a class="google" href="/api/auth/google/start">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.46.9 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
</svg>
Continue with Google
</a>
<div class="or"><span>or email me a one-time link — no password</span></div>
<form onsubmit={submit}>
<input
type="email"
@@ -53,7 +64,6 @@
</form>
{#if status === 'error'}<p class="err">{error}</p>{/if}
<!-- Phase 2: "Continue with Google" button slots in here -->
<p class="fine">No account needed to browse — signing in just saves your stuff.</p>
{/if}
</div>
@@ -135,6 +145,41 @@
opacity: 0.6;
cursor: default;
}
.google {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 11px 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--ink);
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.14s ease, border-color 0.14s ease;
}
.google:hover {
background: #f7f7f5;
border-color: #d9d3c6;
}
.or {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
color: var(--muted);
font-size: 0.78rem;
}
.or::before,
.or::after {
content: '';
flex: 1;
height: 1px;
background: var(--line);
}
.err {
margin: 10px 0 0;
color: #9a3b3b;
+7 -1
View File
@@ -7,7 +7,13 @@
let error = $state('');
onMount(async () => {
const token = new URLSearchParams(window.location.search).get('token');
const params = new URLSearchParams(window.location.search);
if (params.get('error') === 'google') {
status = 'error';
error = "Google sign-in didn't complete. Please try again.";
return;
}
const token = params.get('token');
if (!token) {
status = 'error';
error = 'This sign-in link is missing its token.';
+73 -1
View File
@@ -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(
+101
View File
@@ -0,0 +1,101 @@
"""Google Sign-In (OAuth 2.0 / OpenID Connect) — stdlib only.
Authorization-code flow with PKCE. The ID token is received directly from
Google's token endpoint over TLS (server-to-server), so per Google's own
guidance we validate its claims (iss/aud/exp/email_verified) without re-verifying
the RS256 signature — no JWT/JWKS dependency required.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import urllib.parse
import urllib.request
from datetime import datetime, timezone
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
_VALID_ISS = {"accounts.google.com", "https://accounts.google.com"}
def configured() -> bool:
return bool(
os.environ.get("GOODNEWS_GOOGLE_CLIENT_ID")
and os.environ.get("GOODNEWS_GOOGLE_CLIENT_SECRET")
)
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def new_pkce() -> tuple[str, str]:
"""Return (code_verifier, code_challenge) for S256 PKCE."""
verifier = _b64url(secrets.token_bytes(48))
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def auth_url(redirect_uri: str, state: str, code_challenge: str) -> str:
params = {
"client_id": os.environ["GOODNEWS_GOOGLE_CLIENT_ID"],
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"access_type": "online",
"prompt": "select_account",
}
return AUTH_URL + "?" + urllib.parse.urlencode(params)
def exchange_code(code: str, redirect_uri: str, code_verifier: str) -> dict:
data = urllib.parse.urlencode(
{
"code": code,
"client_id": os.environ["GOODNEWS_GOOGLE_CLIENT_ID"],
"client_secret": os.environ["GOODNEWS_GOOGLE_CLIENT_SECRET"],
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
"code_verifier": code_verifier,
}
).encode("ascii")
request = urllib.request.Request(
TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
)
with urllib.request.urlopen(request, timeout=15) as response:
return json.loads(response.read())
def _decode_jwt_payload(token: str) -> dict:
parts = token.split(".")
if len(parts) != 3:
raise ValueError("malformed id_token")
payload = parts[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
def verify_id_token(id_token: str) -> dict:
"""Validate an ID token's claims (not its signature — see module docstring).
Returns {"sub", "email", "name"} or raises ValueError.
"""
claims = _decode_jwt_payload(id_token)
if claims.get("iss") not in _VALID_ISS:
raise ValueError("unexpected issuer")
if claims.get("aud") != os.environ.get("GOODNEWS_GOOGLE_CLIENT_ID"):
raise ValueError("audience mismatch")
if datetime.now(timezone.utc).timestamp() > float(claims.get("exp", 0)):
raise ValueError("id_token expired")
if not claims.get("email") or claims.get("email_verified") not in (True, "true"):
raise ValueError("email not verified")
return {"sub": str(claims["sub"]), "email": claims["email"], "name": claims.get("name")}
+51
View File
@@ -0,0 +1,51 @@
import base64
import hashlib
import json
import pytest
from goodnews import oauth_google as g
def _seg(d: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
def _token(claims: dict) -> str:
return f"{_seg({'alg': 'RS256'})}.{_seg(claims)}.sig"
def test_pkce_challenge_matches_verifier():
verifier, challenge = g.new_pkce()
expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
assert challenge == expected
def test_auth_url_has_required_params(monkeypatch):
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
url = g.auth_url("https://x/cb", "state123", "chal")
for needle in ["client_id=cid", "redirect_uri=https", "state=state123",
"code_challenge=chal", "code_challenge_method=S256", "scope=openid"]:
assert needle in url
def test_verify_id_token_happy(monkeypatch):
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
tok = _token({"iss": "https://accounts.google.com", "aud": "cid", "exp": 9999999999,
"sub": "g-123", "email": "a@b.com", "email_verified": True, "name": "A"})
info = g.verify_id_token(tok)
assert info == {"sub": "g-123", "email": "a@b.com", "name": "A"}
def test_verify_id_token_rejects(monkeypatch):
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
base = {"iss": "https://accounts.google.com", "aud": "cid", "exp": 9999999999,
"sub": "g", "email": "a@b.com", "email_verified": True}
with pytest.raises(ValueError): # wrong audience
g.verify_id_token(_token({**base, "aud": "other"}))
with pytest.raises(ValueError): # bad issuer
g.verify_id_token(_token({**base, "iss": "evil.com"}))
with pytest.raises(ValueError): # expired
g.verify_id_token(_token({**base, "exp": 1}))
with pytest.raises(ValueError): # email not verified
g.verify_id_token(_token({**base, "email_verified": False}))