Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
100 changes: 94 additions & 6 deletions src/ropeway/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions src/ropeway/server/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 19 additions & 1 deletion src/ropeway/server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'")
)
7 changes: 7 additions & 0 deletions src/ropeway/server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/ropeway/server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class UserRead(BaseModel):
id: int
email: EmailStr
role: Role
tier: str = "community"
active: bool
created_at: datetime

Expand Down
Loading
Loading