Skip to content
Merged
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
23 changes: 22 additions & 1 deletion apps/api/app/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
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
from shared.core.database import get_db
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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
50 changes: 49 additions & 1 deletion apps/api/tests/contract/test_job_creation_contract.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading