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} {:else}
<h2>Sign in to Upbeat Bytes</h2> <h2>Sign in to Upbeat Bytes</h2>
<p class="sub"> <p class="sub">
Save articles and keep your history across devices. We'll email you a one-time Save articles and keep your history across devices.
link — no password to remember.
</p> </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}> <form onsubmit={submit}>
<input <input
type="email" type="email"
@@ -53,7 +64,6 @@
</form> </form>
{#if status === 'error'}<p class="err">{error}</p>{/if} {#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> <p class="fine">No account needed to browse — signing in just saves your stuff.</p>
{/if} {/if}
</div> </div>
@@ -135,6 +145,41 @@
opacity: 0.6; opacity: 0.6;
cursor: default; 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 { .err {
margin: 10px 0 0; margin: 10px 0 0;
color: #9a3b3b; color: #9a3b3b;
+7 -1
View File
@@ -7,7 +7,13 @@
let error = $state(''); let error = $state('');
onMount(async () => { 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) { if (!token) {
status = 'error'; status = 'error';
error = 'This sign-in link is missing its token.'; 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 from __future__ import annotations
import hashlib
import hmac
import json import json
import os import os
import re import re
import secrets
import sqlite3 import sqlite3
from collections import Counter from collections import Counter
from contextlib import contextmanager from contextlib import contextmanager
@@ -24,10 +27,11 @@ from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel 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 .db import connect
from .filters import filter_articles, prefs_from_json from .filters import filter_articles, prefs_from_json
from .hero import safe_to_lead 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("/") PUBLIC_BASE_URL = os.environ.get("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com").rstrip("/")
SESSION_COOKIE = "ub_session" SESSION_COOKIE = "ub_session"
OAUTH_COOKIE = "ub_oauth"
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds()) 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. # Secure cookies in production (https); off for http (local/test) so they round-trip.
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https") _COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _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: def _session_token_from_request(request: Request) -> str | None:
"""Web sends the session as an httpOnly cookie; the app sends a bearer token.""" """Web sends the session as an httpOnly cookie; the app sends a bearer token."""
cookie = request.cookies.get(SESSION_COOKIE) cookie = request.cookies.get(SESSION_COOKIE)
@@ -348,6 +371,55 @@ def create_app() -> FastAPI:
response.delete_cookie(SESSION_COOKIE, path="/") response.delete_cookie(SESSION_COOKIE, path="/")
return {"ok": True} 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) @app.get("/api/categories", response_model=CategoriesResponse)
def categories() -> CategoriesResponse: def categories() -> CategoriesResponse:
return 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}))