diff --git a/.env.example b/.env.example index 63179c8..8ee1234 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,13 @@ OCR_LANG=eng MEMORY_WARNING_THRESHOLD=80 MEMORY_REJECT_THRESHOLD=95 +# ============================================================================= +# TLSync Configuration +# ============================================================================= +# Server-side secret used to sign short-lived TLSync auth tokens. Do not expose with a VITE_ prefix. +TLSYNC_SECRET=change-me-server-side-only +TLSYNC_TOKEN_TTL_SECONDS=300 + # ============================================================================= # CORS Configuration # ============================================================================= diff --git a/routers/tlsync_token.py b/routers/tlsync_token.py new file mode 100644 index 0000000..5fb2938 --- /dev/null +++ b/routers/tlsync_token.py @@ -0,0 +1,65 @@ +import os +import time +from typing import Any, Dict +from uuid import uuid4 + +import jwt +from fastapi import APIRouter, Depends, HTTPException + +from modules.auth.supabase_bearer import verify_supabase_token_dep + + +router = APIRouter() + +TLSYNC_TOKEN_AUDIENCE = "tlsync" +DEFAULT_TLSYNC_TOKEN_TTL_SECONDS = 300 + + +def _tlsync_token_ttl_seconds() -> int: + raw_value = os.getenv("TLSYNC_TOKEN_TTL_SECONDS") + if not raw_value: + return DEFAULT_TLSYNC_TOKEN_TTL_SECONDS + + try: + ttl = int(raw_value) + except ValueError as exc: + raise HTTPException(status_code=500, detail="TLSync token TTL is misconfigured") from exc + + if ttl <= 0 or ttl > 3600: + raise HTTPException(status_code=500, detail="TLSync token TTL is out of range") + return ttl + + +def create_tlsync_token(user_claims: Dict[str, Any]) -> Dict[str, Any]: + secret = os.getenv("TLSYNC_SECRET") + if not secret: + raise HTTPException(status_code=503, detail="TLSync authentication is not configured") + + user_id = user_claims.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="Authenticated user id is missing") + + ttl_seconds = _tlsync_token_ttl_seconds() + issued_at = int(time.time()) + expires_at = issued_at + ttl_seconds + payload = { + "sub": user_id, + "aud": TLSYNC_TOKEN_AUDIENCE, + "iat": issued_at, + "exp": expires_at, + "jti": uuid4().hex, + } + + token = jwt.encode(payload, secret, algorithm="HS256") + return { + "token": token, + "token_type": "Bearer", + "expires_in": ttl_seconds, + "expires_at": expires_at, + } + + +@router.get("/token") +async def get_tlsync_token(user_claims: Dict[str, Any] = Depends(verify_supabase_token_dep)) -> Dict[str, Any]: + """Issue a short-lived TLSync token for an authenticated Supabase user.""" + return create_tlsync_token(user_claims) diff --git a/run/routers.py b/run/routers.py index 9290ad1..52a4f5b 100644 --- a/run/routers.py +++ b/run/routers.py @@ -38,6 +38,7 @@ from routers import provisioning as provisioning_router from routers.transcribe.sessions import router as sessions_router from routers.transcribe.canvas_events import router as canvas_events_router from routers.transcribe.keywords import router as keywords_router +from routers import tlsync_token as tlsync_token_router def register_routes(app: FastAPI): logger.info("Starting to register routes...") @@ -124,6 +125,9 @@ def register_routes(app: FastAPI): # Provisioning Routes app.include_router(provisioning_router.router) + # TLSync auth token route + app.include_router(tlsync_token_router.router, prefix="/api/tlsync", tags=["TLSync"]) + # Transcription Routes (CIS Phase 1) app.include_router(sessions_router, prefix="/transcribe", tags=["Transcription Sessions"]) app.include_router(canvas_events_router, prefix="/transcribe", tags=["Transcription Canvas Events"]) diff --git a/tests/test_tlsync_token.py b/tests/test_tlsync_token.py new file mode 100644 index 0000000..1eb3893 --- /dev/null +++ b/tests/test_tlsync_token.py @@ -0,0 +1,69 @@ +import jwt +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from modules.auth.supabase_bearer import verify_supabase_token_dep +from routers.tlsync_token import TLSYNC_TOKEN_AUDIENCE, create_tlsync_token, router + + +def test_create_tlsync_token_is_short_lived_and_signed(monkeypatch): + monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes") + monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "120") + + response = create_tlsync_token({"sub": "user-123"}) + + assert response["token_type"] == "Bearer" + assert response["expires_in"] == 120 + assert response["token"] + + payload = jwt.decode( + response["token"], + "test-tlsync-secret-with-at-least-32-bytes", + algorithms=["HS256"], + audience=TLSYNC_TOKEN_AUDIENCE, + ) + assert payload["sub"] == "user-123" + assert payload["exp"] == response["expires_at"] + assert payload["exp"] - payload["iat"] == 120 + assert payload["jti"] + + +def test_create_tlsync_token_requires_tlsync_secret(monkeypatch): + monkeypatch.delenv("TLSYNC_SECRET", raising=False) + + with pytest.raises(HTTPException) as excinfo: + create_tlsync_token({"sub": "user-123"}) + + assert excinfo.value.status_code == 503 + + +def test_create_tlsync_token_requires_authenticated_subject(monkeypatch): + monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes") + + with pytest.raises(HTTPException) as excinfo: + create_tlsync_token({}) + + assert excinfo.value.status_code == 401 + + +def test_tlsync_token_route_uses_authenticated_user_claims(monkeypatch): + monkeypatch.setenv("TLSYNC_SECRET", "test-tlsync-secret-with-at-least-32-bytes") + monkeypatch.setenv("TLSYNC_TOKEN_TTL_SECONDS", "60") + + app = FastAPI() + app.include_router(router, prefix="/api/tlsync") + app.dependency_overrides[verify_supabase_token_dep] = lambda: {"sub": "route-user-123"} + + response = TestClient(app).get("/api/tlsync/token", headers={"Authorization": "Bearer supabase-jwt"}) + + assert response.status_code == 200 + body = response.json() + payload = jwt.decode( + body["token"], + "test-tlsync-secret-with-at-least-32-bytes", + algorithms=["HS256"], + audience=TLSYNC_TOKEN_AUDIENCE, + ) + assert payload["sub"] == "route-user-123" + assert body["expires_in"] == 60