From fd241079d83aa42ce6a35648fe78e53e1a45bcbc Mon Sep 17 00:00:00 2001 From: Ryan Date: Sat, 18 Apr 2026 16:32:04 -0400 Subject: [PATCH 1/2] basic auth --- backend/.env.example | 23 +++++- backend/app/api/deps.py | 31 ++++++++ backend/app/api/router.py | 5 +- backend/app/api/routes/auth.py | 107 ++++++++++++++++++++++++++ backend/app/api/routes/deployments.py | 62 +++++++++++++++ backend/app/api/routes/uploads.py | 37 +++++++++ backend/app/core/config.py | 16 ++++ backend/app/db/__init__.py | 4 + backend/app/db/base.py | 24 ++++++ backend/app/db/session.py | 37 +++++++++ backend/app/main.py | 2 + backend/app/models/__init__.py | 5 ++ backend/app/models/deployment.py | 62 +++++++++++++++ backend/app/models/session.py | 21 +++++ backend/app/models/user.py | 25 ++++++ backend/app/schemas/deployment.py | 48 ++++++++++++ backend/app/schemas/user.py | 12 +++ backend/app/services/__init__.py | 0 backend/app/services/auth.py | 70 +++++++++++++++++ backend/app/services/github_oauth.py | 88 +++++++++++++++++++++ backend/pyproject.toml | 2 + backend/tests/conftest.py | 75 ++++++++++++++++++ backend/tests/test_auth.py | 78 +++++++++++++++++++ backend/tests/test_deployments.py | 58 ++++++++++++++ backend/tests/test_health.py | 8 +- backend/uv.lock | 94 ++++++++++++++++++++++ 26 files changed, 983 insertions(+), 11 deletions(-) create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/routes/auth.py create mode 100644 backend/app/api/routes/deployments.py create mode 100644 backend/app/api/routes/uploads.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/deployment.py create mode 100644 backend/app/models/session.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/deployment.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth.py create mode 100644 backend/app/services/github_oauth.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_deployments.py diff --git a/backend/.env.example b/backend/.env.example index 8d32a76..571ce45 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,19 @@ -PROJECT_NAME="Backend API" -DEBUG=false -API_V1_PREFIX=/api/v1 -CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] +# Copy to .env and fill in. + +DEBUG=true + +# DB +DATABASE_URL=sqlite+aiosqlite:///./app.db + +# GitHub OAuth (https://github.com/settings/developers) +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URI=http://localhost:8000/api/v1/auth/github/callback + +# Where to redirect the user after a successful login. +FRONTEND_URL=http://localhost:5173 + +# Session cookie. Set SESSION_COOKIE_SECURE=true behind HTTPS. +SESSION_COOKIE_NAME=dploy_session +SESSION_COOKIE_SECURE=false +SESSION_TTL_HOURS=168 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..506958c --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,31 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.db.session import get_session +from app.models.user import User +from app.services.auth import get_session_user + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +async def _resolve_current_user(request: Request, db: SessionDep) -> User: + settings = get_settings() + token = request.cookies.get(settings.session_cookie_name) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + user = await get_session_user(db, token) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired session", + ) + return user + + +CurrentUser = Annotated[User, Depends(_resolve_current_user)] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 9506a38..426287f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,9 @@ from fastapi import APIRouter -from app.api.routes import health +from app.api.routes import auth, deployments, health, uploads api_router = APIRouter() api_router.include_router(health.router, tags=["health"]) +api_router.include_router(auth.router) +api_router.include_router(uploads.router) +api_router.include_router(deployments.router) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..375b5f5 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,107 @@ +import secrets + +from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse + +from app.api.deps import CurrentUser, SessionDep +from app.core.config import get_settings +from app.schemas.user import UserRead +from app.services import github_oauth +from app.services.auth import create_session, delete_session, upsert_user + +router = APIRouter(prefix="/auth", tags=["auth"]) + +_OAUTH_STATE_COOKIE = "dploy_oauth_state" + + +@router.get("/github/login") +async def github_login(request: Request) -> RedirectResponse: + """Kick off the GitHub OAuth flow.""" + settings = get_settings() + if not settings.github_client_id: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="GitHub OAuth is not configured", + ) + + state = secrets.token_urlsafe(16) + redirect = RedirectResponse( + url=github_oauth.build_authorize_url(state), + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + redirect.set_cookie( + _OAUTH_STATE_COOKIE, + state, + max_age=600, + httponly=True, + secure=settings.session_cookie_secure, + samesite="lax", + ) + return redirect + + +@router.get("/github/callback") +async def github_callback( + request: Request, + db: SessionDep, + code: str | None = None, + state: str | None = None, + error: str | None = None, +) -> RedirectResponse: + settings = get_settings() + if error: + raise HTTPException(status_code=400, detail=f"GitHub error: {error}") + if not code or not state: + raise HTTPException(status_code=400, detail="Missing code or state") + + expected_state = request.cookies.get(_OAUTH_STATE_COOKIE) + if not expected_state or not secrets.compare_digest(expected_state, state): + raise HTTPException(status_code=400, detail="Invalid OAuth state") + + access_token = await github_oauth.exchange_code_for_token(code) + gh_user = await github_oauth.fetch_user(access_token) + + user = await upsert_user(db, gh_user, access_token) + session = await create_session(db, user) + await db.commit() + + redirect = RedirectResponse( + url=settings.frontend_url, + status_code=status.HTTP_303_SEE_OTHER, + ) + redirect.delete_cookie(_OAUTH_STATE_COOKIE) + redirect.set_cookie( + settings.session_cookie_name, + session.token, + max_age=settings.session_ttl_hours * 3600, + httponly=True, + secure=settings.session_cookie_secure, + samesite="lax", + path="/", + ) + return redirect + + +@router.get("/me", response_model=UserRead) +async def me(current_user: CurrentUser) -> UserRead: + return UserRead.model_validate(current_user) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(request: Request, response: Response, db: SessionDep) -> Response: + settings = get_settings() + token = request.cookies.get(settings.session_cookie_name) + if token: + await delete_session(db, token) + await db.commit() + response.delete_cookie(settings.session_cookie_name, path="/") + response.status_code = status.HTTP_204_NO_CONTENT + return response + + +@router.get("/login", include_in_schema=False) +async def login_alias() -> RedirectResponse: + return RedirectResponse( + url="/api/v1/auth/github/login", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) diff --git a/backend/app/api/routes/deployments.py b/backend/app/api/routes/deployments.py new file mode 100644 index 0000000..4f72c91 --- /dev/null +++ b/backend/app/api/routes/deployments.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models.deployment import Deployment +from app.schemas.deployment import DeploymentCreate, DeploymentList, DeploymentRead + +router = APIRouter(prefix="/deployments", tags=["deployments"]) + + +@router.post( + "", + response_model=DeploymentRead, + status_code=status.HTTP_201_CREATED, +) +async def create_deployment( + payload: DeploymentCreate, + session: SessionDep, + current_user: CurrentUser, +) -> Deployment: + deployment = Deployment( + user_id=current_user.id, + name=payload.name, + github_url=payload.github_url, + upload_id=payload.upload_id, + ) + session.add(deployment) + await session.commit() + await session.refresh(deployment) + + # TODO: enqueue Agent #1 (analyze) -> Agent #2 (expose ports) here. + return deployment + + +@router.get("", response_model=DeploymentList) +async def list_deployments( + session: SessionDep, + current_user: CurrentUser, + limit: int = 50, + offset: int = 0, +) -> DeploymentList: + base = select(Deployment).where(Deployment.user_id == current_user.id) + total = await session.scalar( + select(func.count()).select_from(base.subquery()), + ) + result = await session.scalars( + base.order_by(Deployment.created_at.desc()).limit(limit).offset(offset), + ) + items = [DeploymentRead.model_validate(d) for d in result.all()] + return DeploymentList(items=items, total=total or 0) + + +@router.get("/{deployment_id}", response_model=DeploymentRead) +async def get_deployment( + deployment_id: str, + session: SessionDep, + current_user: CurrentUser, +) -> Deployment: + deployment = await session.get(Deployment, deployment_id) + if deployment is None or deployment.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Deployment not found") + return deployment diff --git a/backend/app/api/routes/uploads.py b/backend/app/api/routes/uploads.py new file mode 100644 index 0000000..70dca44 --- /dev/null +++ b/backend/app/api/routes/uploads.py @@ -0,0 +1,37 @@ +import uuid +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, File, UploadFile + +from app.schemas.deployment import UploadResponse + +router = APIRouter(prefix="/uploads", tags=["uploads"]) + +# Local-disk staging area. Swap for S3 later. +UPLOAD_DIR = Path("uploads") +UPLOAD_DIR.mkdir(exist_ok=True) + + +@router.post("", response_model=UploadResponse) +async def upload_code(file: Annotated[UploadFile, File(...)]) -> UploadResponse: + """Accept a tarball / zip of the user's project directory. + + The CLI bundles the current folder, POSTs it here, and uses the returned + `upload_id` when creating a deployment. + """ + upload_id = uuid.uuid4().hex + suffix = Path(file.filename or "").suffix + dest = UPLOAD_DIR / f"{upload_id}{suffix}" + + size = 0 + with dest.open("wb") as out: + while chunk := await file.read(1024 * 1024): + out.write(chunk) + size += len(chunk) + + return UploadResponse( + upload_id=upload_id, + filename=file.filename or dest.name, + size=size, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4485047..cfcc597 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -21,6 +21,22 @@ class Settings(BaseSettings): "http://localhost:3000", ] + database_url: str = "sqlite+aiosqlite:///./app.db" + db_echo: bool = False + + # GitHub App (user-to-server "Login with GitHub" flow). + github_client_id: str = "" + github_client_secret: str = "" + github_redirect_uri: str = "http://localhost:8000/api/v1/auth/github/callback" + + # Where to send the user after a successful login. + frontend_url: str = "http://localhost:5173" + + # Session cookie. + session_cookie_name: str = "dploy_session" + session_cookie_secure: bool = False + session_ttl_hours: int = 24 * 7 + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..49a428b --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1,4 @@ +from app.db.base import Base +from app.db.session import get_session, init_db + +__all__ = ["Base", "get_session", "init_db"] diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..ab4d9ed --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,24 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Base(DeclarativeBase): + """Shared declarative base with created/updated timestamps.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=_utcnow, + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=_utcnow, + onupdate=_utcnow, + nullable=False, + ) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..c2956d1 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,37 @@ +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.core.config import get_settings +from app.db.base import Base + +_settings = get_settings() + +engine = create_async_engine( + _settings.database_url, + echo=_settings.db_echo, + future=True, +) + +SessionLocal = async_sessionmaker( + bind=engine, + expire_on_commit=False, + class_=AsyncSession, +) + + +async def init_db() -> None: + """Create all tables. Imports models so they register on Base.metadata.""" + from app import models # noqa: F401 (ensure models are imported) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_session() -> AsyncIterator[AsyncSession]: + async with SessionLocal() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py index b95c70b..34d6156 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,10 +5,12 @@ from app.api.router import api_router from app.core.config import get_settings +from app.db.session import init_db @asynccontextmanager async def lifespan(app: FastAPI): + await init_db() yield diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..fc28b00 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.deployment import DEPLOYMENT_STATUSES, Deployment +from app.models.session import Session +from app.models.user import User + +__all__ = ["DEPLOYMENT_STATUSES", "Deployment", "Session", "User"] diff --git a/backend/app/models/deployment.py b/backend/app/models/deployment.py new file mode 100644 index 0000000..ac82cdc --- /dev/null +++ b/backend/app/models/deployment.py @@ -0,0 +1,62 @@ +import uuid +from typing import Final + +from sqlalchemy import JSON, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +# Allowed values for Deployment.status. Stored as plain strings. +DEPLOYMENT_STATUS_PENDING: Final = "pending" +DEPLOYMENT_STATUS_ANALYZING: Final = "analyzing" +DEPLOYMENT_STATUS_BUILDING: Final = "building" +DEPLOYMENT_STATUS_RUNNING: Final = "running" +DEPLOYMENT_STATUS_FAILED: Final = "failed" +DEPLOYMENT_STATUS_STOPPED: Final = "stopped" + +DEPLOYMENT_STATUSES: Final = ( + DEPLOYMENT_STATUS_PENDING, + DEPLOYMENT_STATUS_ANALYZING, + DEPLOYMENT_STATUS_BUILDING, + DEPLOYMENT_STATUS_RUNNING, + DEPLOYMENT_STATUS_FAILED, + DEPLOYMENT_STATUS_STOPPED, +) + + +def _new_id() -> str: + return uuid.uuid4().hex + + +class Deployment(Base): + __tablename__ = "deployments" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id) + user_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("users.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # One of these is provided by the client at creation time. + github_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + upload_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + + status: Mapped[str] = mapped_column( + String(32), + default=DEPLOYMENT_STATUS_PENDING, + nullable=False, + ) + + # Filled in by Agent #1 once it inspects the code. + run_commands: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + env_required: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + + # Filled in by Agent #2 once ports are exposed. + exposed_ports: Mapped[list[int] | None] = mapped_column(JSON, nullable=True) + public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + + logs: Mapped[str | None] = mapped_column(Text, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..10c77e9 --- /dev/null +++ b/backend/app/models/session.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Session(Base): + """Opaque, server-side session. The cookie value is the primary-key token.""" + + __tablename__ = "sessions" + + token: Mapped[str] = mapped_column(String(64), primary_key=True) + user_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("users.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..0da9fde --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,25 @@ +import uuid + +from sqlalchemy import BigInteger, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +def _new_id() -> str: + return uuid.uuid4().hex + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id) + github_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True, nullable=False) + login: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + email: Mapped[str | None] = mapped_column(String(320), nullable=True) + avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + + # The GitHub user-access token. Used so we can clone private repos on the + # user's behalf during deployment. + github_access_token: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/deployment.py b/backend/app/schemas/deployment.py new file mode 100644 index 0000000..bf25a77 --- /dev/null +++ b/backend/app/schemas/deployment.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DeploymentCreate(BaseModel): + """Request body for creating a deployment. + + Provide either a `github_url` or an `upload_id` returned from the upload endpoint. + """ + + name: str | None = None + github_url: str | None = Field(default=None, description="https://github.com/owner/repo[.git]") + upload_id: str | None = Field(default=None, description="ID returned from POST /upload") + + @model_validator(mode="after") + def _require_source(self) -> "DeploymentCreate": + if not self.github_url and not self.upload_id: + raise ValueError("Either github_url or upload_id must be provided") + return self + + +class DeploymentRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str | None + github_url: str | None + upload_id: str | None + status: str + run_commands: list[str] | None + env_required: list[str] | None + exposed_ports: list[int] | None + public_url: str | None + error: str | None + created_at: datetime + updated_at: datetime + + +class DeploymentList(BaseModel): + items: list[DeploymentRead] + total: int + + +class UploadResponse(BaseModel): + upload_id: str + filename: str + size: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..e8418c9 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + github_id: int + login: str + name: str | None + email: str | None + avatar_url: str | None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..b1d9279 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,70 @@ +import secrets +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.models.session import Session +from app.models.user import User +from app.services.github_oauth import GitHubUser + + +async def upsert_user( + db: AsyncSession, + gh_user: GitHubUser, + access_token: str, +) -> User: + user = await db.scalar(select(User).where(User.github_id == gh_user.id)) + if user is None: + user = User( + github_id=gh_user.id, + login=gh_user.login, + name=gh_user.name, + email=gh_user.email, + avatar_url=gh_user.avatar_url, + github_access_token=access_token, + ) + db.add(user) + else: + user.login = gh_user.login + user.name = gh_user.name + user.email = gh_user.email + user.avatar_url = gh_user.avatar_url + user.github_access_token = access_token + await db.flush() + return user + + +async def create_session(db: AsyncSession, user: User) -> Session: + settings = get_settings() + session = Session( + token=secrets.token_urlsafe(32), + user_id=user.id, + expires_at=datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours), + ) + db.add(session) + await db.flush() + return session + + +async def get_session_user(db: AsyncSession, token: str) -> User | None: + session = await db.get(Session, token) + if session is None: + return None + # SQLite drops tz info on read; assume stored values are UTC. + expires_at = session.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + if expires_at < datetime.now(UTC): + await db.delete(session) + await db.flush() + return None + return await db.get(User, session.user_id) + + +async def delete_session(db: AsyncSession, token: str) -> None: + session = await db.get(Session, token) + if session is not None: + await db.delete(session) + await db.flush() diff --git a/backend/app/services/github_oauth.py b/backend/app/services/github_oauth.py new file mode 100644 index 0000000..a32c5a6 --- /dev/null +++ b/backend/app/services/github_oauth.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from urllib.parse import urlencode + +import httpx + +from app.core.config import get_settings + +GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_USER_URL = "https://api.github.com/user" +GITHUB_USER_EMAILS_URL = "https://api.github.com/user/emails" + + +@dataclass +class GitHubUser: + id: int + login: str + name: str | None + email: str | None + avatar_url: str | None + + +def build_authorize_url(state: str) -> str: + settings = get_settings() + params = { + "client_id": settings.github_client_id, + "redirect_uri": settings.github_redirect_uri, + "scope": settings.github_oauth_scopes, + "state": state, + "allow_signup": "true", + } + return f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}" + + +async def exchange_code_for_token(code: str) -> str: + """Exchange an OAuth `code` for a user access token.""" + settings = get_settings() + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + GITHUB_TOKEN_URL, + headers={"Accept": "application/json"}, + data={ + "client_id": settings.github_client_id, + "client_secret": settings.github_client_secret, + "code": code, + "redirect_uri": settings.github_redirect_uri, + }, + ) + response.raise_for_status() + body = response.json() + + token = body.get("access_token") + if not token: + raise ValueError(f"GitHub token exchange failed: {body!r}") + return token + + +async def fetch_user(access_token: str) -> GitHubUser: + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: + user_resp = await client.get(GITHUB_USER_URL) + user_resp.raise_for_status() + user = user_resp.json() + + email = user.get("email") + if not email: + # The /user endpoint omits the email when the user has it set to private; + # /user/emails returns all verified addresses. + emails_resp = await client.get(GITHUB_USER_EMAILS_URL) + if emails_resp.status_code == 200: + emails = emails_resp.json() + primary = next( + (e for e in emails if e.get("primary") and e.get("verified")), + None, + ) + email = primary["email"] if primary else None + + return GitHubUser( + id=int(user["id"]), + login=user["login"], + name=user.get("name"), + email=email, + avatar_url=user.get("avatar_url"), + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1793b6a..b966fb2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,8 +5,10 @@ description = "FastAPI backend" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "aiosqlite>=0.20.0", "fastapi[standard]>=0.136.0", "pydantic-settings>=2.6.0", + "sqlalchemy[asyncio]>=2.0.36", ] [dependency-groups] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..a5a6202 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,75 @@ +import asyncio +import os +import tempfile +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +_db_path = Path(tempfile.gettempdir()) / "hp-sp-26-test.db" +if _db_path.exists(): + _db_path.unlink() +os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_db_path}" +os.environ.setdefault("GITHUB_CLIENT_ID", "test-client-id") +os.environ.setdefault("GITHUB_CLIENT_SECRET", "test-client-secret") + +from fastapi.testclient import TestClient # noqa: E402 + +from app.core.config import get_settings # noqa: E402 +from app.db.base import Base # noqa: E402 +from app.db.session import SessionLocal, engine # noqa: E402 +from app.main import app # noqa: E402 +from app.models.session import Session # noqa: E402 +from app.models.user import User # noqa: E402 + +get_settings.cache_clear() + + +@pytest.fixture(autouse=True) +def _reset_db() -> Iterator[None]: + """Drop & recreate all tables before every test for isolation.""" + + async def _do_reset() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + asyncio.run(_do_reset()) + yield + + +@pytest.fixture() +def client() -> Iterator[TestClient]: + with TestClient(app) as c: + yield c + + +@pytest.fixture() +def authed_client(client: TestClient) -> TestClient: + """Insert a user + session into the DB and attach the cookie to the client.""" + + async def _setup() -> str: + async with SessionLocal() as db: + user = User( + github_id=42, + login="octotest", + name="Octo Test", + email="octo@test.dev", + avatar_url="https://example.com/avatar.png", + github_access_token="ghu_test", + ) + db.add(user) + await db.flush() + session = Session( + token="test-session-token", + user_id=user.id, + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + db.add(session) + await db.commit() + return session.token + + token = asyncio.run(_setup()) + client.cookies.set(get_settings().session_cookie_name, token) + return client diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..3298b8d --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,78 @@ +import pytest +from fastapi.testclient import TestClient + +from app.services import github_oauth + + +def test_login_redirects_to_github(client: TestClient) -> None: + response = client.get("/api/v1/auth/github/login", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"].startswith(github_oauth.GITHUB_AUTHORIZE_URL) + assert "dploy_oauth_state" in response.cookies + + +def test_me_unauthenticated(client: TestClient) -> None: + response = client.get("/api/v1/auth/me") + assert response.status_code == 401 + + +def test_me_authenticated(authed_client: TestClient) -> None: + response = authed_client.get("/api/v1/auth/me") + assert response.status_code == 200 + body = response.json() + assert body["login"] == "octotest" + assert body["github_id"] == 42 + + +def test_logout(authed_client: TestClient) -> None: + response = authed_client.post("/api/v1/auth/logout") + assert response.status_code == 204 + # Cookie removed on the client; subsequent /me should now be 401. + authed_client.cookies.clear() + follow = authed_client.get("/api/v1/auth/me") + assert follow.status_code == 401 + + +def test_github_callback_full_flow( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_exchange(code: str) -> str: + assert code == "abc123" + return "ghu_fake_token" + + async def fake_fetch(token: str) -> github_oauth.GitHubUser: + assert token == "ghu_fake_token" + return github_oauth.GitHubUser( + id=99, + login="callbackuser", + name="Callback User", + email="cb@example.com", + avatar_url="https://example.com/cb.png", + ) + + monkeypatch.setattr( + "app.api.routes.auth.github_oauth.exchange_code_for_token", + fake_exchange, + ) + monkeypatch.setattr( + "app.api.routes.auth.github_oauth.fetch_user", + fake_fetch, + ) + + # Pretend the user already started the OAuth dance and has a state cookie. + client.cookies.set("dploy_oauth_state", "state-xyz") + response = client.get( + "/api/v1/auth/github/callback", + params={"code": "abc123", "state": "state-xyz"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert "dploy_session" in response.cookies + + # Use the new session cookie to hit /me. + client.cookies.clear() + client.cookies.set("dploy_session", response.cookies["dploy_session"]) + me = client.get("/api/v1/auth/me") + assert me.status_code == 200 + assert me.json()["login"] == "callbackuser" diff --git a/backend/tests/test_deployments.py b/backend/tests/test_deployments.py new file mode 100644 index 0000000..87f5b04 --- /dev/null +++ b/backend/tests/test_deployments.py @@ -0,0 +1,58 @@ +from fastapi.testclient import TestClient + + +def test_create_requires_auth(client: TestClient) -> None: + response = client.post( + "/api/v1/deployments", + json={"github_url": "https://github.com/foo/bar"}, + ) + assert response.status_code == 401 + + +def test_create_requires_source(authed_client: TestClient) -> None: + response = authed_client.post("/api/v1/deployments", json={"name": "missing-src"}) + assert response.status_code == 422 + + +def test_create_and_get_deployment(authed_client: TestClient) -> None: + payload = { + "name": "example", + "github_url": "https://github.com/octocat/hello-world", + } + create = authed_client.post("/api/v1/deployments", json=payload) + assert create.status_code == 201, create.text + body = create.json() + assert body["id"] + assert body["status"] == "pending" + assert body["github_url"] == payload["github_url"] + + deployment_id = body["id"] + fetched = authed_client.get(f"/api/v1/deployments/{deployment_id}") + assert fetched.status_code == 200 + assert fetched.json()["id"] == deployment_id + + +def test_list_deployments(authed_client: TestClient) -> None: + authed_client.post( + "/api/v1/deployments", + json={"github_url": "https://github.com/foo/bar"}, + ) + response = authed_client.get("/api/v1/deployments") + assert response.status_code == 200 + body = response.json() + assert body["total"] >= 1 + assert isinstance(body["items"], list) + + +def test_get_unknown_deployment(authed_client: TestClient) -> None: + response = authed_client.get("/api/v1/deployments/does-not-exist") + assert response.status_code == 404 + + +def test_upload_endpoint(client: TestClient) -> None: + files = {"file": ("hello.txt", b"hello world", "text/plain")} + response = client.post("/api/v1/uploads", files=files) + assert response.status_code == 200 + body = response.json() + assert body["upload_id"] + assert body["size"] == len(b"hello world") diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 447f8fe..b0081a6 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,17 +1,13 @@ from fastapi.testclient import TestClient -from app.main import app -client = TestClient(app) - - -def test_root() -> None: +def test_root(client: TestClient) -> None: response = client.get("/") assert response.status_code == 200 assert "message" in response.json() -def test_health() -> None: +def test_health(client: TestClient) -> None: response = client.get("/api/v1/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} diff --git a/backend/uv.lock b/backend/uv.lock index 73c9e88..c3d7c9e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -6,6 +6,15 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -41,8 +50,10 @@ name = "backend" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "aiosqlite" }, { name = "fastapi", extra = ["standard"] }, { name = "pydantic-settings" }, + { name = "sqlalchemy", extra = ["asyncio"] }, ] [package.dev-dependencies] @@ -54,8 +65,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.136.0" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.36" }, ] [package.metadata.requires-dev] @@ -241,6 +254,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, ] +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -730,6 +780,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "starlette" version = "1.0.0" From 5bdaf8b7e80f90d4a56d5a3c1d238501602f7e69 Mon Sep 17 00:00:00 2001 From: Ryan Date: Sat, 18 Apr 2026 16:39:00 -0400 Subject: [PATCH 2/2] github setup --- .gitignore | 5 ++ backend/.env.example | 10 +++- backend/Makefile | 45 ++++++++++++++++ backend/app/api/routes/auth.py | 34 +++--------- backend/app/core/config.py | 4 ++ backend/app/models/user.py | 15 ++++-- backend/app/services/auth.py | 53 +++++++++++++++---- backend/app/services/github_oauth.py | 77 ++++++++++++++++++++++++---- backend/app/services/oauth_state.py | 38 ++++++++++++++ backend/tests/test_auth.py | 20 +++++--- 10 files changed, 246 insertions(+), 55 deletions(-) create mode 100644 backend/Makefile create mode 100644 backend/app/services/oauth_state.py diff --git a/.gitignore b/.gitignore index 7dfaf75..42d575f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,8 @@ env/ .mypy_cache/ .coverage htmlcov/ + +# Backend runtime data +backend/app.db +backend/app.db-journal +backend/uploads/ diff --git a/backend/.env.example b/backend/.env.example index 571ce45..68ce0c5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,7 +5,11 @@ DEBUG=true # DB DATABASE_URL=sqlite+aiosqlite:///./app.db -# GitHub OAuth (https://github.com/settings/developers) +# GitHub App ("Login with GitHub") +# Settings → Developer settings → GitHub Apps → your app +# - Client ID is shown on the app settings page (different from App ID). +# - Generate a new client secret and paste it below. +# - The Callback URL on the app must match GITHUB_REDIRECT_URI exactly. GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GITHUB_REDIRECT_URI=http://localhost:8000/api/v1/auth/github/callback @@ -17,3 +21,7 @@ FRONTEND_URL=http://localhost:5173 SESSION_COOKIE_NAME=dploy_session SESSION_COOKIE_SECURE=false SESSION_TTL_HOURS=168 + +# Used to sign short-lived values like the OAuth `state`. Use a long random +# string in production: `python -c "import secrets; print(secrets.token_urlsafe(48))"` +SESSION_SECRET=dev-only-change-me diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..5186dd4 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,45 @@ +.PHONY: help install dev run test lint fmt fix clean reset-db + +UV ?= uv +APP ?= app/main.py +HOST ?= 0.0.0.0 +PORT ?= 8000 + +help: + @echo "Targets:" + @echo " install Install/sync dependencies via uv" + @echo " dev Run FastAPI with hot reload (HOST=$(HOST) PORT=$(PORT))" + @echo " run Run FastAPI in production mode" + @echo " test Run pytest" + @echo " lint Run ruff check" + @echo " fmt Run ruff format" + @echo " fix Run ruff check --fix" + @echo " reset-db Delete the local SQLite DB" + @echo " clean Remove caches and the local DB" + +install: + $(UV) sync + +dev: + $(UV) run fastapi dev $(APP) --host $(HOST) --port $(PORT) + +run: + $(UV) run fastapi run $(APP) --host $(HOST) --port $(PORT) + +test: + $(UV) run pytest + +lint: + $(UV) run ruff check . + +fmt: + $(UV) run ruff format . + +fix: + $(UV) run ruff check . --fix + +reset-db: + rm -f app.db app.db-journal + +clean: reset-db + rm -rf .pytest_cache .ruff_cache .mypy_cache **/__pycache__ diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 375b5f5..90732fd 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,21 +1,17 @@ -import secrets - from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi.responses import RedirectResponse from app.api.deps import CurrentUser, SessionDep from app.core.config import get_settings from app.schemas.user import UserRead -from app.services import github_oauth +from app.services import github_oauth, oauth_state from app.services.auth import create_session, delete_session, upsert_user router = APIRouter(prefix="/auth", tags=["auth"]) -_OAUTH_STATE_COOKIE = "dploy_oauth_state" - @router.get("/github/login") -async def github_login(request: Request) -> RedirectResponse: +async def github_login() -> RedirectResponse: """Kick off the GitHub OAuth flow.""" settings = get_settings() if not settings.github_client_id: @@ -24,25 +20,14 @@ async def github_login(request: Request) -> RedirectResponse: detail="GitHub OAuth is not configured", ) - state = secrets.token_urlsafe(16) - redirect = RedirectResponse( - url=github_oauth.build_authorize_url(state), + return RedirectResponse( + url=github_oauth.build_authorize_url(oauth_state.issue_state()), status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) - redirect.set_cookie( - _OAUTH_STATE_COOKIE, - state, - max_age=600, - httponly=True, - secure=settings.session_cookie_secure, - samesite="lax", - ) - return redirect @router.get("/github/callback") async def github_callback( - request: Request, db: SessionDep, code: str | None = None, state: str | None = None, @@ -53,15 +38,13 @@ async def github_callback( raise HTTPException(status_code=400, detail=f"GitHub error: {error}") if not code or not state: raise HTTPException(status_code=400, detail="Missing code or state") - - expected_state = request.cookies.get(_OAUTH_STATE_COOKIE) - if not expected_state or not secrets.compare_digest(expected_state, state): + if not oauth_state.verify_state(state): raise HTTPException(status_code=400, detail="Invalid OAuth state") - access_token = await github_oauth.exchange_code_for_token(code) - gh_user = await github_oauth.fetch_user(access_token) + token = await github_oauth.exchange_code_for_token(code) + gh_user = await github_oauth.fetch_user(token.access_token) - user = await upsert_user(db, gh_user, access_token) + user = await upsert_user(db, gh_user, token) session = await create_session(db, user) await db.commit() @@ -69,7 +52,6 @@ async def github_callback( url=settings.frontend_url, status_code=status.HTTP_303_SEE_OTHER, ) - redirect.delete_cookie(_OAUTH_STATE_COOKIE) redirect.set_cookie( settings.session_cookie_name, session.token, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index cfcc597..f831f4b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -37,6 +37,10 @@ class Settings(BaseSettings): session_cookie_secure: bool = False session_ttl_hours: int = 24 * 7 + # Used to HMAC-sign short-lived values like the OAuth `state` param. Set + # this to a long random string in production. + session_secret: str = "dev-only-change-me" + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0da9fde..0bed26e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,7 @@ import uuid +from datetime import datetime -from sqlalchemy import BigInteger, String, Text +from sqlalchemy import BigInteger, DateTime, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -20,6 +21,14 @@ class User(Base): email: Mapped[str | None] = mapped_column(String(320), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) - # The GitHub user-access token. Used so we can clone private repos on the - # user's behalf during deployment. + # GitHub App user-to-server access token. Used to act on the user's behalf + # (e.g. clone private repos during deployment). Default lifetime is 8h + # unless the App opted out of expiration. github_access_token: Mapped[str | None] = mapped_column(Text, nullable=True) + github_access_token_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + github_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True) + github_refresh_token_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index b1d9279..6775122 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -7,13 +7,22 @@ from app.core.config import get_settings from app.models.session import Session from app.models.user import User -from app.services.github_oauth import GitHubUser +from app.services import github_oauth +from app.services.github_oauth import GitHubToken, GitHubUser + + +def _apply_token(user: User, token: GitHubToken) -> None: + user.github_access_token = token.access_token + user.github_access_token_expires_at = token.access_token_expires_at + if token.refresh_token is not None: + user.github_refresh_token = token.refresh_token + user.github_refresh_token_expires_at = token.refresh_token_expires_at async def upsert_user( db: AsyncSession, gh_user: GitHubUser, - access_token: str, + token: GitHubToken, ) -> User: user = await db.scalar(select(User).where(User.github_id == gh_user.id)) if user is None: @@ -23,7 +32,6 @@ async def upsert_user( name=gh_user.name, email=gh_user.email, avatar_url=gh_user.avatar_url, - github_access_token=access_token, ) db.add(user) else: @@ -31,7 +39,7 @@ async def upsert_user( user.name = gh_user.name user.email = gh_user.email user.avatar_url = gh_user.avatar_url - user.github_access_token = access_token + _apply_token(user, token) await db.flush() return user @@ -48,15 +56,18 @@ async def create_session(db: AsyncSession, user: User) -> Session: return session +def _as_aware_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + return value if value.tzinfo else value.replace(tzinfo=UTC) + + async def get_session_user(db: AsyncSession, token: str) -> User | None: session = await db.get(Session, token) if session is None: return None - # SQLite drops tz info on read; assume stored values are UTC. - expires_at = session.expires_at - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=UTC) - if expires_at < datetime.now(UTC): + expires_at = _as_aware_utc(session.expires_at) + if expires_at is not None and expires_at < datetime.now(UTC): await db.delete(session) await db.flush() return None @@ -68,3 +79,27 @@ async def delete_session(db: AsyncSession, token: str) -> None: if session is not None: await db.delete(session) await db.flush() + + +async def get_valid_github_token(db: AsyncSession, user: User) -> str | None: + """Return a non-expired GitHub access token for `user`, refreshing if needed. + + Returns None if there's no token or it can no longer be refreshed. + """ + now = datetime.now(UTC) + skew = timedelta(seconds=60) + expires_at = _as_aware_utc(user.github_access_token_expires_at) + + if user.github_access_token and (expires_at is None or expires_at - skew > now): + return user.github_access_token + + if not user.github_refresh_token: + return None + refresh_expires = _as_aware_utc(user.github_refresh_token_expires_at) + if refresh_expires is not None and refresh_expires <= now: + return None + + new_token = await github_oauth.refresh_access_token(user.github_refresh_token) + _apply_token(user, new_token) + await db.flush() + return user.github_access_token diff --git a/backend/app/services/github_oauth.py b/backend/app/services/github_oauth.py index a32c5a6..f173ba4 100644 --- a/backend/app/services/github_oauth.py +++ b/backend/app/services/github_oauth.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from urllib.parse import urlencode import httpx @@ -20,19 +21,61 @@ class GitHubUser: avatar_url: str | None +@dataclass +class GitHubToken: + """Result of an access-token exchange or refresh. + + GitHub App user tokens expire (8h by default). `refresh_token` can be used + to mint a new access token until it too expires (~6 months by default). + Both expiry fields will be `None` if the App opted out of expiration. + """ + + access_token: str + token_type: str + access_token_expires_at: datetime | None + refresh_token: str | None + refresh_token_expires_at: datetime | None + + def build_authorize_url(state: str) -> str: + """Build the URL we redirect users to so they can authorize the GitHub App. + + GitHub App user tokens use the App's fine-grained permissions, so no + `scope` parameter is sent. + """ settings = get_settings() params = { "client_id": settings.github_client_id, "redirect_uri": settings.github_redirect_uri, - "scope": settings.github_oauth_scopes, "state": state, - "allow_signup": "true", } return f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}" -async def exchange_code_for_token(code: str) -> str: +def _parse_token_response(body: dict) -> GitHubToken: + if "access_token" not in body: + raise ValueError(f"GitHub token response missing access_token: {body!r}") + + now = datetime.now(UTC) + access_expires = ( + now + timedelta(seconds=int(body["expires_in"])) if body.get("expires_in") else None + ) + refresh_expires = ( + now + timedelta(seconds=int(body["refresh_token_expires_in"])) + if body.get("refresh_token_expires_in") + else None + ) + + return GitHubToken( + access_token=body["access_token"], + token_type=body.get("token_type", "bearer"), + access_token_expires_at=access_expires, + refresh_token=body.get("refresh_token"), + refresh_token_expires_at=refresh_expires, + ) + + +async def exchange_code_for_token(code: str) -> GitHubToken: """Exchange an OAuth `code` for a user access token.""" settings = get_settings() async with httpx.AsyncClient(timeout=10.0) as client: @@ -47,12 +90,25 @@ async def exchange_code_for_token(code: str) -> str: }, ) response.raise_for_status() - body = response.json() + return _parse_token_response(response.json()) - token = body.get("access_token") - if not token: - raise ValueError(f"GitHub token exchange failed: {body!r}") - return token + +async def refresh_access_token(refresh_token: str) -> GitHubToken: + """Use a refresh token to mint a new user access token.""" + settings = get_settings() + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + GITHUB_TOKEN_URL, + headers={"Accept": "application/json"}, + data={ + "client_id": settings.github_client_id, + "client_secret": settings.github_client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + ) + response.raise_for_status() + return _parse_token_response(response.json()) async def fetch_user(access_token: str) -> GitHubUser: @@ -68,8 +124,9 @@ async def fetch_user(access_token: str) -> GitHubUser: email = user.get("email") if not email: - # The /user endpoint omits the email when the user has it set to private; - # /user/emails returns all verified addresses. + # /user omits email when the user has it set to private; /user/emails + # returns the verified addresses (requires `email` user permission + # on the GitHub App, otherwise this returns 404 and we just skip). emails_resp = await client.get(GITHUB_USER_EMAILS_URL) if emails_resp.status_code == 200: emails = emails_resp.json() diff --git a/backend/app/services/oauth_state.py b/backend/app/services/oauth_state.py new file mode 100644 index 0000000..2b3d73c --- /dev/null +++ b/backend/app/services/oauth_state.py @@ -0,0 +1,38 @@ +"""Stateless, HMAC-signed values for the OAuth `state` parameter. + +We avoid storing the expected state in a cookie (which is fragile across +browsers / SameSite settings / redirects) by embedding an HMAC signature +right in the value. On callback, we just verify the signature. + +Format: ".." +""" + +import hashlib +import hmac +import secrets +import time + +from app.core.config import get_settings + +_MAX_AGE_SECONDS = 600 # 10 minutes is plenty to complete the OAuth round trip. + + +def _sign(message: str, secret: str) -> str: + return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest() + + +def issue_state() -> str: + secret = get_settings().session_secret + nonce = secrets.token_urlsafe(16) + ts = str(int(time.time())) + msg = f"{nonce}.{ts}" + return f"{msg}.{_sign(msg, secret)}" + + +def verify_state(state: str) -> bool: + secret = get_settings().session_secret + nonce, ts_str, sig = state.rsplit(".", 2) + expected = _sign(f"{nonce}.{ts_str}", secret) + if not hmac.compare_digest(sig, expected): + return False + return (time.time() - int(ts_str)) <= _MAX_AGE_SECONDS diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 3298b8d..d0d4797 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -8,7 +8,7 @@ def test_login_redirects_to_github(client: TestClient) -> None: response = client.get("/api/v1/auth/github/login", follow_redirects=False) assert response.status_code == 307 assert response.headers["location"].startswith(github_oauth.GITHUB_AUTHORIZE_URL) - assert "dploy_oauth_state" in response.cookies + assert "state=" in response.headers["location"] def test_me_unauthenticated(client: TestClient) -> None: @@ -37,9 +37,15 @@ def test_github_callback_full_flow( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - async def fake_exchange(code: str) -> str: + async def fake_exchange(code: str) -> github_oauth.GitHubToken: assert code == "abc123" - return "ghu_fake_token" + return github_oauth.GitHubToken( + access_token="ghu_fake_token", + token_type="bearer", + access_token_expires_at=None, + refresh_token="ghr_fake_refresh", + refresh_token_expires_at=None, + ) async def fake_fetch(token: str) -> github_oauth.GitHubUser: assert token == "ghu_fake_token" @@ -60,11 +66,13 @@ async def fake_fetch(token: str) -> github_oauth.GitHubUser: fake_fetch, ) - # Pretend the user already started the OAuth dance and has a state cookie. - client.cookies.set("dploy_oauth_state", "state-xyz") + # The state is HMAC-signed; we mint a real one the same way the login route does. + from app.services import oauth_state + + state = oauth_state.issue_state() response = client.get( "/api/v1/auth/github/callback", - params={"code": "abc123", "state": "state-xyz"}, + params={"code": "abc123", "state": state}, follow_redirects=False, ) assert response.status_code == 303