Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dda14f0
feat: adds first auth capabilities
r0liveir Jul 20, 2026
1426bb9
feat: add .env example and db/dependency injections for backend
r0liveir Jul 20, 2026
f5139d5
refactor: switch from factory pattern to pure function
r0liveir Jul 20, 2026
04c894f
chore: update dependencies
r0liveir Jul 20, 2026
297da1d
refactor: refactors files to use true dependency injection + adds aut…
r0liveir Jul 20, 2026
d8202fc
style: formats code with ruff format
r0liveir Jul 20, 2026
3505b2e
fix: fix migrations bug
r0liveir Jul 20, 2026
5649325
test: adds initial seed.sql for testing purposes
r0liveir Jul 20, 2026
a328882
docs: add supabase workflow readme
r0liveir Jul 20, 2026
bd2798a
refactor: refactor db session lookup on views.py by adding a lifespan…
r0liveir Jul 20, 2026
4da3e54
refactor: adds (mostly) better error handling for WS and HTTP auth er…
r0liveir Jul 20, 2026
5e7b2d9
refactor: formats some dependency injections to Annotated[..., Depend…
r0liveir Jul 20, 2026
93b6b50
feat: add initial database schemas
r0liveir Jul 21, 2026
c011937
feat(session): add committee-scoped session access
r0liveir Jul 21, 2026
6fcc0c8
test: add authentication and session coverage
r0liveir Jul 21, 2026
bd57b65
fix: override vulnerable js-yaml dependency
r0liveir Jul 21, 2026
83dc369
fix(supabase): seed password-login users
r0liveir Jul 21, 2026
77bbcc8
feat(auth): verify Supabase JWKS tokens
r0liveir Jul 21, 2026
5f7f646
feat(frontend): add Supabase session authentication
r0liveir Jul 21, 2026
5c9fce7
docs: update README
r0liveir Jul 21, 2026
424dd3b
chore: add Makefile and some fixes for .env on docker
r0liveir Jul 21, 2026
39d1566
chore: delete root .env
r0liveir Jul 21, 2026
92f4225
fix(dev): refresh frontend schemas and dependencies
r0liveir Jul 21, 2026
e7c88b1
docs: adds instructions for dev workflow
r0liveir Jul 21, 2026
13a4a0f
fix: fixes websocket connection and state_snapshot json insertion
r0liveir Jul 22, 2026
e5ac790
feat: adds prepare_session_connect function that rehydrates missing m…
r0liveir Jul 22, 2026
c9404f1
tests: add tests for prepare_session
r0liveir Jul 22, 2026
8416bd4
fix: fix ws disconnect log message when using React Strict Mode
r0liveir Jul 22, 2026
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
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.PHONY: dev stop

dev:
echo "Starting dev environment"
supabase start || npx supabase start
docker compose up --build

stop:
echo "Stopping dev environment"
docker compose down
supabase stop || npx supabase stop


29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
# webmun
# WebMUN

Projeto do USP CodeLab para simulações de seções da ONU
USPCodeLab project for managing and hosting real time Model United Nations

# Prerequisites

This project stack is composed of:
- React 19 + Vite 8 as the frontend stack
- Tailwind, Shadcn as UI libraries
- Python 3.11, FastAPI, pytest and ruff
- Supabase for database and auth
- Redis for in-memory database

Further improvements are planned to make this a cloud-native application.

# Local development

To start the backend, frontend and supabase, issue:

```
make dev
```

To stop these services, do:

```
make stop
```
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
SUPABASE_URL=http://127.0.0.1:54321
# Required only while supporting legacy HS256 Supabase tokens.
SUPABASE_JWT_SECRET=jwt_secret_here
DATABASE_URL=postgresql+asyncpg://postgres:postgres@127.0.0.1:54322/postgres
12 changes: 12 additions & 0 deletions backend/app/access/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from dataclasses import dataclass
from typing import Literal
from uuid import UUID


@dataclass(frozen=True)
class CommitteeAssignment:
"""Object that holds info about an UUID to a commitee and Delegation / Chair"""
user_id: UUID
committee_id: int # TODO: remove this to map out to committees/conferences
role: Literal["chair", "delegate"]
representation_id: int | None
69 changes: 69 additions & 0 deletions backend/app/access/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from uuid import UUID

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from .models import CommitteeAssignment

# TODO: pass this to a conference/ domain
async def get_committee_assignment(
session: AsyncSession, user_id: UUID, committee_id: int
) -> CommitteeAssignment | None:
query = text("""
SELECT
ca.user_id,
ca.committee_id,
ca.role,
ca.representation_id
FROM public.committees c
JOIN public.committee_assignments ca
ON c.id = ca.committee_id
WHERE c.id = :committee_id AND
ca.user_id = :user_id
""")

result = await session.execute(
query, {"committee_id": committee_id, "user_id": user_id}
)

row = result.mappings().one_or_none()
if row is None:
return None

return CommitteeAssignment(
user_id=row["user_id"],
committee_id=row["committee_id"],
role=row["role"],
representation_id=row["representation_id"],
)


async def get_session_assignment(
session: AsyncSession, user_id: UUID, session_id: int
) -> CommitteeAssignment | None:
"""Get a user's assignment for the committee that owns a session."""
query = text("""
SELECT
ca.user_id,
ca.committee_id,
ca.role,
ca.representation_id
FROM public.sessions s
JOIN public.committee_assignments ca
ON ca.committee_id = s.committee_id
WHERE s.id = :session_id
AND ca.user_id = :user_id
""")

result = await session.execute(
query, {"session_id": session_id, "user_id": user_id}
)
row = result.mappings().one_or_none()
if row is None:
return None

return CommitteeAssignment(
user_id=row["user_id"],
committee_id=row["committee_id"],
role=row["role"],
representation_id=row["representation_id"],
)
64 changes: 64 additions & 0 deletions backend/app/access/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from typing import Literal
from uuid import UUID

from sqlalchemy.ext.asyncio import AsyncSession
from app.access.models import CommitteeAssignment
from .repository import get_committee_assignment, get_session_assignment


class AccessDenied(Exception): ...


async def resolve_committee_assignment(
session: AsyncSession,
user_id: UUID,
committee_id: int,
) -> CommitteeAssignment:
assignment: CommitteeAssignment | None = await get_committee_assignment(
session, user_id, committee_id
)

if assignment is None:
raise AccessDenied("User has no committee assignment")

if assignment.role == 'delegate' and assignment.representation_id is None:
raise AccessDenied("Delegate role has no delegation id")

return assignment


async def resolve_session_assignment(
session: AsyncSession,
user_id: UUID,
session_id: int,
) -> CommitteeAssignment:
"""Resolve the assignment for a session without trusting client committee data."""
assignment = await get_session_assignment(session, user_id, session_id)

if assignment is None:
raise AccessDenied("User has no assignment for this session")

if assignment.role == "delegate" and assignment.representation_id is None:
raise AccessDenied("Delegate role has no delegation id")

return assignment


async def verify_user_role(
session: AsyncSession,
user_id: UUID,
committee_id: int,
required_role: Literal["chair", "delegate"],
) -> CommitteeAssignment:
"""Require a user's role within one specific committee."""
assignment = await get_committee_assignment(session, user_id, committee_id)

if assignment is None:
raise AccessDenied("User has no committee assignment")

if assignment.role != required_role:
raise AccessDenied(
f"User requires the {required_role} role for this committee"
)

return assignment
35 changes: 35 additions & 0 deletions backend/app/access/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dep import get_current_user
from app.auth.service import AuthUser
from app.core.database import get_db_session

from .service import AccessDenied, resolve_session_assignment

router = APIRouter()


@router.get("/sessions/{session_id}/me")
async def get_my_session_access(
session_id: int,
db_session: Annotated[AsyncSession, Depends(get_db_session)],
current_user: Annotated[AuthUser, Depends(get_current_user)],
):
"""Return the authenticated user's actor context for a session."""
try:
assignment = await resolve_session_assignment(
db_session, current_user.user_id, session_id
)
except AccessDenied as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)

return {
"role": assignment.role,
"representation_id": assignment.representation_id,
}
33 changes: 33 additions & 0 deletions backend/app/auth/dep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from fastapi import Depends, status
from fastapi.exceptions import HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.auth.service import AuthUser, TokenExpiredError, TokenInvalidError, verify_jwt_token
from typing import Annotated

from app.core.config import Settings, get_settings

bearer = HTTPBearer()

def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
settings: Annotated[Settings, Depends(get_settings)],
) -> AuthUser:
"""Dependency injection to get current user"""
try:
return verify_jwt_token(
token=credentials.credentials,
settings=settings,
)
except TokenExpiredError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token_expired", # TODO: map this out to an outer table, since both WS and HTTP uses this
headers={"WWW-Authenticate": "Bearer"},
) from e
except TokenInvalidError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token_invalid", # TODO: map this out to an outer table, since both WS and HTTP uses this
headers={"WWW-Authenticate": "Bearer"},
) from e

57 changes: 57 additions & 0 deletions backend/app/auth/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import jwt
from functools import lru_cache
from uuid import UUID
from pydantic import BaseModel
from app.core.config import Settings

# Self documented errors: might be better for protection
class TokenInvalidError(Exception):
pass

class TokenExpiredError(Exception):
pass

class AuthUser(BaseModel):
user_id: UUID
email: str | None


