From 93fb35eae782ca1fa085249fc22ffa39abf90b6d Mon Sep 17 00:00:00 2001 From: kcar Date: Thu, 28 May 2026 15:12:51 +0100 Subject: [PATCH] feat: add authenticated tlsync token endpoint --- .env.example | 5 +++ routers/tlsync.py | 44 ++++++++++++++++++++++++++ run/routers.py | 4 +++ tests/test_tlsync_token.py | 64 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 routers/tlsync.py create mode 100644 tests/test_tlsync_token.py diff --git a/.env.example b/.env.example index 63179c8..702e8f3 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,11 @@ OCR_LANG=eng MEMORY_WARNING_THRESHOLD=80 MEMORY_REJECT_THRESHOLD=95 +# ============================================================================= +# TLSync Configuration +# ============================================================================= +TLSYNC_SECRET=your-tlsync-shared-secret + # ============================================================================= # CORS Configuration # ============================================================================= diff --git a/routers/tlsync.py b/routers/tlsync.py new file mode 100644 index 0000000..c59db6b --- /dev/null +++ b/routers/tlsync.py @@ -0,0 +1,44 @@ +import os +from typing import Dict + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from modules.auth.supabase_bearer import verify_supabase_jwt_str + +router = APIRouter() +security = HTTPBearer(auto_error=False) + + +async def require_supabase_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), +) -> Dict: + """Validate a Supabase bearer token and return the decoded session claims.""" + if credentials is None or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + return verify_supabase_jwt_str(credentials.credentials) + except HTTPException as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=exc.detail, + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + +@router.get("/token") +async def get_tlsync_token(_claims: Dict = Depends(require_supabase_user)) -> Dict[str, str]: + """Return the API-side TLSync shared secret for authenticated clients.""" + tlsync_secret = os.getenv("TLSYNC_SECRET") + if not tlsync_secret: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="TLSync secret is not configured", + ) + + return {"token": tlsync_secret} diff --git a/run/routers.py b/run/routers.py index 9290ad1..4b75df0 100644 --- a/run/routers.py +++ b/run/routers.py @@ -35,6 +35,7 @@ from routers.dev.test_analysis import router as test_analysis_router from routers.queue_management import router as queue_management_router from routers.maintenance.redis_admin import router as redis_admin_router from routers import provisioning as provisioning_router +from routers import tlsync as tlsync_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 @@ -124,6 +125,9 @@ def register_routes(app: FastAPI): # Provisioning Routes app.include_router(provisioning_router.router) + # TLSync runtime auth route + app.include_router(tlsync_router.router, prefix="/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..0a4147f --- /dev/null +++ b/tests/test_tlsync_token.py @@ -0,0 +1,64 @@ +import importlib +import time + +import jwt +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def _client(monkeypatch, jwt_secret="test-jwt-secret-with-at-least-32-bytes", tlsync_secret="test-tlsync-secret"): + monkeypatch.setenv("JWT_SECRET", jwt_secret) + if tlsync_secret is None: + monkeypatch.delenv("TLSYNC_SECRET", raising=False) + else: + monkeypatch.setenv("TLSYNC_SECRET", tlsync_secret) + + tlsync = importlib.import_module("routers.tlsync") + app = FastAPI() + app.include_router(tlsync.router, prefix="/tlsync") + return TestClient(app), jwt_secret + + +def _token(jwt_secret: str) -> str: + now = int(time.time()) + return jwt.encode( + { + "sub": "user-123", + "aud": "authenticated", + "iat": now, + "exp": now + 3600, + }, + jwt_secret, + algorithm="HS256", + ) + + +def test_tlsync_token_requires_bearer_auth(monkeypatch): + client, _ = _client(monkeypatch) + + response = client.get("/tlsync/token") + + assert response.status_code == 401 + + +def test_tlsync_token_returns_configured_secret(monkeypatch): + client, jwt_secret = _client(monkeypatch, tlsync_secret="runtime-secret") + + response = client.get( + "/tlsync/token", + headers={"Authorization": f"Bearer {_token(jwt_secret)}"}, + ) + + assert response.status_code == 200 + assert response.json() == {"token": "runtime-secret"} + + +def test_tlsync_token_500_when_secret_missing(monkeypatch): + client, jwt_secret = _client(monkeypatch, tlsync_secret=None) + + response = client.get( + "/tlsync/token", + headers={"Authorization": f"Bearer {_token(jwt_secret)}"}, + ) + + assert response.status_code == 500