diff --git a/CHANGELOG.md b/CHANGELOG.md index 0304297..a8f49c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,24 @@ a documentation / housekeeping commit on `main`. parity, truth-eval reduction vs the baseline, feasibility under the surrogate, screen-fraction monotonicity, and a truth-recompute on the returned best. +- **Phase 26 — OAuth callback + `User.tier` column** + (`src/ropeway/server/api.py`, `models.py`, `db.py`, `auth.py`, + `schemas.py`). + - New endpoint `GET /auth/oauth/{provider}/callback`: verifies the + state cookie set by `/login` against the `state` query parameter + (400 on mismatch — CSRF anchor), exchanges the authorization code + for an access token, fetches userinfo, upserts the local user, and + returns the same `TokenResponse` JWT the password endpoint mints. + - `User.tier` column (default `"community"`) — drives the + `server/billing.py` feature gates. Added with an idempotent + `ALTER TABLE` migration in `init_db` so legacy DBs read-compatible. + - JWT payload now carries `tier`; `/auth/me` and `UserRead` expose it. + - Repeat OAuth login **preserves** any existing tier — P27's Razorpay + webhook is the only thing allowed to mutate it. + - `tests/test_oauth_callback.py` — 10 new tests (legacy-DB migration, + register default tier, login sets state cookie, callback happy path, + repeat-login preserves tier, state mismatch / missing cookie 400, + unknown provider 404, unconfigured 503, no-email-from-provider 502). ### Fixed diff --git a/src/ropeway/server/api.py b/src/ropeway/server/api.py index 9492534..432546c 100644 --- a/src/ropeway/server/api.py +++ b/src/ropeway/server/api.py @@ -77,6 +77,13 @@ ) from .config import Settings, get_settings from .db import init_db +from .oauth import ( + configured_providers, + exchange_code_for_token, + fetch_userinfo, + get_provider, + new_state_token, +) from .models import ( AuditAction, AuditLog, @@ -159,9 +166,11 @@ def create_app(settings: Settings | None = None) -> FastAPI: from .ratelimit import SlidingWindowLimiter, install_rate_limit install_rate_limit(app, SlidingWindowLimiter(settings.rate_limit_per_min)) - # ---- COMM-1: OAuth (Google / Microsoft) login redirect ---- + # ---- COMM-1 + P26: OAuth (Google / Microsoft) login + callback ---- + from fastapi import Cookie from fastapi.responses import RedirectResponse - from .oauth import configured_providers, get_provider, new_state_token + + OAUTH_STATE_COOKIE = "ropeway_oauth_state" @app.get("/auth/oauth/providers", include_in_schema=False) def oauth_providers(): @@ -186,10 +195,89 @@ def oauth_login(provider: str): f"(set {p.env_prefix}_CLIENT_ID + _CLIENT_SECRET)", ) state = new_state_token() - # Production stores `state` in a signed cookie + verifies it on - # /callback. Scaffolding ships the redirect; the cookie store + - # /callback handler land in a follow-up. - return RedirectResponse(url=p.authorize_redirect(state), status_code=302) + response = RedirectResponse(url=p.authorize_redirect(state), status_code=302) + # State cookie is the CSRF anchor — the /callback handler echoes + # it back from a query parameter and must match this server-set + # value byte-for-byte. HttpOnly so it cannot leak to the SPA; + # SameSite=lax so it survives the cross-site provider redirect. + response.set_cookie( + key=OAUTH_STATE_COOKIE, + value=state, + max_age=600, + httponly=True, + samesite="lax", + secure=False, # set True behind TLS; localhost dev uses http + path="/", + ) + return response + + @app.get("/auth/oauth/{provider}/callback", response_model=TokenResponse) + def oauth_callback( + provider: str, + code: str = Query(..., description="authorization code from provider"), + state: str = Query(..., description="state echoed back; must match cookie"), + cookie_state: str | None = Cookie(default=None, alias=OAUTH_STATE_COOKIE), + db: Session = Depends(_db_session), + settings: Settings = Depends(get_settings), + ): + """Finalize the OAuth dance: verify state, exchange code, upsert user, mint JWT. + + Steps: + 1. Look up the provider; 404 if unknown, 503 if not configured. + 2. Reject with 400 if the state cookie is missing OR does not + match the ``state`` query parameter — that's the CSRF anchor. + 3. POST the authorization code to the provider's token endpoint. + 4. GET the userinfo endpoint with the access token to pin the + verified email. + 5. Upsert the local ``User`` row (preserving the existing tier + on repeat logins — P27's webhook is the only thing allowed + to mutate ``tier``). + 6. Mint a JWT and clear the state cookie. + """ + p = get_provider(provider) + if p is None: + raise HTTPException(status_code=404, detail=f"unknown provider {provider!r}") + if not p.is_configured(): + raise HTTPException(status_code=503, detail=f"{provider} OAuth not configured") + if not cookie_state or not state or cookie_state != state: + raise HTTPException(status_code=400, detail="oauth state mismatch") + + try: + token_payload = exchange_code_for_token(p, code=code) + access_token = token_payload.get("access_token", "") + if not access_token: + raise RuntimeError("provider returned no access_token") + userinfo = fetch_userinfo(p, access_token) + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=f"oauth exchange failed: {exc}") from exc + + email = (userinfo.get("email") or "").strip().lower() + if not email: + raise HTTPException(status_code=502, detail="provider did not return an email") + + user = db.scalar(select(User).where(User.email == email)) + if user is None: + # New OAuth user: random password hash (they can never log in + # via /auth/login until they set one — by design). + import secrets as _sec + user = User( + email=email, + hashed_password=hash_password(_sec.token_urlsafe(32)), + role=Role.ENGINEER, + tier="community", + ) + db.add(user) + db.commit() + db.refresh(user) + elif not user.active: + raise HTTPException(status_code=403, detail="user disabled") + # Existing user: tier is *not* touched here — P27's webhook owns it. + + token = create_access_token(user, settings) + response = Response(content=TokenResponse(access_token=token).model_dump_json(), + media_type="application/json") + response.delete_cookie(OAUTH_STATE_COOKIE, path="/") + return response # ---- health ---- @app.get("/health") diff --git a/src/ropeway/server/auth.py b/src/ropeway/server/auth.py index 58e0be4..4682fc8 100644 --- a/src/ropeway/server/auth.py +++ b/src/ropeway/server/auth.py @@ -44,6 +44,7 @@ def create_access_token(user: User, settings: Settings) -> str: "sub": str(user.id), "email": user.email, "role": user.role.value, + "tier": getattr(user, "tier", "community") or "community", "exp": expire, } return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) diff --git a/src/ropeway/server/db.py b/src/ropeway/server/db.py index 0e89978..fb46be3 100644 --- a/src/ropeway/server/db.py +++ b/src/ropeway/server/db.py @@ -60,6 +60,24 @@ def session_scope(settings: Settings) -> Iterator[Session]: def init_db(settings: Settings) -> None: """Create all tables. Safe to call at startup; idempotent.""" + from sqlalchemy import inspect, text + from .models import Base - Base.metadata.create_all(bind=get_engine(settings)) + engine = get_engine(settings) + Base.metadata.create_all(bind=engine) + + # P26 — additive migration for the ``users.tier`` column. SQLAlchemy's + # ``create_all`` only creates new tables; it does not add columns to + # tables that already exist. A long-running deploy from before P26 + # has a ``users`` table without ``tier`` — touching it would crash on + # the first INSERT/SELECT. ``ALTER TABLE … ADD COLUMN`` is universally + # supported with the IF-NOT-EXISTS-equivalent guard below. + inspector = inspect(engine) + if "users" in inspector.get_table_names(): + existing_cols = {c["name"] for c in inspector.get_columns("users")} + if "tier" not in existing_cols: + with engine.begin() as conn: + conn.execute( + text("ALTER TABLE users ADD COLUMN tier VARCHAR(32) NOT NULL DEFAULT 'community'") + ) diff --git a/src/ropeway/server/models.py b/src/ropeway/server/models.py index 62d0298..ff0f2ac 100644 --- a/src/ropeway/server/models.py +++ b/src/ropeway/server/models.py @@ -68,8 +68,15 @@ class User(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + # P26: OAuth-authenticated users have no local password — store the + # bcrypt hash of a server-side random secret so the column stays + # NOT NULL without exposing a real credential. hashed_password: Mapped[str] = mapped_column(String(255)) role: Mapped[Role] = mapped_column(SAEnum(Role, native_enum=False), default=Role.ENGINEER) + # P26: subscription tier — drives the billing feature gates in + # ``server/billing.py``. Defaults to ``community`` (free). Webhook + # handlers from Razorpay (P27) update this field on charge / cancel. + tier: Mapped[str] = mapped_column(String(32), default="community") active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) diff --git a/src/ropeway/server/schemas.py b/src/ropeway/server/schemas.py index ba9e514..a97ea23 100644 --- a/src/ropeway/server/schemas.py +++ b/src/ropeway/server/schemas.py @@ -25,6 +25,7 @@ class UserRead(BaseModel): id: int email: EmailStr role: Role + tier: str = "community" active: bool created_at: datetime diff --git a/tests/test_oauth_callback.py b/tests/test_oauth_callback.py new file mode 100644 index 0000000..93b31a7 --- /dev/null +++ b/tests/test_oauth_callback.py @@ -0,0 +1,247 @@ +"""P26 — OAuth callback + User.tier column. + +Covers: +- state cookie set on /login, verified on /callback (mismatch → 400) +- code exchange + userinfo fetch → user upsert → JWT mint +- repeat login preserves tier (P27's webhook is the only mutator) +- tier exposed on /auth/me and in the JWT payload +- migration adds users.tier to legacy DBs +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from jose import jwt + +from ropeway.server import oauth +from ropeway.server.api import create_app +from ropeway.server.config import Settings +from ropeway.server.db import init_db + + +@pytest.fixture() +def google_env(monkeypatch): + monkeypatch.setenv("ROPEWAY_GOOGLE_OAUTH_CLIENT_ID", "cid") + monkeypatch.setenv("ROPEWAY_GOOGLE_OAUTH_CLIENT_SECRET", "secret") + monkeypatch.setenv("ROPEWAY_GOOGLE_OAUTH_REDIRECT_URI", "http://localhost/cb") + + +@pytest.fixture() +def settings(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path}/api.db" + monkeypatch.setenv("ROPEWAY_DATABASE_URL", db_url) + monkeypatch.setenv("ROPEWAY_SECRET_KEY", "testkey") + # Routes resolve their Settings via Depends(get_settings) which is + # lru-cached at module load — flush so this test sees its own DB. + from ropeway.server import config as cfg_mod + cfg_mod.get_settings.cache_clear() + from ropeway.server import db as db_mod + db_mod._engines.clear() + return cfg_mod.get_settings() + + +@pytest.fixture() +def client(settings, google_env): + return TestClient(create_app(settings), follow_redirects=False) + + +def _stub_oauth(monkeypatch, email: str = "user@example.com"): + """Replace the network-touching helpers with deterministic stubs.""" + monkeypatch.setattr( + "ropeway.server.api.exchange_code_for_token", + lambda provider, code, timeout_s=10.0: {"access_token": "fake-at"}, + ) + monkeypatch.setattr( + "ropeway.server.api.fetch_userinfo", + lambda provider, access_token, timeout_s=10.0: { + "email": email, "name": "Test User", + }, + ) + + +# --------------------------------------------------------------------------- +# Migration + tier column +# --------------------------------------------------------------------------- + + +def test_init_db_adds_tier_to_legacy_users_table(tmp_path): + """A pre-P26 DB has no ``tier`` column; ``init_db`` must add it.""" + db_path = tmp_path / "legacy.db" + with sqlite3.connect(db_path) as con: + con.execute( + "CREATE TABLE users (" + " id INTEGER PRIMARY KEY," + " email VARCHAR(255) UNIQUE," + " hashed_password VARCHAR(255)," + " role VARCHAR(32) DEFAULT 'engineer'," + " active BOOLEAN DEFAULT 1," + " created_at DATETIME)" + ) + con.execute( + "INSERT INTO users (id, email, hashed_password, role, active, created_at)" + " VALUES (1, 'old@example.com', 'h', 'engineer', 1, '2024-01-01')" + ) + + s = Settings(database_url=f"sqlite:///{db_path}", secret_key="x") + init_db(s) + + with sqlite3.connect(db_path) as con: + cols = {row[1] for row in con.execute("PRAGMA table_info(users)")} + assert "tier" in cols + # Existing row got the default backfilled. + row = con.execute("SELECT tier FROM users WHERE id = 1").fetchone() + assert row[0] == "community" + + +def test_register_assigns_default_tier_community(client): + r = client.post( + "/auth/register", + json={"email": "new@example.com", "password": "supersecret123"}, + ) + assert r.status_code == 201 + assert r.json()["tier"] == "community" + + +# --------------------------------------------------------------------------- +# Login → state cookie +# --------------------------------------------------------------------------- + + +def test_login_sets_state_cookie(client): + r = client.get("/auth/oauth/google/login") + assert r.status_code == 302 + cookie = r.cookies.get("ropeway_oauth_state") + assert cookie and len(cookie) > 16 # URL-safe random + + +# --------------------------------------------------------------------------- +# Callback happy path +# --------------------------------------------------------------------------- + + +def test_callback_state_match_mints_jwt_and_upserts_user( + client, settings, monkeypatch +): + _stub_oauth(monkeypatch, email="grace@example.com") + + # Drive the login first so the cookie + a real state value land. + login = client.get("/auth/oauth/google/login") + state = login.cookies.get("ropeway_oauth_state") + assert state + + cb = client.get( + f"/auth/oauth/google/callback?code=fake-code&state={state}", + cookies={"ropeway_oauth_state": state}, + ) + assert cb.status_code == 200, cb.text + token = cb.json()["access_token"] + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm]) + assert payload["email"] == "grace@example.com" + assert payload["tier"] == "community" + + # /auth/me sees the same user with default tier. + me = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 200 + assert me.json()["email"] == "grace@example.com" + assert me.json()["tier"] == "community" + + +def test_callback_repeat_login_preserves_existing_tier( + client, monkeypatch, settings +): + """An OAuth re-login must NOT downgrade a paid user back to community.""" + _stub_oauth(monkeypatch, email="paid@example.com") + + login = client.get("/auth/oauth/google/login") + state = login.cookies.get("ropeway_oauth_state") + cb1 = client.get( + f"/auth/oauth/google/callback?code=c&state={state}", + cookies={"ropeway_oauth_state": state}, + ) + assert cb1.status_code == 200 + + # Simulate a Razorpay webhook bumping the tier directly in the DB. + from sqlalchemy import update + from ropeway.server.db import session_scope + from ropeway.server.models import User + + with session_scope(settings) as db: + db.execute( + update(User).where(User.email == "paid@example.com").values(tier="professional") + ) + + # Second OAuth round-trip — tier must survive. + login2 = client.get("/auth/oauth/google/login") + state2 = login2.cookies.get("ropeway_oauth_state") + cb2 = client.get( + f"/auth/oauth/google/callback?code=c&state={state2}", + cookies={"ropeway_oauth_state": state2}, + ) + assert cb2.status_code == 200 + payload = jwt.decode( + cb2.json()["access_token"], + settings.secret_key, + algorithms=[settings.jwt_algorithm], + ) + assert payload["tier"] == "professional" + + +# --------------------------------------------------------------------------- +# Callback rejection paths +# --------------------------------------------------------------------------- + + +def test_callback_state_mismatch_rejected(client, monkeypatch): + _stub_oauth(monkeypatch) + cb = client.get( + "/auth/oauth/google/callback?code=fake&state=AAA", + cookies={"ropeway_oauth_state": "BBB"}, + ) + assert cb.status_code == 400 + assert "state mismatch" in cb.json()["detail"] + + +def test_callback_missing_cookie_rejected(client, monkeypatch): + _stub_oauth(monkeypatch) + cb = client.get("/auth/oauth/google/callback?code=fake&state=AAA") + assert cb.status_code == 400 + + +def test_callback_unknown_provider_404(client): + cb = client.get( + "/auth/oauth/facebook/callback?code=fake&state=x", + cookies={"ropeway_oauth_state": "x"}, + ) + assert cb.status_code == 404 + + +def test_callback_unconfigured_provider_503(client, monkeypatch): + monkeypatch.delenv("ROPEWAY_MICROSOFT_OAUTH_CLIENT_ID", raising=False) + monkeypatch.delenv("ROPEWAY_MICROSOFT_OAUTH_CLIENT_SECRET", raising=False) + cb = client.get( + "/auth/oauth/microsoft/callback?code=fake&state=x", + cookies={"ropeway_oauth_state": "x"}, + ) + assert cb.status_code == 503 + + +def test_callback_provider_returns_no_email_is_502(client, monkeypatch): + monkeypatch.setattr( + "ropeway.server.api.exchange_code_for_token", + lambda provider, code, timeout_s=10.0: {"access_token": "at"}, + ) + monkeypatch.setattr( + "ropeway.server.api.fetch_userinfo", + lambda provider, access_token, timeout_s=10.0: {"name": "no email"}, + ) + login = client.get("/auth/oauth/google/login") + state = login.cookies.get("ropeway_oauth_state") + cb = client.get( + f"/auth/oauth/google/callback?code=c&state={state}", + cookies={"ropeway_oauth_state": state}, + ) + assert cb.status_code == 502