diff --git a/apps/api/app/core/dependencies.py b/apps/api/app/core/dependencies.py index de7b3a636..bf56d39c8 100644 --- a/apps/api/app/core/dependencies.py +++ b/apps/api/app/core/dependencies.py @@ -7,6 +7,7 @@ from fastapi import Depends, Header, Request from jwt import PyJWKClient from loguru import logger +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from shared.core.config import settings @@ -14,6 +15,7 @@ from shared.core.exceptions.domain_exceptions import ( AuthException, ) +from shared.models.database.user import User from shared.utils.api_keys import is_api_key_token # Standard JWKS endpoint path (fixed, following OpenID Connect convention) @@ -106,6 +108,23 @@ def decode_jwt_token(token: str) -> str: raise AuthException(user_message="Invalid token") +async def _ensure_authenticated_user_exists( + db: AsyncSession, + user_id: str, +) -> None: + result = await db.execute(select(User.id).where(User.id == user_id).limit(1)) + if result.scalar_one_or_none() is not None: + return + + raise AuthException( + user_message="Invalid authentication credentials", + internal_message=( + "Authenticated user id is not present in the user table: " + f"user_id={user_id}" + ), + ) + + async def get_current_user_id( request: Request, authorization: str | None = Header( @@ -134,4 +153,6 @@ async def get_current_user_id( raise AuthException(user_message="Invalid API Key") # Mode 2: JWT verification (for Dashboard/Internal) - return decode_jwt_token(token) + user_id = decode_jwt_token(token) + await _ensure_authenticated_user_exists(db, user_id) + return user_id diff --git a/apps/api/tests/contract/test_job_creation_contract.py b/apps/api/tests/contract/test_job_creation_contract.py index 7b4025b44..b6cfa1375 100644 --- a/apps/api/tests/contract/test_job_creation_contract.py +++ b/apps/api/tests/contract/test_job_creation_contract.py @@ -1,11 +1,12 @@ from collections.abc import Callable from contextlib import AbstractAsyncContextManager -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import json import socket from typing import cast from uuid import uuid4 +import jwt import pytest from httpx import AsyncClient from pytest import MonkeyPatch @@ -405,6 +406,53 @@ async def test_should_reject_a_malformed_authorization_header_when_creating_a_jo assert await _count_jobs() == 0 +@pytest.mark.asyncio +async def test_should_reject_authenticated_user_id_missing_from_user_table( + api_client_factory: Callable[[], AbstractAsyncContextManager[AsyncClient]], + monkeypatch: MonkeyPatch, +) -> None: + user_id = f"contract-missing-user-{uuid4().hex[:12]}" + jwt_secret = f"contract-jwt-secret-{uuid4().hex[:12]}" + token = jwt.encode( + { + "id": user_id, + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + }, + jwt_secret, + algorithm="HS256", + ) + payload: dict[str, str] = { + "namespace": "contract-jobs", + "source_type": "file", + "file_name": "contract-upload.pdf", + "data_id": "contract-job-missing-user", + } + + async with api_client_factory() as api_client: + from app.core import dependencies as auth_dependencies + + monkeypatch.setattr( + auth_dependencies, + "_get_verification_key", + lambda _token: jwt_secret, + ) + + api_client.headers.update({"Authorization": f"Bearer {token}"}) + response = await api_client.post("/api/v1/jobs", json=payload) + + assert response.status_code == 401 + assert response.headers["x-request-id"] + + response_json: dict[str, object] = response.json() + error = cast(dict[str, object], response_json["error"]) + + assert response_json["success"] is False + assert error["code"] == "UNAUTHENTICATED" + assert error["message"] == "Invalid authentication credentials" + assert "details" not in error + assert await _count_jobs() == 0 + + @pytest.mark.asyncio async def test_should_return_conflict_when_creating_a_job_for_a_document_with_an_active_ingestion_job( developer_api_client_factory: Callable[ diff --git a/packages/shared-python/shared/services/billing/credits_service.py b/packages/shared-python/shared/services/billing/credits_service.py index e4c8e03c8..d0dc48fc4 100644 --- a/packages/shared-python/shared/services/billing/credits_service.py +++ b/packages/shared-python/shared/services/billing/credits_service.py @@ -16,7 +16,9 @@ from shared.core.billing import MicroDollar from shared.core.config import settings -from shared.core.exceptions.domain_exceptions import InsufficientCreditsException +from shared.core.exceptions.domain_exceptions import ( + InsufficientCreditsException, +) from shared.core.logging import logger from shared.models.database.credits_transaction import CreditsTransaction from shared.models.database.payment_record import PaymentRecord diff --git a/packages/shared-python/shared/services/billing/credits_sync_service.py b/packages/shared-python/shared/services/billing/credits_sync_service.py index ba6ddbe38..e6eb63446 100644 --- a/packages/shared-python/shared/services/billing/credits_sync_service.py +++ b/packages/shared-python/shared/services/billing/credits_sync_service.py @@ -11,7 +11,9 @@ from shared.core.billing import MicroDollar from shared.core.config import settings -from shared.core.exceptions.domain_exceptions import InsufficientCreditsException +from shared.core.exceptions.domain_exceptions import ( + InsufficientCreditsException, +) from shared.core.logging import logger from shared.models.database.credits_transaction import CreditsTransaction from shared.models.database.payment_record import PaymentRecord