@lru_cache
def get_jwk_client(supabase_url: str) -> jwt.PyJWKClient:
"""Cache Supabase's public signing keys for asymmetric access tokens."""
return jwt.PyJWKClient(
f"{supabase_url.rstrip('/')}/auth/v1/.well-known/jwks.json"
)


def verify_jwt_token(
token: str, # self contained token that already has info like id, email, etc
settings: Settings,
) -> AuthUser:
"""Verify either legacy HS256 or current Supabase ES256 access tokens."""
try:
algorithm = jwt.get_unverified_header(token).get("alg")

if algorithm == "HS256":
key = settings.SUPABASE_JWT_SECRET.get_secret_value()
elif algorithm == "ES256":
key = get_jwk_client(str(settings.SUPABASE_URL)).get_signing_key_from_jwt(
token
).key
else:
raise TokenInvalidError("Unsupported access token algorithm")

payload = jwt.decode(
token,
key,
algorithms=[algorithm],
audience="authenticated",
)

return AuthUser(user_id=UUID(payload["sub"]), email=payload.get("email"))

except jwt.exceptions.ExpiredSignatureError:
raise TokenExpiredError("Access token expired")

except (jwt.exceptions.PyJWTError, KeyError, TypeError, ValueError):
raise TokenInvalidError("Access token invalid")
25 changes: 25 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from functools import lru_cache
from pydantic import AnyHttpUrl, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
# app name
APP_NAME: str = "Meu App FastAPI"
ENVIRONMENT: str = "development"

# db config
DATABASE_URL: SecretStr

# supabase config
SUPABASE_URL: AnyHttpUrl
SUPABASE_JWT_SECRET: SecretStr
JWT_ALGORITHM: str = "HS256"

# Host development uses backend/.env; containers and cloud inject process env.
model_config = SettingsConfigDict(env_file=".env", extra="ignore")


@lru_cache
def get_settings():
return Settings()
38 changes: 38 additions & 0 deletions backend/app/core/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncEngine,
create_async_engine,
async_sessionmaker,
AsyncSession,
)
from app.core.config import Settings
from fastapi import Request


def create_db(
settings: Settings,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
database_url = settings.DATABASE_URL.get_secret_value()
if database_url.startswith("postgresql://"):
database_url = database_url.replace(
"postgresql://", "postgresql+asyncpg://", 1
)

engine = create_async_engine(
database_url,
pool_pre_ping=True,
connect_args={"prepared_statement_cache_size": 0},
)

session_factory = async_sessionmaker(engine, expire_on_commit=False)
return engine, session_factory


async def get_db_session(
request: Request,
) -> AsyncGenerator[AsyncSession, None]:
"""Dependency Injection for DB session. Uses app state for storing db setup"""
session_factory = request.app.state.db_session_factory

async with session_factory() as session:
yield session
Loading