From 5ec03aaf9434efea7b3179b1a32162f16714e954 Mon Sep 17 00:00:00 2001 From: ShyamSagothia Date: Mon, 16 Mar 2026 18:39:55 +0530 Subject: [PATCH 1/5] feat: Add Volopay backend client with token validation utility. --- vp_core/clients/__init__.py | 7 ++ vp_core/clients/volopay_be.py | 118 ++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 vp_core/clients/__init__.py create mode 100644 vp_core/clients/volopay_be.py diff --git a/vp_core/clients/__init__.py b/vp_core/clients/__init__.py new file mode 100644 index 0000000..0ec78e9 --- /dev/null +++ b/vp_core/clients/__init__.py @@ -0,0 +1,7 @@ +""" +HTTP client utilities for interacting with external/internal services. +""" + +from .volopay_be import validate_token + +__all__ = ["validate_token"] diff --git a/vp_core/clients/volopay_be.py b/vp_core/clients/volopay_be.py new file mode 100644 index 0000000..97233a7 --- /dev/null +++ b/vp_core/clients/volopay_be.py @@ -0,0 +1,118 @@ +""" +Volopay Backend (volo-be) HTTP client utilities. + +This module provides shared utilities for microservices that need to interact +with the main volo-be backend, primarily for auth token validation. + +Usage: + from vp_core.clients.volopay_be import validate_token + + valid = await validate_token( + client="web", + access_token="abc123", + uid="user@example.com", + account="volopay", + volo_be_url="https://api.volopay.com" + ) +""" + +import httpx + + +async def validate_token( + client: str, + access_token: str, + uid: str, + account: str, + volo_be_url: str, + x_feature: str | None = None, + env: str | None = None, +) -> bool: + """ + Validates a user's session token by calling volo-be's token validation endpoint. + + This is used by satellite microservices (volo-agents, ocr-reader, etc.) to verify + that a frontend user's token is valid without maintaining their own user database. + + Args: + client: Client identifier (e.g., "web", "mobile") + access_token: User's session access token + uid: User identifier (typically email) + account: Account/organization identifier + volo_be_url: Base URL of volo-be (e.g., "https://api.volopay.com") + x_feature: Optional feature flag header + env: Environment name (defaults to None). If "test", always returns True. + + Returns: + True if the token is valid (volo-be returned 200), False otherwise. + + Example: + >>> valid = await validate_token( + ... client="web", + ... access_token="eyJ0eXAi...", + ... uid="user@example.com", + ... account="volopay", + ... volo_be_url="https://api.volopay.com" + ... ) + >>> if valid: + ... # proceed with authenticated request + """ + # Skip validation in test environments + if env == "test": + return True + + headers = { + "client": client, + "access-token": access_token, + "uid": uid, + "account": account, + } + + # Add optional x_feature header if provided + if x_feature: + headers["x_feature"] = x_feature + + async with httpx.AsyncClient(base_url=volo_be_url, headers=headers) as http: + try: + response = await http.get("/api/v3/auth/user/validate_token") + return response.status_code == 200 + except httpx.HTTPError: + # Network errors, timeouts, etc. — treat as invalid token + return False + + +# Future: Two-layer auth builder +# When multiple microservices (volo-agents, ocr-reader, future services) all adopt +# the same two-layer auth pattern (shared secret + token validation fallback), +# consider adding a generic FastAPI dependency builder here: +# +# def create_two_layer_auth_dependency( +# service_name: str, # "agents", "ocr", etc. +# secret_env_var: str, # "VOLO_BE_AGENTS_SECRET" +# volo_be_url_env_var: str = "VOLO_BE_URL", +# env_var: str = "ENV" +# ) -> Callable: +# """ +# Creates a FastAPI Depends() function that enforces two-layer auth: +# 1. Checks volo_be_{service_name}_secret header against env var +# 2. Falls back to validate_token() if Layer 1 fails +# +# Returns: +# A FastAPI dependency (Depends-compatible callable) +# +# Example: +# from vp_core.clients import create_two_layer_auth_dependency +# +# AuthDep = create_two_layer_auth_dependency( +# service_name="agents", +# secret_env_var="VOLO_BE_AGENTS_SECRET" +# ) +# +# app.include_router(router, dependencies=[AuthDep]) +# """ +# ... +# +# Current blockers for this abstraction: +# - Only 2 services use this pattern (volo-agents, ocr-reader) +# - Each service has slight variations (required vs optional headers) +# - Each service benefits from owning its auth logic for debugging From a7b4a58fafdef704734425f7627235177234c0b3 Mon Sep 17 00:00:00 2001 From: ShyamSagothia Date: Wed, 25 Mar 2026 03:52:33 +0530 Subject: [PATCH 2/5] added httpx library --- poetry.lock | 11 ++++------- pyproject.toml | 1 + 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3128099..53c15de 100644 --- a/poetry.lock +++ b/poetry.lock @@ -210,10 +210,9 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand name = "h11" version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = true +optional = false python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"test\"" files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -223,10 +222,9 @@ files = [ name = "httpcore" version = "1.0.9" description = "A minimal low-level HTTP client." -optional = true +optional = false python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"test\"" files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -246,10 +244,9 @@ trio = ["trio (>=0.22.0,<1.0)"] name = "httpx" version = "0.28.1" description = "The next generation HTTP client." -optional = true +optional = false python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"test\"" files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -728,4 +725,4 @@ test = ["httpx", "pytest", "pytest-benchmark", "pytest-cov"] [metadata] lock-version = "2.1" python-versions = ">=3.8" -content-hash = "9def112c6c60f8f387c108fb503d1c7b24b0ffec9d03b8dda9c53eeb5fb7c0ec" +content-hash = "72ac204589e45ad2f4d0b56ffff92042fe2569778351ba40f1aac44193a0399b" diff --git a/pyproject.toml b/pyproject.toml index 68ee418..04e0c69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pydantic", "fastapi", "sentry-sdk[fastapi]", + "httpx (>=0.28.1,<0.29.0)", ] [project.optional-dependencies] From b49acda76008c02dcf261df03f49bbd2959f147e Mon Sep 17 00:00:00 2001 From: ShyamSagothia Date: Wed, 25 Mar 2026 03:55:41 +0530 Subject: [PATCH 3/5] minor changes added --- vp_core/clients/volopay_be.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/vp_core/clients/volopay_be.py b/vp_core/clients/volopay_be.py index 97233a7..abc0fb9 100644 --- a/vp_core/clients/volopay_be.py +++ b/vp_core/clients/volopay_be.py @@ -110,9 +110,3 @@ async def validate_token( # # app.include_router(router, dependencies=[AuthDep]) # """ -# ... -# -# Current blockers for this abstraction: -# - Only 2 services use this pattern (volo-agents, ocr-reader) -# - Each service has slight variations (required vs optional headers) -# - Each service benefits from owning its auth logic for debugging From 87bc9f796cc0ba928790b947abdeb0c304599a1f Mon Sep 17 00:00:00 2001 From: ShyamSagothia Date: Mon, 6 Apr 2026 18:09:45 +0530 Subject: [PATCH 4/5] resolved pr comments --- vp_core/clients/volopay_be.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/vp_core/clients/volopay_be.py b/vp_core/clients/volopay_be.py index abc0fb9..4b071da 100644 --- a/vp_core/clients/volopay_be.py +++ b/vp_core/clients/volopay_be.py @@ -26,7 +26,6 @@ async def validate_token( account: str, volo_be_url: str, x_feature: str | None = None, - env: str | None = None, ) -> bool: """ Validates a user's session token by calling volo-be's token validation endpoint. @@ -34,6 +33,9 @@ async def validate_token( This is used by satellite microservices (volo-agents, ocr-reader, etc.) to verify that a frontend user's token is valid without maintaining their own user database. + Note: Environment-specific logic (e.g., skipping validation in test) should be + handled by the calling service, not here. This is a pure HTTP client function. + Args: client: Client identifier (e.g., "web", "mobile") access_token: User's session access token @@ -41,7 +43,6 @@ async def validate_token( account: Account/organization identifier volo_be_url: Base URL of volo-be (e.g., "https://api.volopay.com") x_feature: Optional feature flag header - env: Environment name (defaults to None). If "test", always returns True. Returns: True if the token is valid (volo-be returned 200), False otherwise. @@ -57,10 +58,6 @@ async def validate_token( >>> if valid: ... # proceed with authenticated request """ - # Skip validation in test environments - if env == "test": - return True - headers = { "client": client, "access-token": access_token, From a21add0364d963d2042bc8e094285e5c65894e5e Mon Sep 17 00:00:00 2001 From: ShyamSagothia Date: Sun, 19 Apr 2026 23:40:08 +0530 Subject: [PATCH 5/5] feat: added HMAC Request Signing to use in server-to-server calls --- tests/test_hmac.py | 103 +++++++++++++++++++++++++++++++++++ vp_core/security/__init__.py | 9 ++- vp_core/security/hmac.py | 100 ++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/test_hmac.py create mode 100644 vp_core/security/hmac.py diff --git a/tests/test_hmac.py b/tests/test_hmac.py new file mode 100644 index 0000000..3a4cfc5 --- /dev/null +++ b/tests/test_hmac.py @@ -0,0 +1,103 @@ +from unittest.mock import patch + +from vp_core.security import sign_request, verify_request + +# Shared cross-language test vector. +# The same inputs MUST produce the same signature when Ruby signs via +# OpenSSL::HMAC.hexdigest. volo-be/spec/clients/volo_agents_client_spec.rb +# asserts the same EXPECTED_SIG below — any drift breaks auth in production. +SHARED_SECRET = "test_secret_do_not_use_in_prod" +SHARED_METHOD = "POST" +SHARED_PATH = "/api/v1/keywords/suggest" +SHARED_BODY = b'{"seeds":["beer"]}' +SHARED_TIMESTAMP = 1712419200 +EXPECTED_SIG = "95975a61fdd5896a2e3ada649ead58c1a96dbf3c2a338a8d862580ec05b44e69" + + +def test_shared_vector_matches_ruby(): + """Cross-language contract — Ruby's OpenSSL::HMAC must produce this hex.""" + sig, ts = sign_request( + SHARED_SECRET, SHARED_METHOD, SHARED_PATH, SHARED_BODY, SHARED_TIMESTAMP + ) + assert sig == EXPECTED_SIG + assert ts == SHARED_TIMESTAMP + + +def test_sign_and_verify_round_trip(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello") + with patch("vp_core.security.hmac.time.time", return_value=ts): + assert verify_request("s3cret", sig, "POST", "/x", b"hello", ts) is True + + +def test_verify_tampered_body(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello") + with patch("vp_core.security.hmac.time.time", return_value=ts): + assert verify_request("s3cret", sig, "POST", "/x", b"tampered", ts) is False + + +def test_verify_tampered_path(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello") + with patch("vp_core.security.hmac.time.time", return_value=ts): + assert verify_request("s3cret", sig, "POST", "/y", b"hello", ts) is False + + +def test_verify_tampered_method(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello") + with patch("vp_core.security.hmac.time.time", return_value=ts): + assert verify_request("s3cret", sig, "DELETE", "/x", b"hello", ts) is False + + +def test_verify_wrong_secret(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello") + with patch("vp_core.security.hmac.time.time", return_value=ts): + assert verify_request("wrong", sig, "POST", "/x", b"hello", ts) is False + + +def test_verify_expired_timestamp(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello", timestamp=1_000_000) + # now is 61s past the signed timestamp — outside 60s window + with patch("vp_core.security.hmac.time.time", return_value=1_000_061): + assert verify_request("s3cret", sig, "POST", "/x", b"hello", ts) is False + + +def test_verify_future_timestamp(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello", timestamp=1_000_000) + # now is 61s before the signed timestamp — clock skew outside window + with patch("vp_core.security.hmac.time.time", return_value=999_939): + assert verify_request("s3cret", sig, "POST", "/x", b"hello", ts) is False + + +def test_verify_within_window_edge(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello", timestamp=1_000_000) + # exactly 60s off — still valid (boundary is inclusive) + with patch("vp_core.security.hmac.time.time", return_value=1_000_060): + assert verify_request("s3cret", sig, "POST", "/x", b"hello", ts) is True + + +def test_sign_request_normalizes_method_case(): + sig_upper, _ = sign_request("s3cret", "POST", "/x", b"", timestamp=1) + sig_lower, _ = sign_request("s3cret", "post", "/x", b"", timestamp=1) + assert sig_upper == sig_lower + + +def test_sign_request_empty_body(): + """GET requests have empty body — should still sign cleanly.""" + sig, ts = sign_request("s3cret", "GET", "/_ping", b"") + with patch("vp_core.security.hmac.time.time", return_value=ts): + assert verify_request("s3cret", sig, "GET", "/_ping", b"", ts) is True + + +def test_sign_request_uses_current_time_when_no_timestamp(): + with patch("vp_core.security.hmac.time.time", return_value=1234567890): + _, ts = sign_request("s3cret", "POST", "/x", b"") + assert ts == 1234567890 + + +def test_verify_custom_window(): + sig, ts = sign_request("s3cret", "POST", "/x", b"hello", timestamp=1_000_000) + # 10s old — inside custom 5s window? No. + with patch("vp_core.security.hmac.time.time", return_value=1_000_010): + assert verify_request("s3cret", sig, "POST", "/x", b"hello", ts, window_seconds=5) is False + # 10s old — inside custom 15s window? Yes. + with patch("vp_core.security.hmac.time.time", return_value=1_000_010): + assert verify_request("s3cret", sig, "POST", "/x", b"hello", ts, window_seconds=15) is True diff --git a/vp_core/security/__init__.py b/vp_core/security/__init__.py index 8fdfd34..2d6ba8d 100644 --- a/vp_core/security/__init__.py +++ b/vp_core/security/__init__.py @@ -1,3 +1,10 @@ from .guardrails import PromptSanitizer, OutputGuardrail +from .hmac import REPLAY_WINDOW_SECONDS, sign_request, verify_request -__all__ = ["PromptSanitizer", "OutputGuardrail"] +__all__ = [ + "PromptSanitizer", + "OutputGuardrail", + "sign_request", + "verify_request", + "REPLAY_WINDOW_SECONDS", +] diff --git a/vp_core/security/hmac.py b/vp_core/security/hmac.py new file mode 100644 index 0000000..66e6248 --- /dev/null +++ b/vp_core/security/hmac.py @@ -0,0 +1,100 @@ +""" +HMAC request signing utilities for service-to-service authentication. + +Pure-function helpers for signing and verifying HTTP requests between +Volopay microservices. Shared across all Python services that need +Layer 1 (backend-to-backend) auth. + +Usage (verifier side — e.g., volo-agents FastAPI dependency): + + from vp_core.security import verify_request + + body = await request.body() + ok = verify_request( + secret=SHARED_SECRET, + signature=request.headers["x-signature"], + method=request.method, + path=request.url.path, + body=body, + timestamp=int(request.headers["x-timestamp"]), + ) + +Usage (sender side — if the sender is Python): + + from vp_core.security import sign_request + + signature, ts = sign_request(SHARED_SECRET, "POST", "/api/v1/x", body_bytes) + headers = {"X-Signature": signature, "X-Timestamp": str(ts)} + +Non-Python senders (e.g., the Ruby volo-be client) implement their own +signing using OpenSSL::HMAC. The shared test vector in tests/test_hmac.py +enforces byte-identical output across languages. +""" + +import hashlib +import hmac +import time + +REPLAY_WINDOW_SECONDS = 60 + + +def sign_request( + secret: str, + method: str, + path: str, + body: bytes, + timestamp: int | None = None, +) -> tuple[str, int]: + """ + Compute HMAC-SHA256 signature over (timestamp, method, path, body). + + Args: + secret: Shared secret known to both sender and verifier + method: HTTP method (case-insensitive, normalized to uppercase) + path: Request path (e.g., "/api/v1/keywords/suggest") + body: Raw request body bytes (empty bytes for GET requests) + timestamp: Unix timestamp in seconds. If None, uses current time. + + Returns: + (signature_hex, timestamp) — both travel as headers. + """ + ts = timestamp if timestamp is not None else int(time.time()) + prefix = f"{ts}\n{method.upper()}\n{path}\n".encode() + signature = hmac.new(secret.encode(), prefix + body, hashlib.sha256).hexdigest() + return signature, ts + + +def verify_request( + secret: str, + signature: str, + method: str, + path: str, + body: bytes, + timestamp: int, + window_seconds: int = REPLAY_WINDOW_SECONDS, +) -> bool: + """ + Verify an incoming HMAC signature with replay protection. + + Performs two checks: + 1. Timestamp is within ``window_seconds`` of now (replay protection) + 2. Recomputed signature matches the provided one (timing-safe) + + Args: + secret: Shared secret known to both sender and verifier + signature: Hex-encoded signature from X-Signature header + method: HTTP method from the incoming request + path: Request path from the incoming request + body: Raw request body bytes + timestamp: Unix timestamp from X-Timestamp header + window_seconds: Max allowed clock drift. Defaults to 60s. + + Returns: + True if valid, False on any failure (timestamp skew, signature + mismatch, wrong secret). Never raises. + """ + if abs(int(time.time()) - timestamp) > window_seconds: + return False + + expected, _ = sign_request(secret, method, path, body, timestamp) + return hmac.compare_digest(signature, expected)