From d2ae56dc6534d8cb9783363a8bf2f4ff12fe52fb Mon Sep 17 00:00:00 2001 From: jay Date: Wed, 3 Jun 2026 01:08:33 +0000 Subject: [PATCH] Accounts Phase 1b: magic-link auth endpoints + sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/auth/email/start — validate email, rate-limit, email a single-use magic link (identical reply regardless, so no account enumeration). - POST /api/auth/email/verify — consume token, find-or-create user, open a session, set an httpOnly cookie (web) and return a bearer token (app). - GET /api/auth/me, POST /api/auth/logout. - Session resolved from cookie OR Authorization: Bearer; cookie is Secure in prod (https), relaxed for http so tests round-trip. CORS now allows POST. Live SMTP send verified against the DNSExit relay (587/STARTTLS). 108 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- goodnews/api.py | 116 +++++++++++++++++++++++++++++++++++++++-- tests/test_auth_api.py | 69 ++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/test_auth_api.py diff --git a/goodnews/api.py b/goodnews/api.py index 98a8b36..df489f1 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -22,12 +22,12 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from . import feeds, queries +from . import auth, email_send, feeds, queries from .db import connect from .filters import filter_articles, prefs_from_json from .hero import safe_to_lead @@ -48,6 +48,39 @@ def db_path() -> Path: return Path(os.environ.get("GOODNEWS_DB", str(DEFAULT_DB))) +# --- Auth helpers ----------------------------------------------------------- + +PUBLIC_BASE_URL = os.environ.get("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com").rstrip("/") +SESSION_COOKIE = "ub_session" +SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds()) +# 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 _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) + if cookie: + return cookie + authz = request.headers.get("Authorization", "") + return authz[7:].strip() if authz.startswith("Bearer ") else None + + +def _current_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row | None: + user = auth.resolve_session(conn, _session_token_from_request(request)) + if user: + conn.commit() # persist the last_seen touch + return user + + +def _set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + SESSION_COOKIE, token, max_age=SESSION_MAX_AGE, + httponly=True, secure=_COOKIE_SECURE, samesite="lax", path="/", + ) + + @contextmanager def get_conn(): conn = connect(db_path()) @@ -201,6 +234,25 @@ class SourcePreview(BaseModel): examples_rejected: list[RejectedExample] +class EmailStartRequest(BaseModel): + email: str + + +class TokenVerifyRequest(BaseModel): + token: str + + +class UserOut(BaseModel): + id: int + email: str + display_name: str | None = None + + +class SessionOut(BaseModel): + user: UserOut + token: str # for non-browser (app) clients; the web SPA uses the cookie + + # --- App -------------------------------------------------------------------- @@ -215,7 +267,7 @@ def create_app() -> FastAPI: app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET"], + allow_methods=["GET", "POST"], allow_headers=["*"], ) @@ -230,6 +282,64 @@ def create_app() -> FastAPI: scored = 0 return {"status": "ok", "scored_articles": scored} + # --- Auth: passwordless magic link (Google added in Phase 2) ---------- + + @app.post("/api/auth/email/start") + def auth_email_start(body: EmailStartRequest) -> dict: + email = auth.normalize_email(body.email) + if not _EMAIL_RE.match(email): + raise HTTPException(status_code=422, detail="Please enter a valid email address.") + with get_conn() as conn: + # Light abuse guard: cap recent tokens per address (still reply OK). + recent = conn.execute( + "SELECT COUNT(*) FROM login_tokens WHERE email = ? " + "AND created_at > datetime('now', '-10 minutes')", + (email,), + ).fetchone()[0] + if recent < 5: + raw = auth.create_login_token(conn, email) + conn.commit() + link = f"{PUBLIC_BASE_URL}/auth/verify?token={raw}" + try: + email_send.send_magic_link(email, link) + except Exception: + pass # never leak send failures or whether the address exists + # Always identical (no account enumeration). + return {"ok": True} + + @app.post("/api/auth/email/verify", response_model=SessionOut) + def auth_email_verify(body: TokenVerifyRequest, request: Request, response: Response) -> SessionOut: + with get_conn() as conn: + email = auth.consume_login_token(conn, body.token) + if not email: + conn.commit() + raise HTTPException(status_code=400, detail="This sign-in link is invalid or has expired.") + user_id = auth.find_or_create_user(conn, email, "email", email) + token = auth.create_session(conn, user_id, user_agent=request.headers.get("User-Agent")) + conn.commit() + user = auth.get_user(conn, user_id) + _set_session_cookie(response, token) + return SessionOut( + user=UserOut(id=user["id"], email=user["email"], display_name=user["display_name"]), + token=token, + ) + + @app.get("/api/auth/me", response_model=UserOut | None) + def auth_me(request: Request) -> UserOut | None: + with get_conn() as conn: + user = _current_user(conn, request) + if not user: + return None + return UserOut(id=user["id"], email=user["email"], display_name=user["display_name"]) + + @app.post("/api/auth/logout") + def auth_logout(request: Request, response: Response) -> dict: + with get_conn() as conn: + auth.revoke_session(conn, _session_token_from_request(request)) + conn.commit() + response.delete_cookie(SESSION_COOKIE, path="/") + return {"ok": True} + @app.get("/api/categories", response_model=CategoriesResponse) def categories() -> CategoriesResponse: return CategoriesResponse( diff --git a/tests/test_auth_api.py b/tests/test_auth_api.py new file mode 100644 index 0000000..b6e0a06 --- /dev/null +++ b/tests/test_auth_api.py @@ -0,0 +1,69 @@ +import os + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path, monkeypatch): + db = tmp_path / "t.sqlite3" + monkeypatch.setenv("GOODNEWS_DB", str(db)) + # http base → cookies aren't Secure-only, so the TestClient round-trips them + monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver") + import importlib + import goodnews.api as api + importlib.reload(api) + from goodnews.db import connect, init_db + c = connect(str(db)); init_db(c); c.close() + return api.create_app(), api + + +def test_magic_link_end_to_end(client, monkeypatch): + app, api = client + sent = {} + # capture the link instead of sending real email + monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, link: sent.update(to=to, link=link)) + tc = TestClient(app) + + # not signed in + assert tc.get("/api/auth/me").json() is None + + # request a link + r = tc.post("/api/auth/email/start", json={"email": "Jay@Example.com"}) + assert r.status_code == 200 and r.json() == {"ok": True} + assert sent["to"] == "jay@example.com" + token = sent["link"].split("token=")[1] + + # verify → session established (cookie set), user created + r = tc.post("/api/auth/email/verify", json={"token": token}) + assert r.status_code == 200 + body = r.json() + assert body["user"]["email"] == "jay@example.com" and body["token"] + + # cookie now authenticates /me + me = tc.get("/api/auth/me").json() + assert me and me["email"] == "jay@example.com" + + # bearer token also works (app clients) + bare = TestClient(app) + me2 = bare.get("/api/auth/me", headers={"Authorization": f"Bearer {body['token']}"}).json() + assert me2["email"] == "jay@example.com" + + # reusing the magic link fails (single use) + assert tc.post("/api/auth/email/verify", json={"token": token}).status_code == 400 + + # logout clears the session + assert tc.post("/api/auth/logout").json() == {"ok": True} + assert tc.get("/api/auth/me").json() is None + + +def test_start_rejects_bad_email(client): + app, _ = client + tc = TestClient(app) + assert tc.post("/api/auth/email/start", json={"email": "nope"}).status_code == 422 + + +def test_verify_bad_token(client): + app, _ = client + tc = TestClient(app) + assert tc.post("/api/auth/email/verify", json={"token": "garbage"}).status_code == 400