Skip to content
Open
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
14 changes: 11 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

import os
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

import redis.asyncio as aioredis
Expand Down Expand Up @@ -41,6 +41,7 @@
StaticCacheHeadersMiddleware,
configure_cors,
)
from middleware.timeout import RequestTimeoutMiddleware
from repositories.indexes import ensure_indexes
from routes.api_v1 import router as api_v1_router
from routes.auth import router as auth_router
Expand Down Expand Up @@ -75,12 +76,15 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
)

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# ── Startup ──────────────────────────────────────────────────────────
mongo_client: AsyncMongoClient = AsyncMongoClient(
settings.db.mongodb_uri,
maxPoolSize=settings.db.max_pool_size,
minPoolSize=settings.db.min_pool_size,
serverSelectionTimeoutMS=settings.db.server_selection_timeout_ms,
connectTimeoutMS=settings.db.connect_timeout_ms,
socketTimeoutMS=settings.db.socket_timeout_ms,
)
app.state.mongo_client = mongo_client
app.state.db = mongo_client[settings.db.db_name]
Expand Down Expand Up @@ -235,8 +239,12 @@ async def docs(request: Request):
app.add_middleware(
MaxContentLengthMiddleware, max_content_length=settings.max_content_length
)
# 5. Request logging — innermost, logs all requests with request_id
# 5. Request logging — logs all requests with request_id
app.add_middleware(RequestLoggingMiddleware)
# 6. Request deadline — innermost
app.add_middleware(
RequestTimeoutMiddleware, timeout_seconds=settings.request_timeout_seconds
)

# ── Error handlers + rate limiter ────────────────────────────────────
app.state.limiter = limiter
Expand Down
9 changes: 8 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class DatabaseSettings(BaseSettings):
db_name: str = "url-shortener"
max_pool_size: int = 200
min_pool_size: int = 10
# socket_timeout sized for /metric aggregation; hot-path cap lives in RequestTimeoutMiddleware.
server_selection_timeout_ms: int = 3000
connect_timeout_ms: int = 3000
socket_timeout_ms: int = 120000
Comment on lines +26 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate the new Mongo timeout settings at startup.

These env-backed fields currently accept 0 and negative values, so a bad deployment config can disable or break the new DB-side timeout behavior at runtime. Please add the same positive-value validation you already apply to the float timeouts.

🛡️ Suggested validation
 class DatabaseSettings(BaseSettings):
     model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@
     server_selection_timeout_ms: int = 3000
     connect_timeout_ms: int = 3000
     socket_timeout_ms: int = 120000
