diff --git a/frontend/src/lib/components/SignIn.svelte b/frontend/src/lib/components/SignIn.svelte index fb8cf2f..786ffd4 100644 --- a/frontend/src/lib/components/SignIn.svelte +++ b/frontend/src/lib/components/SignIn.svelte @@ -35,10 +35,21 @@ {:else}

Sign in to Upbeat Bytes

- 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.

+ + + Continue with Google + + +
or email me a one-time link — no password
+
{#if status === 'error'}

{error}

{/if} -

No account needed to browse — signing in just saves your stuff.

{/if} @@ -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; diff --git a/frontend/src/routes/auth/verify/+page.svelte b/frontend/src/routes/auth/verify/+page.svelte index b83bb5c..7218c3b 100644 --- a/frontend/src/routes/auth/verify/+page.svelte +++ b/frontend/src/routes/auth/verify/+page.svelte @@ -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.'; diff --git a/goodnews/api.py b/goodnews/api.py index 986ce20..b9d0a5c 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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( diff --git a/goodnews/oauth_google.py b/goodnews/oauth_google.py new file mode 100644 index 0000000..cb9f666 --- /dev/null +++ b/goodnews/oauth_google.py @@ -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")} diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..0676def --- /dev/null +++ b/tests/test_oauth.py @@ -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}))