Skip to content
Open
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
5 changes: 5 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ DB_USER=tandemcode
DB_PASSWORD=

CORS_ORIGINS=http://localhost:5173

# Clerk instance that signs session tokens. Find it in the Clerk dashboard
# under API Keys as the Frontend API URL. Not a secret.
CLERK_ISSUER=https://your-app.clerk.accounts.dev

RUN_MIGRATIONS_ON_STARTUP=true
55 changes: 55 additions & 0 deletions apps/backend/app/core/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Verification of Clerk session tokens.

Clerk signs session tokens with RS256 and publishes the matching public keys at
its issuer's JWKS endpoint. Tokens are therefore verified here, in process, and
nothing on the request path calls out to Clerk once the key set is cached.
"""

from __future__ import annotations

import jwt
from anyio import to_thread
from jwt import PyJWKClient

from app.core.config import CLERK_AUTHORIZED_PARTIES, CLERK_ISSUER

# Clerk issues short-lived tokens and its frontend SDK refreshes them, so this
# only has to absorb clock drift between us and Clerk.
_LEEWAY_SECONDS = 10

# Caches the key set internally, so this is one fetch every few minutes rather
# than one per request.
_jwks = PyJWKClient(f"{CLERK_ISSUER}/.well-known/jwks.json")


class TokenError(Exception):
"""A session token was absent, malformed, expired, or not issued to us."""


async def clerk_user_id(token: str) -> str:
"""Return the Clerk user id that `token` proves, or raise TokenError."""
try:
# On a cache miss this fetches over the network with a blocking client.
signing_key = await to_thread.run_sync(_jwks.get_signing_key_from_jwt, token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=CLERK_ISSUER,
leeway=_LEEWAY_SECONDS,
# Clerk session tokens carry no `aud`. The `azp` check below is the
# equivalent guard, so requiring an audience here would reject
# every real token.
options={"verify_aud": False, "require": ["exp", "iat", "sub"]},
)
except jwt.PyJWTError as exc:
raise TokenError(str(exc)) from exc

# `azp` is the origin Clerk minted the token for. Without this, a token
# issued to any other application on the same Clerk instance would be
# accepted here.
azp = claims.get("azp")
if azp not in CLERK_AUTHORIZED_PARTIES:
raise TokenError(f"Token was issued to {azp!r}, which is not an allowed origin")

return claims["sub"]
13 changes: 13 additions & 0 deletions apps/backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,16 @@ def _required(name: str) -> str:
"true",
"yes",
}

# The Clerk instance that issues session tokens, e.g.
# https://your-app-42.clerk.accounts.dev. Required: a backend that cannot check
# tokens has no business starting.
CLERK_ISSUER = _required("CLERK_ISSUER").rstrip("/")

# Clerk stamps the requesting origin into each token's `azp` claim. Our own
# origins are the right default, since the browser app is the only client.
CLERK_AUTHORIZED_PARTIES = [
party.strip()
for party in os.getenv("CLERK_AUTHORIZED_PARTIES", ",".join(CORS_ORIGINS)).split(",")
if party.strip()
]
4 changes: 3 additions & 1 deletion apps/backend/app/dao/room_members.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ async def remove_member(self, room_id: str, user_id: str) -> None:

async def list_members(self, room_id: str) -> list[dict]:
query = """
SELECT rm.user_id, u.name, u.email, rm.role, rm.joined_at
SELECT rm.user_id, u.name, u.email, rm.joined_at,
CASE WHEN r.created_by = rm.user_id THEN 'owner' ELSE 'participant' END AS role
FROM room_members rm
JOIN users u ON u.id = rm.user_id
JOIN rooms r ON r.id = rm.room_id
WHERE rm.room_id = $1
ORDER BY rm.joined_at ASC
"""
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/app/dao/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ async def list_active_by_creator(self, user_id: str) -> list[dict]:
rows = await conn.fetch(query, user_id)
return [_map_room(row) for row in rows]

async def deactivate(self, room_id: str) -> None:
query = "UPDATE rooms SET is_active = FALSE WHERE id = $1"
async with self.pool.acquire() as conn:
await conn.execute(query, room_id)

async def exists(self, room_id: str) -> bool:
query = "SELECT EXISTS(SELECT 1 FROM rooms WHERE id = $1)"
async with self.pool.acquire() as conn:
Expand Down
29 changes: 28 additions & 1 deletion apps/backend/app/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import asyncpg
from fastapi import Request
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.core.auth import TokenError, clerk_user_id
from app.dao.problems import ProblemDAO
from app.dao.room_members import RoomMemberDAO
from app.dao.rooms import RoomDAO
Expand All @@ -14,6 +16,31 @@
from app.services.users import UserService


# auto_error=False so a missing header lands on our own 401 below rather than
# FastAPI's bare 403.
_bearer = HTTPBearer(auto_error=False)


async def current_user_id(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> str:
"""The Clerk user id behind this request."""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
return await clerk_user_id(credentials.credentials)
except TokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
headers={"WWW-Authenticate": "Bearer"},
) from exc


def get_pool(request: Request) -> asyncpg.Pool:
return request.app.state.db_pool

Expand Down
14 changes: 11 additions & 3 deletions apps/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from app.routes.rooms import router as rooms_router
from app.routes.submissions import router as submissions_router
from app.routes.users import router as users_router
from app.websocket.auth import authenticate
from app.websocket.room_chat import RoomChatManager
from app.websocket.yjs import YjsRelayManager

Expand Down Expand Up @@ -56,13 +57,17 @@ async def healthcheck() -> dict[str, str]:

@app.websocket("/ws/room/{room_id}")
async def room_websocket(websocket: WebSocket, room_id: str) -> None:
user_id = await authenticate(websocket, room_id)
if user_id is None:
return

manager: RoomChatManager = websocket.app.state.room_chat_manager
room_member_dao = RoomMemberDAO(websocket.app.state.db_pool)
user_id = websocket.query_params.get("userId")

await manager.join(websocket, room_id, user_id, room_member_dao)

# join() is inside the try so that an accept() which fails still unwinds
# through leave() and takes the presence row with it.
try:
await manager.join(websocket, room_id, user_id, room_member_dao)
while True:
message = await websocket.receive_text()
await manager.broadcast(room_id, message)
Expand All @@ -74,6 +79,9 @@ async def room_websocket(websocket: WebSocket, room_id: str) -> None:

@app.websocket("/ws/yjs/{room_id}")
async def yjs_websocket(websocket: WebSocket, room_id: str) -> None:
if await authenticate(websocket, room_id) is None:
return

manager: YjsRelayManager = websocket.app.state.yjs_relay_manager
await manager.connect(websocket, room_id)

Expand Down
9 changes: 7 additions & 2 deletions apps/backend/app/routes/problems.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@

from fastapi import APIRouter, Depends, Query

from app.dependencies import get_problem_service
from app.dependencies import current_user_id, get_problem_service
from app.schemas.problems import CreateProblemRequest, ProblemResponse
from app.services.problems import ProblemService

router = APIRouter(prefix="/api/problems", tags=["problems"])
# Declared on the router so a route added later cannot quietly skip it.
router = APIRouter(
prefix="/api/problems",
tags=["problems"],
dependencies=[Depends(current_user_id)],
)


@router.get("", response_model=list[ProblemResponse])
Expand Down
31 changes: 25 additions & 6 deletions apps/backend/app/routes/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,31 @@

from fastapi import APIRouter, Depends

from app.dependencies import get_room_service
from app.dependencies import current_user_id, get_room_service
from app.schemas.rooms import (
CreateRoomRequest,
LeaveRoomResponse,
RoomResponse,
SetProblemRequest,
UserInRoomResponse,
)
from app.services.rooms import RoomService

router = APIRouter(prefix="/api/rooms", tags=["rooms"])
# Declared on the router so a route added later cannot quietly skip it.
router = APIRouter(
prefix="/api/rooms",
tags=["rooms"],
dependencies=[Depends(current_user_id)],
)


@router.post("", response_model=RoomResponse)
async def create_room(
payload: CreateRoomRequest,
user_id: str = Depends(current_user_id),
service: RoomService = Depends(get_room_service),
) -> RoomResponse:
room = await service.create_room(payload.name, payload.description, payload.createdBy)
room = await service.create_room(payload.name, payload.description, user_id)
return RoomResponse.model_validate(room)


Expand All @@ -43,26 +50,38 @@ async def list_rooms_by_creator(
@router.get("/{room_id}/members", response_model=list[UserInRoomResponse])
async def list_room_members(
room_id: str,
caller_id: str = Depends(current_user_id),
service: RoomService = Depends(get_room_service),
) -> list[UserInRoomResponse]:
members = await service.list_room_members(room_id)
members = await service.list_room_members(room_id, caller_id)
return [UserInRoomResponse.model_validate(member) for member in members]


@router.post("/{room_id}/leave", response_model=LeaveRoomResponse)
async def leave_room(
room_id: str,
caller_id: str = Depends(current_user_id),
service: RoomService = Depends(get_room_service),
) -> LeaveRoomResponse:
return LeaveRoomResponse.model_validate(await service.leave_room(room_id, caller_id))


@router.patch("/{room_id}/problem", response_model=RoomResponse)
async def set_current_problem(
room_id: str,
payload: SetProblemRequest,
caller_id: str = Depends(current_user_id),
service: RoomService = Depends(get_room_service),
) -> RoomResponse:
room = await service.set_current_problem(room_id, payload.problemId)
room = await service.set_current_problem(room_id, payload.problemId, caller_id)
return RoomResponse.model_validate(room)


@router.get("/{room_id}", response_model=RoomResponse)
async def get_room(
room_id: str,
caller_id: str = Depends(current_user_id),
service: RoomService = Depends(get_room_service),
) -> RoomResponse:
room = await service.get_room(room_id)
room = await service.get_room(room_id, caller_id)
return RoomResponse.model_validate(room)
17 changes: 12 additions & 5 deletions apps/backend/app/routes/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,26 @@

from fastapi import APIRouter, Depends, Query

from app.dependencies import get_submission_service
from app.dependencies import current_user_id, get_submission_service
from app.schemas.submissions import SubmissionResponse, SubmitRequest
from app.services.submissions import SubmissionService

router = APIRouter(prefix="/api/submissions", tags=["submissions"])
router = APIRouter(
prefix="/api/submissions",
tags=["submissions"],
dependencies=[Depends(current_user_id)],
)


@router.post("", response_model=SubmissionResponse)
async def submit_code(
payload: SubmitRequest,
user_id: str = Depends(current_user_id),
service: SubmissionService = Depends(get_submission_service),
) -> SubmissionResponse:
submission = await service.submit(
room_id=payload.roomId,
user_id=payload.userId,
user_id=user_id,
problem_id=payload.problemId,
language=payload.language,
code=payload.code,
Expand All @@ -30,16 +35,18 @@ async def submit_code(
async def list_submissions(
room_id: str,
userId: str | None = Query(default=None),
caller_id: str = Depends(current_user_id),
service: SubmissionService = Depends(get_submission_service),
) -> list[SubmissionResponse]:
submissions = await service.list_submissions(room_id, userId)
submissions = await service.list_submissions(room_id, caller_id, userId)
return [SubmissionResponse.model_validate(submission) for submission in submissions]


@router.get("/{submission_id}", response_model=SubmissionResponse)
async def get_submission(
submission_id: UUID,
caller_id: str = Depends(current_user_id),
service: SubmissionService = Depends(get_submission_service),
) -> SubmissionResponse:
submission = await service.get_submission(submission_id)
submission = await service.get_submission(submission_id, caller_id)
return SubmissionResponse.model_validate(submission)
14 changes: 11 additions & 3 deletions apps/backend/app/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@

from fastapi import APIRouter, Depends, status

from app.dependencies import get_user_service
from app.dependencies import current_user_id, get_user_service
from app.schemas.users import CreateUserRequest, UserResponse
from app.services.users import UserService

router = APIRouter(prefix="/api/users", tags=["users"])
# Declared on the router so a route added later cannot quietly skip it.
router = APIRouter(
prefix="/api/users",
tags=["users"],
dependencies=[Depends(current_user_id)],
)


@router.post("", response_model=UserResponse, status_code=status.HTTP_200_OK)
async def create_user(
payload: CreateUserRequest,
user_id: str = Depends(current_user_id),
service: UserService = Depends(get_user_service),
) -> UserResponse:
user = await service.create_user(payload.id, payload.email, payload.name)
# The id is the token's, never the body's. Email and name stay client-supplied
# because Clerk's default session token does not carry them.
user = await service.create_user(user_id, payload.email, payload.name)
return UserResponse.model_validate(user)


Expand Down
5 changes: 4 additions & 1 deletion apps/backend/app/schemas/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
class CreateRoomRequest(BaseModel):
name: str
description: str | None = None
createdBy: str


class SetProblemRequest(BaseModel):
problemId: UUID


class LeaveRoomResponse(BaseModel):
roomClosed: bool


class RoomResponse(BaseModel):
id: str
name: str
Expand Down
1 change: 0 additions & 1 deletion apps/backend/app/schemas/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

class SubmitRequest(BaseModel):
roomId: str
userId: str
problemId: UUID
language: str
code: str
Expand Down
Loading
Loading