+
+    `@field_validator`(
+        "server_selection_timeout_ms", "connect_timeout_ms", "socket_timeout_ms"
+    )
+    `@classmethod`
+    def _must_be_positive_timeout_ms(cls, v: int, info) -> int:
+        if v <= 0:
+            raise ValueError(f"{info.field_name} must be > 0, got {v}")
+        return v
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# socket_timeout sized for /metric aggregation; hot-path cap lives in RequestTimeoutMiddleware.
server_selection_timeout_ms: int = 3000
connect_timeout_ms: int = 3000
socket_timeout_ms: int = 120000
# socket_timeout sized for /metric aggregation; hot-path cap lives in RequestTimeoutMiddleware.
server_selection_timeout_ms: int = 3000
connect_timeout_ms: int = 3000
socket_timeout_ms: int = 120000
`@field_validator`(
"server_selection_timeout_ms", "connect_timeout_ms", "socket_timeout_ms"
)
`@classmethod`
def _must_be_positive_timeout_ms(cls, v: int, info) -> int:
if v <= 0:
raise ValueError(f"{info.field_name} must be > 0, got {v}")
return v
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config.py` around lines 26 - 29, Add startup validation to ensure the new
Mongo timeout config ints (server_selection_timeout_ms, connect_timeout_ms,
socket_timeout_ms) are positive values just like the existing float timeouts:
check each value > 0 during config initialization and raise a clear ValueError
(or log+exit) if any are zero or negative. Locate the validation block used for
the float timeout fields and replicate the same positive-value check for these
three integer fields in the same config initialization path (the config class or
function that reads env vars) so bad deployments fail fast.



class RedisSettings(BaseSettings):
Expand Down Expand Up @@ -148,6 +152,7 @@ class AppSettings(BaseSettings):
max_active_api_keys: int = 20
max_date_range_days: int = 90
http_client_timeout: float = 5.0
request_timeout_seconds: float = 8.0

# Validator constraints (overridable by self-hosters via env vars)
blocked_url_regex_timeout: float = 0.2
Expand All @@ -167,7 +172,9 @@ def _must_be_positive_int(cls, v: int, info) -> int:
raise ValueError(f"{info.field_name} must be >= 1, got {v}")
return v

@field_validator("http_client_timeout", "blocked_url_regex_timeout")
@field_validator(
"http_client_timeout", "blocked_url_regex_timeout", "request_timeout_seconds"
)
@classmethod
def _must_be_positive_float(cls, v: float, info) -> float:
if v <= 0:
Expand Down
12 changes: 10 additions & 2 deletions config/apps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,18 @@ apps:
spoo-raycast:
name: Raycast Extension
icon: raycast.svg
description: Shorten links from Raycast
description: Shorten and manage your spoo.me links from Raycast
verified: true
status: coming_soon
status: live
type: device_auth
redirect_uris:
- https://raycast.com/redirect?*
links:
raycast: https://www.raycast.com/spoo-me/spoo
permissions:
- Access your spoo.me account
- Create and manage short URLs
- View and export your analytics

spoo-vscode:
name: VS Code Extension
Expand Down
43 changes: 43 additions & 0 deletions middleware/timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Request deadline middleware — caps request time below the platform timeout."""

from __future__ import annotations

import asyncio

from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import JSONResponse, Response

from infrastructure.logging import get_logger
from shared.ip_utils import get_client_ip

log = get_logger("spoo.timeout")

EXEMPT_PATHS: frozenset[str] = frozenset({"/metric"})


class RequestTimeoutMiddleware(BaseHTTPMiddleware):
def __init__(self, app, timeout_seconds: float = 8.0) -> None:
super().__init__(app)
self._timeout = timeout_seconds

async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if request.url.path in EXEMPT_PATHS:
return await call_next(request)

try:
return await asyncio.wait_for(call_next(request), timeout=self._timeout)
except asyncio.TimeoutError:
log.error(
"request_deadline_exceeded",
method=request.method,
path=request.url.path,
timeout_seconds=self._timeout,
client_ip=get_client_ip(request),
)
Comment on lines +33 to +39
return JSONResponse(
status_code=504,
content={"error": "Request timed out", "code": "request_timeout"},
)
Comment on lines +19 to +43
16 changes: 12 additions & 4 deletions routes/auth/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,15 @@ def _device_error(request: Request, error: str, status_code: int = 400) -> Respo


def _build_callback_redirect(
code: str, state: str, redirect_uri: str, app: AppEntry
code: str,
state: str,
redirect_uri: str,
app: AppEntry,
svc: DeviceAuthSvc,
) -> RedirectResponse:
"""Build the redirect to the callback page or a registered redirect_uri."""
params = urlencode({"code": code, "state": state})
if redirect_uri and redirect_uri in app.redirect_uris:
if redirect_uri and svc.validate_redirect_uri(redirect_uri, app):
separator = "&" if "?" in redirect_uri else "?"
return RedirectResponse(f"{redirect_uri}{separator}{params}", status_code=302)
return RedirectResponse(f"/auth/device/callback?{params}", status_code=302)
Expand Down Expand Up @@ -122,7 +126,9 @@ async def device_login(
code = await device_auth_service.create_device_auth_code(
profile.id, profile.email, app_id=app_id
)
return _build_callback_redirect(code, state, redirect_uri, app)
return _build_callback_redirect(
code, state, redirect_uri, app, device_auth_service
)

# No grant: show consent screen
csrf_token = generate_secure_token(_CSRF_TOKEN_BYTES)
Expand Down Expand Up @@ -193,7 +199,9 @@ async def device_consent_approve(
)

# Clear CSRF cookie and redirect
response = _build_callback_redirect(code, state, redirect_uri, app)
response = _build_callback_redirect(
code, state, redirect_uri, app, device_auth_service
)
response.delete_cookie(_CSRF_COOKIE_NAME)
return response

Expand Down
57 changes: 5 additions & 52 deletions routes/health_routes.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
"""
Health check endpoint.

GET /health — checks MongoDB and Redis connectivity.
Rules:
- MongoDB failure → "unhealthy" (503) — the app cannot function without it.
- Redis failure or absence → "degraded" (200) — Redis is optional.
"""
"""GET /health — liveness probe."""

from __future__ import annotations

from fastapi import APIRouter, Request
from fastapi import APIRouter
from fastapi.responses import JSONResponse

from middleware.openapi import PUBLIC_SECURITY
Expand All @@ -23,46 +16,6 @@
operation_id="healthCheck",
summary="Health Check",
)
async def health_check(request: Request) -> JSONResponse:
"""Check the health of the application and its dependencies.

Pings MongoDB and Redis to determine overall system status:

- **healthy** (200): Both MongoDB and Redis are reachable.
- **degraded** (200): MongoDB is reachable but Redis is down or not configured.
- **unhealthy** (503): MongoDB is unreachable -- the app cannot function.

**Authentication**: Not required (public endpoint)

**Rate Limits**: None
"""
checks: dict[str, str] = {}
overall = "healthy"

try:
db = request.app.state.db
await db.client.admin.command("ping")
checks["mongodb"] = "ok"
except Exception:
checks["mongodb"] = "error"
overall = "unhealthy"

redis = request.app.state.redis
if redis is None:
checks["redis"] = "not_configured"
if overall == "healthy":
overall = "degraded"
else:
try:
await redis.ping()
checks["redis"] = "ok"
except Exception:
checks["redis"] = "error"
if overall == "healthy":
overall = "degraded"

status_code = 503 if overall == "unhealthy" else 200
return JSONResponse(
status_code=status_code,
content={"status": overall, "checks": checks},
)
async def health_check() -> JSONResponse:
"""Liveness probe. Public, unauthenticated, no dependency checks."""
return JSONResponse(status_code=200, content={"status": "ok"})
Comment on lines +20 to +21
2 changes: 1 addition & 1 deletion routes/legacy/url_shortener.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ async def metric(
async def query() -> dict:
start = time.time()

cursor = await db["urls"].aggregate(METRIC_PIPELINE_V1)
cursor = await db["urls"].aggregate(METRIC_PIPELINE_V1, maxTimeMS=90000)
results = await cursor.to_list(length=1)
v1_result = results[0] if results else {}
v1_shortlinks = v1_result.get("total-shortlinks", 0)
Expand Down
10 changes: 1 addition & 9 deletions schemas/dto/responses/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,10 @@ class ErrorResponse(ResponseBase):
details: Any | None = None


class HealthChecks(ResponseBase):
"""Individual service check statuses inside HealthResponse."""

mongodb: str
redis: str


class HealthResponse(ResponseBase):
"""Response body for GET /health."""
"""Response body for GET /health (liveness probe)."""

status: str
checks: dict[str, str]


class MessageResponse(ResponseBase):
Expand Down
18 changes: 16 additions & 2 deletions services/auth/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,22 @@ def resolve_app(self, app_id: str) -> AppEntry | None:
return entry if entry and entry.is_live_device_app() else None

def validate_redirect_uri(self, redirect_uri: str, app: AppEntry) -> bool:
"""Return True if redirect_uri is empty or in the app's allowlist."""
return not redirect_uri or redirect_uri in app.redirect_uris
"""Return True if redirect_uri is empty, exact-matches an allowlist
entry, or prefix-matches an entry ending with ``*``.

The ``*`` suffix is used for OAuth clients (e.g. Raycast) that append
a varying query string to a fixed redirect URL. Only exact or
explicit-wildcard entries are accepted; implicit wildcards are not.
"""
if not redirect_uri:
return True
for allowed in app.redirect_uris:
if allowed.endswith("*"):
if redirect_uri.startswith(allowed[:-1]):
return True
Comment on lines +79 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject bare * redirect allowlist entries.

This branch also accepts allowed == "*", and redirect_uri.startswith("") then approves every callback URI. In this flow that turns one bad app entry into an open redirect for the device auth code. Require a non-empty prefix before treating * as a wildcard.

🔒 Suggested fix
         for allowed in app.redirect_uris:
             if allowed.endswith("*"):
-                if redirect_uri.startswith(allowed[:-1]):
+                prefix = allowed[:-1]
+                if prefix and redirect_uri.startswith(prefix):
                     return True
             elif redirect_uri == allowed:
                 return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for allowed in app.redirect_uris:
if allowed.endswith("*"):
if redirect_uri.startswith(allowed[:-1]):
return True
for allowed in app.redirect_uris:
if allowed.endswith("*"):
prefix = allowed[:-1]
if prefix and redirect_uri.startswith(prefix):
return True
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/auth/device.py` around lines 79 - 82, The wildcard branch currently
treats allowed == "*" as a valid prefix match, allowing any redirect_uri; update
the logic that iterates over app.redirect_uris so that wildcard entries require
a non-empty prefix before treating the trailing "*" as a wildcard (i.e., only
accept when allowed.endswith("*") and the prefix part allowed[:-1] is
non-empty), then perform redirect_uri.startswith(prefix) using that prefix; use
the existing loop variables (app.redirect_uris, allowed, redirect_uri) to locate
and change the check.

