From 9a258abc0209b0ac3799532a507ea6a9125d703a Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Mon, 20 Oct 2025 12:55:25 -0400 Subject: [PATCH] feat: api tokens (#705) * feat: api tokens (vibe) * self-review * remove token terminology + pr comments (vibe) * return email_verified --------- Co-authored-by: Igor Loskutov --- server/README.md | 26 +++ .../9e3f7b2a4c8e_add_user_api_keys.py | 38 ++++ server/reflector/app.py | 2 + server/reflector/auth/auth_jwt.py | 83 +++++--- server/reflector/db/__init__.py | 1 + server/reflector/db/user_api_keys.py | 90 +++++++++ server/reflector/views/user_api_keys.py | 62 ++++++ server/tests/test_user_api_keys.py | 70 +++++++ www/app/reflector-api.d.ts | 191 ++++++++++++++++++ 9 files changed, 532 insertions(+), 31 deletions(-) create mode 100644 server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py create mode 100644 server/reflector/db/user_api_keys.py create mode 100644 server/reflector/views/user_api_keys.py create mode 100644 server/tests/test_user_api_keys.py diff --git a/server/README.md b/server/README.md index f91a49bf..c078f493 100644 --- a/server/README.md +++ b/server/README.md @@ -1,3 +1,29 @@ +## API Key Management + +### Finding Your User ID + +```bash +# Get your OAuth sub (user ID) - requires authentication +curl -H "Authorization: Bearer " http://localhost:1250/v1/me +# Returns: {"sub": "your-oauth-sub-here", "email": "...", ...} +``` + +### Creating API Keys + +```bash +curl -X POST http://localhost:1250/v1/user/api-keys \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "My API Key"}' +``` + +### Using API Keys + +```bash +# Use X-API-Key header instead of Authorization +curl -H "X-API-Key: " http://localhost:1250/v1/transcripts +``` + ## AWS S3/SQS usage clarification Whereby.com uploads recordings directly to our S3 bucket when meetings end. diff --git a/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py b/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py new file mode 100644 index 00000000..ef8f881c --- /dev/null +++ b/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py @@ -0,0 +1,38 @@ +"""add user api keys + +Revision ID: 9e3f7b2a4c8e +Revises: dc035ff72fd5 +Create Date: 2025-10-17 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9e3f7b2a4c8e" +down_revision: Union[str, None] = "dc035ff72fd5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_api_key", + sa.Column("id", sa.String(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("key_hash", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + with op.batch_alter_table("user_api_key", schema=None) as batch_op: + batch_op.create_index("idx_user_api_key_hash", ["key_hash"], unique=True) + batch_op.create_index("idx_user_api_key_user_id", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_table("user_api_key") diff --git a/server/reflector/app.py b/server/reflector/app.py index 8c8724a6..a15934f5 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -26,6 +26,7 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router from reflector.views.transcripts_websocket import router as transcripts_websocket_router from reflector.views.user import router as user_router +from reflector.views.user_api_keys import router as user_api_keys_router from reflector.views.user_websocket import router as user_ws_router from reflector.views.whereby import router as whereby_router from reflector.views.zulip import router as zulip_router @@ -91,6 +92,7 @@ app.include_router(transcripts_websocket_router, prefix="/v1") app.include_router(transcripts_webrtc_router, prefix="/v1") app.include_router(transcripts_process_router, prefix="/v1") app.include_router(user_router, prefix="/v1") +app.include_router(user_api_keys_router, prefix="/v1") app.include_router(user_ws_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1") diff --git a/server/reflector/auth/auth_jwt.py b/server/reflector/auth/auth_jwt.py index 309ab3f7..0dcff9a0 100644 --- a/server/reflector/auth/auth_jwt.py +++ b/server/reflector/auth/auth_jwt.py @@ -1,14 +1,16 @@ -from typing import Annotated, Optional +from typing import Annotated, List, Optional from fastapi import Depends, HTTPException -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from jose import JWTError, jwt from pydantic import BaseModel +from reflector.db.user_api_keys import user_api_keys_controller from reflector.logger import logger from reflector.settings import settings oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read() jwt_algorithm = settings.AUTH_JWT_ALGORITHM @@ -26,7 +28,7 @@ class JWTException(Exception): class UserInfo(BaseModel): sub: str - email: str + email: Optional[str] = None def __getitem__(self, key): return getattr(self, key) @@ -58,34 +60,53 @@ def authenticated(token: Annotated[str, Depends(oauth2_scheme)]): return None -def current_user( - token: Annotated[Optional[str], Depends(oauth2_scheme)], - jwtauth: JWTAuth = Depends(), -): - if token is None: - raise HTTPException(status_code=401, detail="Not authenticated") - try: - payload = jwtauth.verify_token(token) - sub = payload["sub"] - email = payload["email"] - return UserInfo(sub=sub, email=email) - except JWTError as e: - logger.error(f"JWT error: {e}") - raise HTTPException(status_code=401, detail="Invalid authentication") +async def _authenticate_user( + jwt_token: Optional[str], + api_key: Optional[str], + jwtauth: JWTAuth, +) -> UserInfo | None: + user_infos: List[UserInfo] = [] + if api_key: + user_api_key = await user_api_keys_controller.verify_key(api_key) + if user_api_key: + user_infos.append(UserInfo(sub=user_api_key.user_id, email=None)) + if jwt_token: + try: + payload = jwtauth.verify_token(jwt_token) + sub = payload["sub"] + email = payload["email"] + user_infos.append(UserInfo(sub=sub, email=email)) + except JWTError as e: + logger.error(f"JWT error: {e}") + raise HTTPException(status_code=401, detail="Invalid authentication") -def current_user_optional( - token: Annotated[Optional[str], Depends(oauth2_scheme)], - jwtauth: JWTAuth = Depends(), -): - # we accept no token, but if one is provided, it must be a valid one. - if token is None: + if len(user_infos) == 0: return None - try: - payload = jwtauth.verify_token(token) - sub = payload["sub"] - email = payload["email"] - return UserInfo(sub=sub, email=email) - except JWTError as e: - logger.error(f"JWT error: {e}") - raise HTTPException(status_code=401, detail="Invalid authentication") + + if len(set([x.sub for x in user_infos])) > 1: + raise JWTException( + status_code=401, + detail="Invalid authentication: more than one user provided", + ) + + return user_infos[0] + + +async def current_user( + jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)], + api_key: Annotated[Optional[str], Depends(api_key_header)], + jwtauth: JWTAuth = Depends(), +): + user = await _authenticate_user(jwt_token, api_key, jwtauth) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +async def current_user_optional( + jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)], + api_key: Annotated[Optional[str], Depends(api_key_header)], + jwtauth: JWTAuth = Depends(), +): + return await _authenticate_user(jwt_token, api_key, jwtauth) diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py index f79a2573..8822e6b0 100644 --- a/server/reflector/db/__init__.py +++ b/server/reflector/db/__init__.py @@ -29,6 +29,7 @@ import reflector.db.meetings # noqa import reflector.db.recordings # noqa import reflector.db.rooms # noqa import reflector.db.transcripts # noqa +import reflector.db.user_api_keys # noqa kwargs = {} if "postgres" not in settings.DATABASE_URL: diff --git a/server/reflector/db/user_api_keys.py b/server/reflector/db/user_api_keys.py new file mode 100644 index 00000000..b4fe7538 --- /dev/null +++ b/server/reflector/db/user_api_keys.py @@ -0,0 +1,90 @@ +import hmac +import secrets +from datetime import datetime, timezone +from hashlib import sha256 + +import sqlalchemy +from pydantic import BaseModel, Field + +from reflector.db import get_database, metadata +from reflector.settings import settings +from reflector.utils import generate_uuid4 +from reflector.utils.string import NonEmptyString + +user_api_keys = sqlalchemy.Table( + "user_api_key", + metadata, + sqlalchemy.Column("id", sqlalchemy.String, primary_key=True), + sqlalchemy.Column("user_id", sqlalchemy.String, nullable=False), + sqlalchemy.Column("key_hash", sqlalchemy.String, nullable=False), + sqlalchemy.Column("name", sqlalchemy.String, nullable=True), + sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False), + sqlalchemy.Index("idx_user_api_key_hash", "key_hash", unique=True), + sqlalchemy.Index("idx_user_api_key_user_id", "user_id"), +) + + +class UserApiKey(BaseModel): + id: NonEmptyString = Field(default_factory=generate_uuid4) + user_id: NonEmptyString + key_hash: NonEmptyString + name: NonEmptyString | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class UserApiKeyController: + @staticmethod + def generate_key() -> NonEmptyString: + return secrets.token_urlsafe(48) + + @staticmethod + def hash_key(key: NonEmptyString) -> str: + return hmac.new( + settings.SECRET_KEY.encode(), key.encode(), digestmod=sha256 + ).hexdigest() + + @classmethod + async def create_key( + cls, + user_id: NonEmptyString, + name: NonEmptyString | None = None, + ) -> tuple[UserApiKey, NonEmptyString]: + plaintext = cls.generate_key() + api_key = UserApiKey( + user_id=user_id, + key_hash=cls.hash_key(plaintext), + name=name, + ) + query = user_api_keys.insert().values(**api_key.model_dump()) + await get_database().execute(query) + return api_key, plaintext + + @classmethod + async def verify_key(cls, plaintext_key: NonEmptyString) -> UserApiKey | None: + key_hash = cls.hash_key(plaintext_key) + query = user_api_keys.select().where( + user_api_keys.c.key_hash == key_hash, + ) + result = await get_database().fetch_one(query) + return UserApiKey(**result) if result else None + + @staticmethod + async def list_by_user_id(user_id: NonEmptyString) -> list[UserApiKey]: + query = ( + user_api_keys.select() + .where(user_api_keys.c.user_id == user_id) + .order_by(user_api_keys.c.created_at.desc()) + ) + results = await get_database().fetch_all(query) + return [UserApiKey(**r) for r in results] + + @staticmethod + async def delete_key(key_id: NonEmptyString, user_id: NonEmptyString) -> bool: + query = user_api_keys.delete().where( + (user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id) + ) + result = await get_database().execute(query) + return result > 0 + + +user_api_keys_controller = UserApiKeyController() diff --git a/server/reflector/views/user_api_keys.py b/server/reflector/views/user_api_keys.py new file mode 100644 index 00000000..f83768af --- /dev/null +++ b/server/reflector/views/user_api_keys.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Annotated + +import structlog +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +import reflector.auth as auth +from reflector.db.user_api_keys import user_api_keys_controller +from reflector.utils.string import NonEmptyString + +router = APIRouter() +logger = structlog.get_logger(__name__) + + +class CreateApiKeyRequest(BaseModel): + name: NonEmptyString | None = None + + +class ApiKeyResponse(BaseModel): + id: NonEmptyString + user_id: NonEmptyString + name: NonEmptyString | None + created_at: datetime + + +class CreateApiKeyResponse(ApiKeyResponse): + key: NonEmptyString + + +@router.post("/user/api-keys", response_model=CreateApiKeyResponse) +async def create_api_key( + req: CreateApiKeyRequest, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + api_key_model, plaintext = await user_api_keys_controller.create_key( + user_id=user["sub"], + name=req.name, + ) + return CreateApiKeyResponse( + **api_key_model.model_dump(), + key=plaintext, + ) + + +@router.get("/user/api-keys", response_model=list[ApiKeyResponse]) +async def list_api_keys( + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + api_keys = await user_api_keys_controller.list_by_user_id(user["sub"]) + return [ApiKeyResponse(**k.model_dump()) for k in api_keys] + + +@router.delete("/user/api-keys/{key_id}") +async def delete_api_key( + key_id: NonEmptyString, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + deleted = await user_api_keys_controller.delete_key(key_id, user["sub"]) + if not deleted: + raise HTTPException(status_code=404) + return {"status": "ok"} diff --git a/server/tests/test_user_api_keys.py b/server/tests/test_user_api_keys.py new file mode 100644 index 00000000..b92466b7 --- /dev/null +++ b/server/tests/test_user_api_keys.py @@ -0,0 +1,70 @@ +import pytest + +from reflector.db.user_api_keys import user_api_keys_controller + + +@pytest.mark.asyncio +async def test_api_key_creation_and_verification(): + api_key_model, plaintext = await user_api_keys_controller.create_key( + user_id="test_user", + name="Test API Key", + ) + + verified = await user_api_keys_controller.verify_key(plaintext) + assert verified is not None + assert verified.user_id == "test_user" + assert verified.name == "Test API Key" + + invalid = await user_api_keys_controller.verify_key("fake_key") + assert invalid is None + + +@pytest.mark.asyncio +async def test_api_key_hashing(): + _, plaintext = await user_api_keys_controller.create_key( + user_id="test_user_2", + ) + + api_keys = await user_api_keys_controller.list_by_user_id("test_user_2") + assert len(api_keys) == 1 + assert api_keys[0].key_hash != plaintext + + +@pytest.mark.asyncio +async def test_generate_api_key_uniqueness(): + key1 = user_api_keys_controller.generate_key() + key2 = user_api_keys_controller.generate_key() + assert key1 != key2 + + +@pytest.mark.asyncio +async def test_hash_api_key_deterministic(): + key = "test_key_123" + hash1 = user_api_keys_controller.hash_key(key) + hash2 = user_api_keys_controller.hash_key(key) + assert hash1 == hash2 + + +@pytest.mark.asyncio +async def test_get_by_user_id_empty(): + api_keys = await user_api_keys_controller.list_by_user_id("nonexistent_user") + assert api_keys == [] + + +@pytest.mark.asyncio +async def test_get_by_user_id_multiple(): + user_id = "multi_key_user" + + _, plaintext1 = await user_api_keys_controller.create_key( + user_id=user_id, + name="API Key 1", + ) + _, plaintext2 = await user_api_keys_controller.create_key( + user_id=user_id, + name="API Key 2", + ) + + api_keys = await user_api_keys_controller.list_by_user_id(user_id) + assert len(api_keys) == 2 + names = {k.name for k in api_keys} + assert names == {"API Key 1", "API Key 2"} diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index e1709d69..2e6a775b 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -4,6 +4,23 @@ */ export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/metrics": { parameters: { query?: never; @@ -587,6 +604,41 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/user/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Tokens */ + get: operations["v1_list_tokens"]; + put?: never; + /** Create Token */ + post: operations["v1_create_token"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/user/tokens/{token_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Token */ + delete: operations["v1_delete_token"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/zulip/streams": { parameters: { query?: never; @@ -759,6 +811,27 @@ export interface components { */ allow_duplicated: boolean | null; }; + /** CreateTokenRequest */ + CreateTokenRequest: { + /** Name */ + name?: string | null; + }; + /** CreateTokenResponse */ + CreateTokenResponse: { + /** Id */ + id: string; + /** User Id */ + user_id: string; + /** Name */ + name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Token */ + token: string; + }; /** CreateTranscript */ CreateTranscript: { /** Name */ @@ -1352,6 +1425,20 @@ export interface components { * @enum {string} */ SyncStatus: "success" | "unchanged" | "error" | "skipped"; + /** TokenResponse */ + TokenResponse: { + /** Id */ + id: string; + /** User Id */ + user_id: string; + /** Name */ + name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; /** Topic */ Topic: { /** Name */ @@ -1509,6 +1596,26 @@ export interface components { } export type $defs = Record; export interface operations { + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; metrics: { parameters: { query?: never; @@ -2899,6 +3006,90 @@ export interface operations { }; }; }; + v1_list_tokens: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenResponse"][]; + }; + }; + }; + }; + v1_create_token: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTokenRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateTokenResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_delete_token: { + parameters: { + query?: never; + header?: never; + path: { + token_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_zulip_get_streams: { parameters: { query?: never;