elif redirect_uri == allowed:
return True
return False

async def create_device_auth_code(
self, user_id: ObjectId, email: str, app_id: str | None = None
Expand Down
8 changes: 7 additions & 1 deletion tests/integration/test_device_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,13 @@ def device_auth_svc():
# resolve_app and validate_redirect_uri are sync — must not be coroutines
svc.resolve_app = MagicMock(side_effect=_resolve_app)
svc.validate_redirect_uri = MagicMock(
side_effect=lambda uri, app: not uri or uri in app.redirect_uris
side_effect=lambda uri, app: (
not uri
or any(
uri.startswith(a[:-1]) if a.endswith("*") else uri == a
for a in app.redirect_uris
)
Comment on lines 109 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider at least one integration test that uses the real validate_redirect_uri for end-to-end coverage

The mock now copies the wildcard logic from the implementation, so existing tests will keep passing even if the real validate_redirect_uri later changes. You already have good unit coverage, but there’s no integration‑level check that _build_callback_redirect works with the real DeviceAuthService.validate_redirect_uri.

Please add a single integration test that:

  • Uses the real svc.validate_redirect_uri (no override),
  • Sets up an app with a wildcard redirect URI, and
  • Asserts that the redirect response uses that wildcard redirect with the appended code/state.

This will give you one end‑to‑end check tying together the route, service, and redirect behaviour.

Suggested implementation:

    # resolve_app and validate_redirect_uri are sync — must not be coroutines
    svc.resolve_app = MagicMock(side_effect=_resolve_app)
    svc.validate_redirect_uri = MagicMock(
        side_effect=lambda uri, app: (
            not uri
            or any(
                uri.startswith(a[:-1]) if a.endswith("*") else uri == a
                for a in app.redirect_uris
            )
        )
    )
    return svc


@pytest.mark.integration
def test_device_callback_uses_wildcard_redirect_with_real_validation(
    client,
    app_factory,
):
    """
    Integration-level check that _build_callback_redirect cooperates with the real
    DeviceAuthService.validate_redirect_uri for wildcard redirect URIs.
    """

    # Arrange: create an app that allows a wildcard redirect URI
    app = app_factory(redirect_uris=["https://example.com/callback/*"])

    # This is the specific redirect URI we want to use; it should be accepted
    redirect_uri = "https://example.com/callback/flow-123"

    # Act: call the device callback endpoint, which should internally:
    # - resolve the app
    # - validate the redirect_uri using DeviceAuthService.validate_redirect_uri
    # - build a redirect with appended code/state
    response = client.get(
        "/device/callback",
        query_string={
            "client_id": app.client_id,
            "redirect_uri": redirect_uri,
        },
    )

    # Assert: we got redirected to the provided redirect_uri with code/state
    assert response.status_code == 302
    location = response.headers["Location"]

    assert location.startswith(redirect_uri)
    assert "code=" in location
    assert "state=" in location

To make this compile and pass, you’ll likely need to:

  1. Ensure pytest is imported (import pytest) at the top of tests/integration/test_device_auth.py if it is not already.
  2. Adjust the fixture names to match your existing test helpers:
    • If your factory for creating client applications is not called app_factory, rename the parameter and usage accordingly (e.g. application_factory, make_app, etc.).
    • If your device callback route is not /device/callback, update the client.get(...) path to the actual callback endpoint that triggers _build_callback_redirect.
    • If your application model does not expose client_id as app.client_id, replace that access with the appropriate attribute.
  3. Make sure the app created by app_factory is persisted/visible to the running application under test (e.g. committing to the DB or registering in whatever storage resolve_app uses), consistent with how other integration tests create apps.
  4. If you want this test to bypass the MagicMock override of validate_redirect_uri, introduce a separate fixture that does not patch svc.validate_redirect_uri and use that fixture’s client in this test, or scope the existing mocking fixture so it doesn’t apply here (e.g. via a different marker or fixture layering).

)
Comment on lines +110 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Redirect URI mock incorrectly allows empty URI when allowlist is empty

Line 111 short-circuits not uri to True, so apps with redirect_uris=[] are treated as valid for empty redirect URIs. That conflicts with the new unit contract (tests/unit/services/test_redirect_uri.py, Line 62-Line 65) and can hide redirect-validation regressions in integration tests.

Suggested fix
     svc.validate_redirect_uri = MagicMock(
         side_effect=lambda uri, app: (
-            not uri
-            or any(
-                uri.startswith(a[:-1]) if a.endswith("*") else uri == a
-                for a in app.redirect_uris
-            )
+            bool(app.redirect_uris)
+            and (
+                not uri
+                or any(
+                    uri.startswith(a[:-1]) if a.endswith("*") else uri == a
+                    for a in app.redirect_uris
+                )
+            )
         )
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
side_effect=lambda uri, app: (
not uri
or any(
uri.startswith(a[:-1]) if a.endswith("*") else uri == a
for a in app.redirect_uris
)
)
side_effect=lambda uri, app: (
bool(app.redirect_uris)
and (
not uri
or any(
uri.startswith(a[:-1]) if a.endswith("*") else uri == a
for a in app.redirect_uris
)
)
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_device_auth.py` around lines 110 - 116, The mock
side_effect currently short-circuits with "not uri" which allows empty URIs when
app.redirect_uris is empty; update the side_effect lambda so it first requires a
truthy uri and then checks any(match) against app.redirect_uris (i.e., replace
"not uri or any(...)" with "bool(uri) and any(...)" or equivalent) so an empty
allowlist (app.redirect_uris==[]) will not validate an empty URI; locate the
side_effect lambda and ensure it uses app.redirect_uris and wildcard handling
exactly as in the existing generator logic.

)
return svc

Expand Down
Loading
Loading