From e0589ecec1617cb64805e5448b77e5419705b076 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Fri, 28 Nov 2025 15:56:41 +0000 Subject: [PATCH 01/13] feat: implement tenant api fallback for webhook registration Signed-off-by: Yuki I --- oidc-controller/api/core/webhook_utils.py | 60 +++++++- oidc-controller/api/main.py | 6 + .../api/tests/test_webhook_registration.py | 133 ++++++++++++++++-- 3 files changed, 185 insertions(+), 14 deletions(-) diff --git a/oidc-controller/api/core/webhook_utils.py b/oidc-controller/api/core/webhook_utils.py index 2331684e..14e00063 100644 --- a/oidc-controller/api/core/webhook_utils.py +++ b/oidc-controller/api/core/webhook_utils.py @@ -1,6 +1,7 @@ import asyncio import structlog import requests +from typing import Callable logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) @@ -13,10 +14,14 @@ async def register_tenant_webhook( api_key: str | None, admin_api_key: str | None, admin_api_key_name: str | None, + token_fetcher: Callable[[], str] | None = None, ): """ Registers the controller's webhook URL with the ACA-Py Agent Tenant. - Includes retries for agent startup and validation for configuration. + Strategy: + 1. Try the Admin API (`/multitenancy/wallet/{id}`). + 2. If that fails with 403/404 (Blocked) and wallet_key is present, + fallback to the Tenant API (`/tenant/wallet`). """ if not webhook_url or not wallet_id: logger.warning( @@ -51,6 +56,7 @@ async def register_tenant_webhook( for attempt in range(0, max_retries): try: + # Try Admin API response = requests.put( target_url, json=payload, headers=headers, timeout=5 ) @@ -58,16 +64,27 @@ async def register_tenant_webhook( if response.status_code == 200: logger.info("Successfully registered webhook URL with ACA-Py tenant") return - elif response.status_code in [401, 403]: - logger.error( - f"Webhook registration failed: Unauthorized (401/403). Check AGENT_ADMIN_API_KEY configuration." + + # Fallback Logic: If Admin API is blocked (403/404) + elif response.status_code in [403, 404] and token_fetcher: + logger.info( + f"Admin API returned {response.status_code}. Attempting Tenant API fallback..." ) + if await _register_via_tenant_api(admin_url, payload, token_fetcher): + return + # If fallback fails, stop (don't retry admin api) + return + + elif response.status_code == 401: + logger.error("Admin API Unauthorized (401). Check ADMIN_API_KEY.") return + elif response.status_code >= 500: # Retry on server errors logger.warning( f"Webhook registration failed with server error {response.status_code}: {response.text}. Retrying..." ) + else: logger.warning( f"Webhook registration returned status {response.status_code}: {response.text}" @@ -90,3 +107,38 @@ async def register_tenant_webhook( logger.error( "Failed to register webhook after multiple attempts. Agent notification may fail." ) + + +async def _register_via_tenant_api( + admin_url: str, payload: dict, token_fetcher: Callable[[], str] +) -> bool: + """Fallback: use /tenant/wallet endpoint with provided token fetcher.""" + try: + # 1. Get Token + token = token_fetcher() + + if not token: + logger.error("Tenant Fallback: Token fetcher returned empty token") + return False + + # 2. Update via Tenant API + # Using the standard Traction/ACA-Py Tenant endpoint + tenant_url = f"{admin_url}/tenant/wallet" + tenant_headers = {"Authorization": f"Bearer {token}"} + + update_res = requests.put( + tenant_url, json=payload, headers=tenant_headers, timeout=5 + ) + + if update_res.status_code == 200: + logger.info("Successfully registered webhook via Tenant API") + return True + else: + logger.error( + f"Tenant Fallback: Update failed. Status: {update_res.status_code} Body: {update_res.text}" + ) + return False + + except Exception as e: + logger.error(f"Tenant Fallback Exception: {e}") + return False diff --git a/oidc-controller/api/main.py b/oidc-controller/api/main.py index fa99f231..ddbf6ce9 100644 --- a/oidc-controller/api/main.py +++ b/oidc-controller/api/main.py @@ -30,6 +30,7 @@ from .routers.socketio import sio_app, _build_redis_url, _handle_redis_failure from api.core.oidc.provider import init_provider from api.core.webhook_utils import register_tenant_webhook +from api.core.acapy.config import MultiTenantAcapy logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) @@ -160,6 +161,10 @@ async def on_tenant_startup(): # Robust Webhook Registration if settings.ACAPY_TENANCY == "multi": + token_fetcher = None + if settings.MT_ACAPY_WALLET_KEY: + token_fetcher = MultiTenantAcapy().get_wallet_token + await register_tenant_webhook( wallet_id=settings.MT_ACAPY_WALLET_ID, webhook_url=settings.CONTROLLER_WEB_HOOK_URL, @@ -167,6 +172,7 @@ async def on_tenant_startup(): api_key=settings.CONTROLLER_API_KEY, admin_api_key=settings.ST_ACAPY_ADMIN_API_KEY, admin_api_key_name=settings.ST_ACAPY_ADMIN_API_KEY_NAME, + token_fetcher=token_fetcher, ) logger.info(">>> Starting up app new ...") diff --git a/oidc-controller/api/tests/test_webhook_registration.py b/oidc-controller/api/tests/test_webhook_registration.py index 344cb582..5b9816de 100644 --- a/oidc-controller/api/tests/test_webhook_registration.py +++ b/oidc-controller/api/tests/test_webhook_registration.py @@ -20,6 +20,7 @@ def mock_settings(): # Default safe values mock.USE_REDIS_ADAPTER = False mock.ACAPY_TENANCY = "multi" + mock.MT_ACAPY_WALLET_KEY = "wallet-key" yield mock @@ -38,8 +39,8 @@ def mock_sleep(): @pytest.mark.asyncio -async def test_webhook_registration_success(mock_requests_put): - """Test successful webhook registration with API key injection.""" +async def test_webhook_registration_success_admin_api(mock_requests_put): + """Test successful registration via standard Admin API.""" mock_requests_put.return_value.status_code = 200 await register_tenant_webhook( @@ -51,14 +52,72 @@ async def test_webhook_registration_success(mock_requests_put): admin_api_key_name="x-api-key", ) - # Verify URL construction (Hash Hack) - expected_url = "http://controller/webhooks#my-api-key" - - # Verify arguments passed to requests.put + # Verify Admin API was called args, kwargs = mock_requests_put.call_args - assert args[0] == "http://acapy:8077/multitenancy/wallet/test-wallet" - assert kwargs["json"] == {"wallet_webhook_urls": [expected_url]} - assert kwargs["headers"] == {"x-api-key": "admin-key"} + assert "multitenancy/wallet/test-wallet" in args[0] + assert kwargs["headers"]["x-api-key"] == "admin-key" + + +@pytest.mark.asyncio +async def test_webhook_registration_fallback_success(mock_requests_put): + """ + Test fallback to Tenant API when Admin API returns 403. + This validates the new token_fetcher logic. + """ + # 1. Admin API returns 403 (Forbidden) + # 2. Tenant API returns 200 (Success) + mock_requests_put.side_effect = [ + MagicMock(status_code=403, text="Forbidden"), + MagicMock(status_code=200), + ] + + # Create a mock token fetcher function + mock_fetcher = MagicMock(return_value="injected-token") + + await register_tenant_webhook( + wallet_id="test-wallet", + webhook_url="http://controller", + admin_url="http://acapy", + api_key=None, + admin_api_key=None, + admin_api_key_name=None, + token_fetcher=mock_fetcher, + ) + + # Verify flow + assert mock_requests_put.call_count == 2 + + # Check 1st call (Admin) + admin_call = mock_requests_put.call_args_list[0] + assert "multitenancy/wallet" in admin_call[0][0] + + # Check Token Fetcher was called + mock_fetcher.assert_called_once() + + # Check 2nd call (Tenant) + tenant_call = mock_requests_put.call_args_list[1] + assert "tenant/wallet" in tenant_call[0][0] + assert tenant_call[1]["headers"]["Authorization"] == "Bearer injected-token" + + +@pytest.mark.asyncio +async def test_webhook_registration_no_fallback_without_fetcher(mock_requests_put): + """Test 403 error does NOT trigger fallback if no token_fetcher provided.""" + mock_requests_put.return_value.status_code = 403 + + # No fetcher provided + await register_tenant_webhook( + wallet_id="test-wallet", + webhook_url="http://controller", + admin_url="http://acapy", + api_key=None, + admin_api_key=None, + admin_api_key_name=None, + token_fetcher=None, + ) + + # Should try Admin API once, fail, and stop (because no fetcher to try fallback) + assert mock_requests_put.call_count == 1 @pytest.mark.asyncio @@ -82,7 +141,7 @@ async def test_webhook_registration_invalid_url(mock_requests_put): """Test validation for invalid URL protocol.""" await register_tenant_webhook( wallet_id="test-wallet", - webhook_url="ftp://invalid-url", # Invalid protocol + webhook_url="ftp://invalid-url", admin_url="http://acapy", api_key=None, admin_api_key=None, @@ -128,6 +187,60 @@ async def test_webhook_registration_retry_logic_with_backoff( mock_sleep.assert_has_calls([unittest.mock.call(2), unittest.mock.call(4)]) +@pytest.mark.asyncio +async def test_startup_multi_tenant_injects_fetcher(mock_settings, mock_requests_put): + """ + Critical Integration Test: + Ensures main.py actually instantiates MultiTenantAcapy and passes the method. + """ + mock_settings.ACAPY_TENANCY = "multi" + mock_settings.MT_ACAPY_WALLET_KEY = "wallet-key" # Trigger fetcher creation + mock_settings.USE_REDIS_ADAPTER = False + + # Mock MultiTenantAcapy class to verify instantiation + with patch("api.main.init_db", new_callable=AsyncMock), patch( + "api.main.init_provider", new_callable=AsyncMock + ), patch("api.main.get_db", new_callable=AsyncMock), patch( + "api.main.MultiTenantAcapy" + ) as mock_acapy_class, patch( + "api.main.register_tenant_webhook", new_callable=AsyncMock + ) as mock_register: + + # Setup mock instance + mock_acapy_instance = MagicMock() + mock_acapy_class.return_value = mock_acapy_instance + # Mock the bound method we expect to be passed + mock_acapy_instance.get_wallet_token = "bound-method-ref" + + await on_tenant_startup() + + # Verify register function was called + assert mock_register.called + + # Verify the token_fetcher argument was passed correctly + _, kwargs = mock_register.call_args + assert kwargs["token_fetcher"] == "bound-method-ref" + + +@pytest.mark.asyncio +async def test_startup_single_tenant_skips_registration( + mock_settings, mock_requests_put +): + """Test startup logic in single-tenant mode skips registration.""" + mock_settings.ACAPY_TENANCY = "single" + mock_settings.USE_REDIS_ADAPTER = False + + with patch("api.main.init_db", new_callable=AsyncMock), patch( + "api.main.init_provider", new_callable=AsyncMock + ), patch("api.main.get_db", new_callable=AsyncMock), patch( + "api.main.register_tenant_webhook", new_callable=AsyncMock + ) as mock_register: + + await on_tenant_startup() + + assert not mock_register.called + + @pytest.mark.asyncio async def test_webhook_registration_fatal_auth_error(mock_requests_put, mock_sleep): """Test that 401/403 errors stop retries immediately.""" From 12e11eeee2f44d6d62045cb87f3957c2aaabd778 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Sat, 29 Nov 2025 14:39:27 +0000 Subject: [PATCH 02/13] feat: add traction tenant mode for secure webhook registration Signed-off-by: Yuki I --- docker/manage | 3 + docs/ConfigurationGuide.md | 23 +++ docs/MigrationGuide.md | 10 +- oidc-controller/api/core/acapy/client.py | 9 +- oidc-controller/api/core/acapy/config.py | 82 +++++++++ .../api/core/acapy/tests/test_client.py | 14 +- .../api/core/acapy/tests/test_config.py | 156 +++++++++++++++++- oidc-controller/api/core/config.py | 16 +- oidc-controller/api/core/webhook_utils.py | 120 +++++++++----- oidc-controller/api/main.py | 20 ++- .../api/tests/test_webhook_registration.py | 77 +++++++-- 11 files changed, 461 insertions(+), 69 deletions(-) diff --git a/docker/manage b/docker/manage index 03c367b2..e3cc737f 100755 --- a/docker/manage +++ b/docker/manage @@ -251,6 +251,9 @@ configureEnvironment() { export MT_ACAPY_WALLET_ID=${MT_ACAPY_WALLET_ID} export MT_ACAPY_WALLET_KEY=${MT_ACAPY_WALLET_KEY} + export TRACTION_TENANT_ID=${TRACTION_TENANT_ID} + export TRACTION_TENANT_API_KEY=${TRACTION_TENANT_API_KEY} + # keycloak-db export KEYCLOAK_DB_NAME="keycloak" export KEYCLOAK_DB_USER="keycloak" diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 9b7c9b79..131cb935 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -77,6 +77,9 @@ Several functions in ACAPy VC-AuthN can be tweaked by using the following enviro | Variable | Type | What it does | NOTES | | ------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | ACAPY_PROOF_FORMAT | string ("indy" or "anoncreds") | Sets the proof format for ACA-Py presentation requests. Use `anoncreds` for modern AnonCreds support or `indy` for legacy Indy-based ecosystems. | Defaults to `indy` for backward compatibility with existing deployments. | +| ACAPY_TENANCY | string ("single", "multi", "traction") | Determines how the controller authenticates with the ACA-Py agent.
- `single`: No special auth headers (or just admin key).
- `multi`: Uses `MT_ACAPY_WALLET_ID` and Admin API to fetch token.
- `traction`: Uses Traction-specific authentication and Tenant APIs (bypassing Admin endpoints). | Defaults to `single`. Use `traction` when integrating with a secured Traction tenant where Admin APIs are blocked. | +| TRACTION_TENANT_ID | string | **Required for Traction Mode.** The Tenant ID assigned by Traction. | Used to acquire authentication token from `/multitenancy/tenant/{id}/token`. | +| TRACTION_TENANT_API_KEY | string | **Required for Traction Mode.** The Tenant API Key provided by Traction. | Used as the payload `{ "api_key": "..." }` when fetching the token. | | CONTROLLER_WEB_HOOK_URL | string (URL) | **Required for Multi-Tenant Mode.** The public or internal URL where the ACA-Py Agent can reach this Controller to send webhooks (e.g. `http://controller:5000/webhooks`). | The controller will automatically register this URL with the specific tenant wallet on startup. | | SET_NON_REVOKED | bool | if True, the `non_revoked` attributed will be added to each of the present-proof request `requested_attribute` and `requested_predicate` with 'from=0' and'to=`int(time.time())` | | | USE_OOB_LOCAL_DID_SERVICE | bool | Instructs ACAPy VC-AuthN to use a local DID, it must be used when the agent service is not registered on the ledger with a public DID | Use this when `ACAPY_WALLET_LOCAL_DID` is set to `true` in the agent. | @@ -85,6 +88,26 @@ Several functions in ACAPy VC-AuthN can be tweaked by using the following enviro | LOG_TIMESTAMP_FORMAT | string | determines the timestamp formatting used in logs | Default is "iso" | | LOG_LEVEL | "DEBUG", "INFO", "WARNING", or "ERROR" | sets the minimum log level that will be printed to standard out | Defaults to DEBUG | +### Traction Tenancy Mode + +The `traction` mode (`ACAPY_TENANCY="traction"`) is designed specifically for environments where the ACA-Py Admin API endpoints (e.g., `/multitenancy/wallet/...`) are restricted or blocked for security reasons. + +In this mode: +1. **Authentication:** The controller authenticates directly as the tenant using `TRACTION_TENANT_ID` and `TRACTION_TENANT_API_KEY` to obtain a Bearer token. +2. **Fallback:** If Traction keys are not provided, it falls back to trying to authenticate using the `MT_ACAPY_WALLET_ID` and `MT_ACAPY_WALLET_KEY` via the wallet token endpoint (though this may fail if that endpoint is also blocked). +3. **Webhook Registration:** The controller bypasses the Admin API and uses the authenticated Tenant API (`PUT /tenant/wallet`) to register the `CONTROLLER_WEB_HOOK_URL`. + +**Example Docker Configuration for Traction:** + +```yaml +environment: + - ACAPY_TENANCY=traction + - TRACTION_TENANT_ID=00000000-0000-0000-0000-000000000000 + - TRACTION_TENANT_API_KEY=your-traction-api-key + - CONTROLLER_WEB_HOOK_URL=https://your-controller-url.com/webhooks + - ACAPY_ADMIN_URL=https://traction-tenant-proxy-url +``` + ### Multi-Tenant Webhook Registration When running in Multi-Tenant mode (`ACAPY_TENANCY="multi"`), the ACA-Py Agent may not automatically know where to send verification success notifications for specific tenant wallets. diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index c3ab6cd1..5f2dbd84 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -24,4 +24,12 @@ If you are running in Multi-Tenant mode (`ACAPY_TENANCY="multi"`), you **must** ```yaml environment: - CONTROLLER_WEB_HOOK_URL=https:///webhooks - ``` \ No newline at end of file + ``` + +### New Tenancy Mode: Traction + +A new mode has been added for integrating with Traction (or secured multi-tenant agents where Admin APIs are blocked). + +* **Setting:** `ACAPY_TENANCY="traction"` +* **Requirements:** Requires `TRACTION_TENANT_ID` and `TRACTION_TENANT_API_KEY`. +* **Behavior:** Authenticates directly with the Tenant API and bypasses `multitenancy/wallet` Admin endpoints. \ No newline at end of file diff --git a/oidc-controller/api/core/acapy/client.py b/oidc-controller/api/core/acapy/client.py index 4bb6705a..5cb6fa04 100644 --- a/oidc-controller/api/core/acapy/client.py +++ b/oidc-controller/api/core/acapy/client.py @@ -5,7 +5,12 @@ import structlog from ..config import settings -from .config import AgentConfig, MultiTenantAcapy, SingleTenantAcapy +from .config import ( + AgentConfig, + MultiTenantAcapy, + SingleTenantAcapy, + TractionTenantAcapy, +) from .models import CreatePresentationResponse, OobCreateInvitationResponse, WalletDid # HTTP timeout for all ACA-Py API calls (seconds) @@ -36,6 +41,8 @@ class AcapyClient: def __init__(self): if settings.ACAPY_TENANCY == "multi": self.agent_config = MultiTenantAcapy() + elif settings.ACAPY_TENANCY == "traction": + self.agent_config = TractionTenantAcapy() elif settings.ACAPY_TENANCY == "single": self.agent_config = SingleTenantAcapy() else: diff --git a/oidc-controller/api/core/acapy/config.py b/oidc-controller/api/core/acapy/config.py index ebaeb795..100a6b8a 100644 --- a/oidc-controller/api/core/acapy/config.py +++ b/oidc-controller/api/core/acapy/config.py @@ -65,6 +65,88 @@ def get_headers(self) -> dict[str, str]: return {"Authorization": "Bearer " + self.get_wallet_token()} +class TractionTenantAcapy: + """ + Configuration for Traction Multi-Tenancy. + Attempts to fetch a token using Traction Tenant API credentials first. + Falls back to ACA-Py Wallet Key authentication if Traction credentials are not set. + """ + + tenant_id = settings.TRACTION_TENANT_ID + tenant_api_key = settings.TRACTION_TENANT_API_KEY + wallet_id = settings.MT_ACAPY_WALLET_ID + wallet_key = settings.MT_ACAPY_WALLET_KEY + + @cache + def get_wallet_token(self): + logger.debug(">>> get_wallet_token (Traction Mode)") + + # Try Traction API Key Flow + if self.tenant_id and self.tenant_api_key: + logger.debug( + "Attempting Traction Token acquisition via tenant_id/api_key", + tenant_id=self.tenant_id, + ) + try: + payload = {"api_key": self.tenant_api_key} + resp_raw = requests.post( + settings.ACAPY_ADMIN_URL + + f"/multitenancy/tenant/{self.tenant_id}/token", + json=payload, + ) + + if resp_raw.status_code == 200: + resp = json.loads(resp_raw.content) + logger.debug("<<< get_wallet_token (Success via Traction API)") + return resp["token"] + else: + logger.warning( + "Traction API Token fetch failed", + status=resp_raw.status_code, + detail=resp_raw.content.decode(), + ) + except Exception as e: + logger.error("Error connecting to Traction Tenant API", error=str(e)) + + # Fallback to Standard ACA-Py Wallet Key Flow + if self.wallet_id and self.wallet_key: + logger.debug( + "Attempting Wallet Token acquisition via wallet_id/wallet_key", + wallet_id=self.wallet_id, + ) + try: + # No Admin Headers in Traction mode as we assume Admin API is blocked + payload = {"wallet_key": self.wallet_key} + resp_raw = requests.post( + settings.ACAPY_ADMIN_URL + + f"/multitenancy/wallet/{self.wallet_id}/token", + json=payload, + ) + + if resp_raw.status_code == 200: + resp = json.loads(resp_raw.content) + logger.debug("<<< get_wallet_token (Success via Wallet Key)") + return resp["token"] + else: + error_detail = resp_raw.content.decode() + logger.error( + f"Failed to get wallet token via wallet key fallback. Status: {resp_raw.status_code}, Detail: {error_detail}" + ) + raise Exception(f"{resp_raw.status_code}::{error_detail}") + + except Exception as e: + logger.error("Error fetching token via wallet key", error=str(e)) + raise e + + # No valid credentials found + error_msg = "Could not acquire token. Ensure TRACTION_TENANT_ID/API_KEY or MT_ACAPY_WALLET_ID/KEY are set." + logger.error(error_msg) + raise Exception(error_msg) + + def get_headers(self) -> dict[str, str]: + return {"Authorization": "Bearer " + self.get_wallet_token()} + + class SingleTenantAcapy: def get_headers(self) -> dict[str, str]: # Check if admin API key is configured diff --git a/oidc-controller/api/core/acapy/tests/test_client.py b/oidc-controller/api/core/acapy/tests/test_client.py index 34c9d7e3..92d946a6 100644 --- a/oidc-controller/api/core/acapy/tests/test_client.py +++ b/oidc-controller/api/core/acapy/tests/test_client.py @@ -13,7 +13,11 @@ WALLET_DID_URI, AcapyClient, ) -from api.core.acapy.config import MultiTenantAcapy, SingleTenantAcapy +from api.core.acapy.config import ( + MultiTenantAcapy, + SingleTenantAcapy, + TractionTenantAcapy, +) from api.core.acapy.models import CreatePresentationResponse, WalletDid from api.core.acapy.tests.__mocks__ import ( create_presentation_response_http, @@ -46,6 +50,14 @@ async def test_init_multi_returns_client_with_multi_tenancy_config(): assert isinstance(client.agent_config, MultiTenantAcapy) is True +@pytest.mark.asyncio +@mock.patch.object(settings, "ACAPY_TENANCY", "traction") +async def test_init_traction_returns_client_with_traction_tenancy_config(): + client = AcapyClient() + assert client is not None + assert isinstance(client.agent_config, TractionTenantAcapy) is True + + @pytest.mark.asyncio async def test_create_presentation_returns_sucessfully_with_valid_data(requests_mock): requests_mock.post( diff --git a/oidc-controller/api/core/acapy/tests/test_config.py b/oidc-controller/api/core/acapy/tests/test_config.py index ce115588..5b1889b9 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -1,6 +1,11 @@ import mock import pytest -from api.core.acapy.config import MultiTenantAcapy, SingleTenantAcapy +import json +from api.core.acapy.config import ( + MultiTenantAcapy, + SingleTenantAcapy, + TractionTenantAcapy, +) from api.core.config import settings @@ -165,3 +170,152 @@ async def test_multi_tenant_throws_exception_for_401_unauthorized(requests_mock) assert "401" in str(excinfo.value) assert "unauthorized" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_traction_tenant_api_key_flow_success(requests_mock): + """Test Traction mode getting token using Tenant ID and API Key.""" + tenant_id = "test-tenant-id" + api_key = "test-api-key" + + with mock.patch.object( + settings, "TRACTION_TENANT_ID", tenant_id + ), mock.patch.object(settings, "TRACTION_TENANT_API_KEY", api_key): + + # Mock the Traction token endpoint + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", + json={"token": "traction-token"}, + status_code=200, + ) + + acapy = TractionTenantAcapy() + acapy.tenant_id = tenant_id + acapy.tenant_api_key = api_key + acapy.get_wallet_token.cache_clear() + + token = acapy.get_wallet_token() + assert token == "traction-token" + + # Verify request details + last_request = requests_mock.last_request + assert last_request.json() == {"api_key": api_key} + + +@pytest.mark.asyncio +async def test_traction_tenant_fallback_to_wallet_key_success(requests_mock): + """Test Traction mode falling back to Wallet Key when Tenant API auth missing/fails.""" + wallet_id = "test-wallet-id" + wallet_key = "test-wallet-key" + + # Set TRACTION_ vars to None to trigger fallback immediately + with mock.patch.object(settings, "TRACTION_TENANT_ID", None), mock.patch.object( + settings, "TRACTION_TENANT_API_KEY", None + ), mock.patch.object(settings, "MT_ACAPY_WALLET_ID", wallet_id), mock.patch.object( + settings, "MT_ACAPY_WALLET_KEY", wallet_key + ): + + # Mock the Wallet token endpoint (no admin header used in traction mode) + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + json={"token": "fallback-token"}, + status_code=200, + ) + + acapy = TractionTenantAcapy() + acapy.tenant_id = None + acapy.tenant_api_key = None + acapy.wallet_id = wallet_id + acapy.wallet_key = wallet_key + acapy.get_wallet_token.cache_clear() + + token = acapy.get_wallet_token() + assert token == "fallback-token" + + +@pytest.mark.asyncio +async def test_traction_tenant_api_auth_fails_then_fallback_succeeds(requests_mock): + """Test Traction mode tries Tenant API, fails, then succeeds with Wallet Key.""" + tenant_id = "test-tenant-id" + api_key = "test-api-key" + wallet_id = "test-wallet-id" + wallet_key = "test-wallet-key" + + with mock.patch.object( + settings, "TRACTION_TENANT_ID", tenant_id + ), mock.patch.object( + settings, "TRACTION_TENANT_API_KEY", api_key + ), mock.patch.object( + settings, "MT_ACAPY_WALLET_ID", wallet_id + ), mock.patch.object( + settings, "MT_ACAPY_WALLET_KEY", wallet_key + ): + + # Traction API call fails (e.g. 401/403 or server error) + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", + status_code=401, + ) + + # Fallback to Wallet Key succeeds + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + json={"token": "fallback-token"}, + status_code=200, + ) + + acapy = TractionTenantAcapy() + acapy.tenant_id = tenant_id + acapy.tenant_api_key = api_key + acapy.wallet_id = wallet_id + acapy.wallet_key = wallet_key + acapy.get_wallet_token.cache_clear() + + token = acapy.get_wallet_token() + assert token == "fallback-token" + + +@pytest.mark.asyncio +async def test_traction_tenant_all_auth_methods_fail(requests_mock): + """Test exception raised when all authentication methods fail in Traction mode.""" + tenant_id = "test-tenant-id" + api_key = "test-api-key" + wallet_id = "test-wallet-id" + wallet_key = "test-wallet-key" + + with mock.patch.object( + settings, "TRACTION_TENANT_ID", tenant_id + ), mock.patch.object( + settings, "TRACTION_TENANT_API_KEY", api_key + ), mock.patch.object( + settings, "MT_ACAPY_WALLET_ID", wallet_id + ), mock.patch.object( + settings, "MT_ACAPY_WALLET_KEY", wallet_key + ): + + # Traction API fails + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", + status_code=500, + ) + + # Wallet Key fallback fails + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + status_code=404, + content=b"Wallet not found", + ) + + acapy = TractionTenantAcapy() + acapy.tenant_id = tenant_id + acapy.tenant_api_key = api_key + acapy.wallet_id = wallet_id + acapy.wallet_key = wallet_key + acapy.get_wallet_token.cache_clear() + + with pytest.raises(Exception) as excinfo: + acapy.get_wallet_token() + + # Verify the exception came from the final fallback failure + assert "404" in str(excinfo.value) + assert "Wallet not found" in str(excinfo.value) diff --git a/oidc-controller/api/core/config.py b/oidc-controller/api/core/config.py index 3f0d4822..154737e0 100644 --- a/oidc-controller/api/core/config.py +++ b/oidc-controller/api/core/config.py @@ -199,9 +199,8 @@ class GlobalConfig(BaseSettings): if not ACAPY_AGENT_URL: logger.warning("ACAPY_AGENT_URL was not provided, agent will not be accessible") - ACAPY_TENANCY: str = os.environ.get( - "ACAPY_TENANCY", "single" - ) # valid options are "multi" and "single" + # valid options are "multi", "single", and "traction" + ACAPY_TENANCY: str = os.environ.get("ACAPY_TENANCY", "single") ACAPY_ADMIN_URL: str = os.environ.get("ACAPY_ADMIN_URL", "http://localhost:8031") @@ -210,6 +209,10 @@ class GlobalConfig(BaseSettings): MT_ACAPY_WALLET_ID: str | None = os.environ.get("MT_ACAPY_WALLET_ID") MT_ACAPY_WALLET_KEY: str = os.environ.get("MT_ACAPY_WALLET_KEY", "random-key") + # Traction Configuration + TRACTION_TENANT_ID: str | None = os.environ.get("TRACTION_TENANT_ID") + TRACTION_TENANT_API_KEY: str | None = os.environ.get("TRACTION_TENANT_API_KEY") + ST_ACAPY_ADMIN_API_KEY_NAME: str | None = os.environ.get( "ST_ACAPY_ADMIN_API_KEY_NAME" ) @@ -312,9 +315,12 @@ def get_configuration() -> GlobalConfig: ) # Startup validation for CONTROLLER_WEB_HOOK_URL in Multi-Tenant mode -if settings.ACAPY_TENANCY == "multi" and not settings.CONTROLLER_WEB_HOOK_URL: +if ( + settings.ACAPY_TENANCY in ["multi", "traction"] + and not settings.CONTROLLER_WEB_HOOK_URL +): logger.warning( - "ACAPY_TENANCY is set to 'multi' but CONTROLLER_WEB_HOOK_URL is missing. " + f"ACAPY_TENANCY is set to '{settings.ACAPY_TENANCY}' but CONTROLLER_WEB_HOOK_URL is missing. " "The controller will not be able to register webhooks with the tenant wallet, " "which may cause verification flows to hang." ) diff --git a/oidc-controller/api/core/webhook_utils.py b/oidc-controller/api/core/webhook_utils.py index 14e00063..5e692dbc 100644 --- a/oidc-controller/api/core/webhook_utils.py +++ b/oidc-controller/api/core/webhook_utils.py @@ -15,21 +15,32 @@ async def register_tenant_webhook( admin_api_key: str | None, admin_api_key_name: str | None, token_fetcher: Callable[[], str] | None = None, + use_admin_api: bool = True, ): """ Registers the controller's webhook URL with the ACA-Py Agent Tenant. - Strategy: - 1. Try the Admin API (`/multitenancy/wallet/{id}`). - 2. If that fails with 403/404 (Blocked) and wallet_key is present, - fallback to the Tenant API (`/tenant/wallet`). + + Strategies: + 1. If use_admin_api is True (default for 'multi' mode): + - Try the Admin API (`/multitenancy/wallet/{id}`). + - If that fails with 403/404 (Blocked) and token_fetcher is present, + fallback to the Tenant API (`/tenant/wallet`). + + 2. If use_admin_api is False (default for 'traction' mode): + - Directly fetch token using token_fetcher. + - Use Tenant API (`/tenant/wallet`) to update webhook. """ - if not webhook_url or not wallet_id: + if not webhook_url: logger.warning( - "Multi-tenant mode enabled but CONTROLLER_WEB_HOOK_URL or MT_ACAPY_WALLET_ID is missing. " + "Webhook registration skipped: CONTROLLER_WEB_HOOK_URL is missing. " "Verification callbacks may not work." ) return + if use_admin_api and not wallet_id: + logger.error("Admin API registration requested but wallet_id is missing.") + return + if not webhook_url.startswith("http"): logger.error( f"Invalid webhook URL format: {webhook_url}. Must start with http:// or https://" @@ -42,54 +53,76 @@ async def register_tenant_webhook( if api_key and "#" not in webhook_url: webhook_url = f"{webhook_url}#{api_key}" - headers = {} - if admin_api_key_name and admin_api_key: - headers[admin_api_key_name] = admin_api_key - - target_url = f"{admin_url}/multitenancy/wallet/{wallet_id}" payload = {"wallet_webhook_urls": [webhook_url]} max_retries = 5 base_delay = 2 # seconds - logger.info(f"Attempting to register webhook for wallet {wallet_id}...") + logger.info(f"Attempting to register webhook: {webhook_url}") for attempt in range(0, max_retries): try: - # Try Admin API - response = requests.put( - target_url, json=payload, headers=headers, timeout=5 - ) + # STRATEGY 1: Standard Multi-Tenant Admin API + if use_admin_api: + headers = {} + if admin_api_key_name and admin_api_key: + headers[admin_api_key_name] = admin_api_key - if response.status_code == 200: - logger.info("Successfully registered webhook URL with ACA-Py tenant") - return + target_url = f"{admin_url}/multitenancy/wallet/{wallet_id}" + logger.debug(f"Attempting Admin API update at {target_url}") - # Fallback Logic: If Admin API is blocked (403/404) - elif response.status_code in [403, 404] and token_fetcher: - logger.info( - f"Admin API returned {response.status_code}. Attempting Tenant API fallback..." + response = requests.put( + target_url, json=payload, headers=headers, timeout=5 ) - if await _register_via_tenant_api(admin_url, payload, token_fetcher): + + if response.status_code == 200: + logger.info( + "Successfully registered webhook URL with ACA-Py tenant via Admin API" + ) return - # If fallback fails, stop (don't retry admin api) - return - elif response.status_code == 401: - logger.error("Admin API Unauthorized (401). Check ADMIN_API_KEY.") - return + # Fallback Logic: If Admin API is blocked (403/404) + elif response.status_code in [403, 404]: + logger.warning( + f"Admin API returned {response.status_code}. Checking for Tenant API fallback capability..." + ) + if token_fetcher: + if await _register_via_tenant_api( + admin_url, payload, token_fetcher + ): + return + else: + logger.error( + "Cannot fallback to Tenant API: No token fetcher available." + ) + return + + elif response.status_code == 401: + logger.error("Admin API Unauthorized (401). Check ADMIN_API_KEY.") + return - elif response.status_code >= 500: - # Retry on server errors - logger.warning( - f"Webhook registration failed with server error {response.status_code}: {response.text}. Retrying..." - ) + elif response.status_code >= 500: + logger.warning( + f"Webhook registration failed with server error {response.status_code}: {response.text}. Retrying..." + ) + else: + logger.warning( + f"Webhook registration returned unexpected status {response.status_code}: {response.text}" + ) + return + # STRATEGY 2: Direct Tenant API (Traction Mode) else: - logger.warning( - f"Webhook registration returned status {response.status_code}: {response.text}" - ) - return + if not token_fetcher: + logger.error( + "Direct Tenant API registration requested but no token_fetcher provided." + ) + return + + logger.debug("Attempting Direct Tenant API update") + if await _register_via_tenant_api(admin_url, payload, token_fetcher): + return + logger.warning("Direct Tenant API update failed. Retrying...") except requests.exceptions.ConnectionError: logger.warning(f"ACA-Py Agent unreachable at {admin_url}") @@ -112,7 +145,7 @@ async def register_tenant_webhook( async def _register_via_tenant_api( admin_url: str, payload: dict, token_fetcher: Callable[[], str] ) -> bool: - """Fallback: use /tenant/wallet endpoint with provided token fetcher.""" + """Fallback/Direct: use /tenant/wallet endpoint with provided token fetcher.""" try: # 1. Get Token token = token_fetcher() @@ -133,12 +166,17 @@ async def _register_via_tenant_api( if update_res.status_code == 200: logger.info("Successfully registered webhook via Tenant API") return True + elif update_res.status_code >= 500: + logger.warning( + f"Tenant API Server Error: {update_res.status_code}. {update_res.text}" + ) + return False else: logger.error( - f"Tenant Fallback: Update failed. Status: {update_res.status_code} Body: {update_res.text}" + f"Tenant API Update failed. Status: {update_res.status_code} Body: {update_res.text}" ) return False except Exception as e: - logger.error(f"Tenant Fallback Exception: {e}") + logger.error(f"Tenant API Update Exception: {e}") return False diff --git a/oidc-controller/api/main.py b/oidc-controller/api/main.py index ddbf6ce9..a562fef6 100644 --- a/oidc-controller/api/main.py +++ b/oidc-controller/api/main.py @@ -30,7 +30,7 @@ from .routers.socketio import sio_app, _build_redis_url, _handle_redis_failure from api.core.oidc.provider import init_provider from api.core.webhook_utils import register_tenant_webhook -from api.core.acapy.config import MultiTenantAcapy +from api.core.acapy.config import MultiTenantAcapy, TractionTenantAcapy logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) @@ -161,6 +161,7 @@ async def on_tenant_startup(): # Robust Webhook Registration if settings.ACAPY_TENANCY == "multi": + logger.info("Starting up in Multi-Tenant Admin Mode") token_fetcher = None if settings.MT_ACAPY_WALLET_KEY: token_fetcher = MultiTenantAcapy().get_wallet_token @@ -173,6 +174,23 @@ async def on_tenant_startup(): admin_api_key=settings.ST_ACAPY_ADMIN_API_KEY, admin_api_key_name=settings.ST_ACAPY_ADMIN_API_KEY_NAME, token_fetcher=token_fetcher, + use_admin_api=True, + ) + + elif settings.ACAPY_TENANCY == "traction": + logger.info("Starting up in Traction Mode") + + token_fetcher = TractionTenantAcapy().get_wallet_token + + await register_tenant_webhook( + wallet_id=settings.MT_ACAPY_WALLET_ID, # Optional/Unused for traction mode registration + webhook_url=settings.CONTROLLER_WEB_HOOK_URL, + admin_url=settings.ACAPY_ADMIN_URL, + api_key=settings.CONTROLLER_API_KEY, + admin_api_key=None, # Not used in direct tenant update + admin_api_key_name=None, + token_fetcher=token_fetcher, + use_admin_api=False, ) logger.info(">>> Starting up app new ...") diff --git a/oidc-controller/api/tests/test_webhook_registration.py b/oidc-controller/api/tests/test_webhook_registration.py index 5b9816de..6dc59cb0 100644 --- a/oidc-controller/api/tests/test_webhook_registration.py +++ b/oidc-controller/api/tests/test_webhook_registration.py @@ -100,6 +100,35 @@ async def test_webhook_registration_fallback_success(mock_requests_put): assert tenant_call[1]["headers"]["Authorization"] == "Bearer injected-token" +@pytest.mark.asyncio +async def test_webhook_registration_traction_mode_direct_tenant_api(mock_requests_put): + """ + Test Traction mode (use_admin_api=False) which skips Admin API and goes direct to Tenant API. + """ + mock_requests_put.return_value.status_code = 200 + mock_fetcher = MagicMock(return_value="traction-token") + + await register_tenant_webhook( + wallet_id="ignored-in-traction-mode", + webhook_url="http://controller", + admin_url="http://acapy", + api_key=None, + admin_api_key=None, + admin_api_key_name=None, + token_fetcher=mock_fetcher, + use_admin_api=False, # Trigger direct tenant mode + ) + + # Verify flow + assert mock_requests_put.call_count == 1 + + # Verify call was to Tenant endpoint directly + tenant_call = mock_requests_put.call_args_list[0] + assert "tenant/wallet" in tenant_call[0][0] + assert "multitenancy/wallet" not in tenant_call[0][0] + assert tenant_call[1]["headers"]["Authorization"] == "Bearer traction-token" + + @pytest.mark.asyncio async def test_webhook_registration_no_fallback_without_fetcher(mock_requests_put): """Test 403 error does NOT trigger fallback if no token_fetcher provided.""" @@ -123,7 +152,7 @@ async def test_webhook_registration_no_fallback_without_fetcher(mock_requests_pu @pytest.mark.asyncio async def test_webhook_registration_missing_config(mock_requests_put): """Test early return if config is missing.""" - # Missing wallet_id + # Missing wallet_id AND use_admin_api=True (default) await register_tenant_webhook( wallet_id=None, webhook_url="http://controller", @@ -220,6 +249,35 @@ async def test_startup_multi_tenant_injects_fetcher(mock_settings, mock_requests # Verify the token_fetcher argument was passed correctly _, kwargs = mock_register.call_args assert kwargs["token_fetcher"] == "bound-method-ref" + assert kwargs["use_admin_api"] == True + + +@pytest.mark.asyncio +async def test_startup_traction_mode_config(mock_settings, mock_requests_put): + """ + Test startup logic in traction mode: uses TractionTenantAcapy and skips admin API. + """ + mock_settings.ACAPY_TENANCY = "traction" + mock_settings.USE_REDIS_ADAPTER = False + + with patch("api.main.init_db", new_callable=AsyncMock), patch( + "api.main.init_provider", new_callable=AsyncMock + ), patch("api.main.get_db", new_callable=AsyncMock), patch( + "api.main.TractionTenantAcapy" + ) as mock_traction_class, patch( + "api.main.register_tenant_webhook", new_callable=AsyncMock + ) as mock_register: + + mock_traction_instance = MagicMock() + mock_traction_class.return_value = mock_traction_instance + mock_traction_instance.get_wallet_token = "traction-token-fetcher" + + await on_tenant_startup() + + assert mock_register.called + _, kwargs = mock_register.call_args + assert kwargs["token_fetcher"] == "traction-token-fetcher" + assert kwargs["use_admin_api"] == False @pytest.mark.asyncio @@ -312,23 +370,6 @@ async def test_startup_multi_tenant_registers_webhook(mock_settings, mock_reques assert mock_register.called -@pytest.mark.asyncio -async def test_startup_single_tenant_skips_registration( - mock_settings, mock_requests_put -): - """Test startup logic in single-tenant mode skips registration.""" - mock_settings.ACAPY_TENANCY = "single" - mock_settings.USE_REDIS_ADAPTER = False - - with patch("api.main.init_db", new_callable=AsyncMock), patch( - "api.main.init_provider", new_callable=AsyncMock - ), patch("api.main.get_db", new_callable=AsyncMock): - - await on_tenant_startup() - - assert not mock_requests_put.called - - @pytest.mark.asyncio async def test_startup_redis_check_success(mock_settings): """Test startup logic verifies Redis connection if adapter enabled.""" From ea7a07c4fb4c9824091b60f59418c29b8cc72563 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Sat, 29 Nov 2025 22:32:52 +0000 Subject: [PATCH 03/13] feat: add traction tenant mode for secure webhook registration Signed-off-by: Yuki I --- docker/docker-compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 9cab5a4d..cf39908c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -51,6 +51,8 @@ services: - ACAPY_ADMIN_URL=${AGENT_ADMIN_URL} - MT_ACAPY_WALLET_ID=${MT_ACAPY_WALLET_ID} - MT_ACAPY_WALLET_KEY=${MT_ACAPY_WALLET_KEY} + - TRACTION_TENANT_ID=${TRACTION_TENANT_ID} + - TRACTION_TENANT_API_KEY=${TRACTION_TENANT_API_KEY} - ST_ACAPY_ADMIN_API_KEY=${AGENT_ADMIN_API_KEY} - ST_ACAPY_ADMIN_API_KEY_NAME=${ST_ACAPY_ADMIN_API_KEY_NAME} - USE_OOB_LOCAL_DID_SERVICE=${USE_OOB_LOCAL_DID_SERVICE} From 192990300e837d019047c8b53abeb53fed72d106 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Sat, 29 Nov 2025 23:30:59 +0000 Subject: [PATCH 04/13] test: improve coverage for webhook utils and config error handling Signed-off-by: Yuki I --- .../api/core/acapy/tests/test_config.py | 82 ++++++++++++++ .../api/tests/test_webhook_registration.py | 101 +++++++++++++++--- 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/oidc-controller/api/core/acapy/tests/test_config.py b/oidc-controller/api/core/acapy/tests/test_config.py index 5b1889b9..bf31a8d1 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -7,6 +7,7 @@ TractionTenantAcapy, ) from api.core.config import settings +from requests.exceptions import RequestException @pytest.mark.asyncio @@ -319,3 +320,84 @@ async def test_traction_tenant_all_auth_methods_fail(requests_mock): # Verify the exception came from the final fallback failure assert "404" in str(excinfo.value) assert "Wallet not found" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_traction_tenant_api_connection_error_triggers_fallback(requests_mock): + """Test that a connection error to Traction API triggers the wallet fallback.""" + tenant_id = "test-tenant-id" + api_key = "test-api-key" + wallet_id = "test-wallet-id" + wallet_key = "test-wallet-key" + + with mock.patch.object( + settings, "TRACTION_TENANT_ID", tenant_id + ), mock.patch.object( + settings, "TRACTION_TENANT_API_KEY", api_key + ), mock.patch.object( + settings, "MT_ACAPY_WALLET_ID", wallet_id + ), mock.patch.object( + settings, "MT_ACAPY_WALLET_KEY", wallet_key + ): + + # Traction API raises exception + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", + exc=RequestException("Connection refused"), + ) + + # Wallet fallback succeeds + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + json={"token": "fallback-token"}, + status_code=200, + ) + + acapy = TractionTenantAcapy() + acapy.get_wallet_token.cache_clear() + + token = acapy.get_wallet_token() + assert token == "fallback-token" + + +@pytest.mark.asyncio +async def test_traction_tenant_wallet_fallback_exception(requests_mock): + """Test exception handling when wallet fallback also raises an exception.""" + wallet_id = "test-wallet-id" + wallet_key = "test-wallet-key" + + # Only set wallet vars to go straight to fallback logic + with mock.patch.object(settings, "TRACTION_TENANT_ID", None), mock.patch.object( + settings, "TRACTION_TENANT_API_KEY", None + ), mock.patch.object(settings, "MT_ACAPY_WALLET_ID", wallet_id), mock.patch.object( + settings, "MT_ACAPY_WALLET_KEY", wallet_key + ): + + # Wallet API raises exception + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + exc=RequestException("Wallet DB down"), + ) + + acapy = TractionTenantAcapy() + acapy.get_wallet_token.cache_clear() + + with pytest.raises(RequestException): + acapy.get_wallet_token() + + +@pytest.mark.asyncio +async def test_traction_tenant_no_credentials_configured(): + """Test error when no credentials are provided at all.""" + with mock.patch.object(settings, "TRACTION_TENANT_ID", None), mock.patch.object( + settings, "TRACTION_TENANT_API_KEY", None + ), mock.patch.object(settings, "MT_ACAPY_WALLET_ID", None), mock.patch.object( + settings, "MT_ACAPY_WALLET_KEY", None + ): + + acapy = TractionTenantAcapy() + acapy.get_wallet_token.cache_clear() + + with pytest.raises(Exception) as exc: + acapy.get_wallet_token() + assert "Could not acquire token" in str(exc.value) diff --git a/oidc-controller/api/tests/test_webhook_registration.py b/oidc-controller/api/tests/test_webhook_registration.py index 6dc59cb0..50a359dc 100644 --- a/oidc-controller/api/tests/test_webhook_registration.py +++ b/oidc-controller/api/tests/test_webhook_registration.py @@ -5,6 +5,7 @@ from api.core.webhook_utils import register_tenant_webhook from api.main import on_tenant_startup from api.core.config import settings +from api.core.webhook_utils import _register_via_tenant_api @pytest.fixture @@ -353,23 +354,6 @@ async def test_webhook_registration_unexpected_exception(mock_requests_put, mock mock_sleep.assert_not_called() -@pytest.mark.asyncio -async def test_startup_multi_tenant_registers_webhook(mock_settings, mock_requests_put): - """Test startup logic in multi-tenant mode calls registration.""" - mock_settings.ACAPY_TENANCY = "multi" - mock_settings.USE_REDIS_ADAPTER = False - - with patch("api.main.init_db", new_callable=AsyncMock), patch( - "api.main.init_provider", new_callable=AsyncMock - ), patch("api.main.get_db", new_callable=AsyncMock), patch( - "api.main.register_tenant_webhook", new_callable=AsyncMock - ) as mock_register: - - await on_tenant_startup() - - assert mock_register.called - - @pytest.mark.asyncio async def test_startup_redis_check_success(mock_settings): """Test startup logic verifies Redis connection if adapter enabled.""" @@ -412,3 +396,86 @@ async def test_startup_redis_check_failure(mock_settings): # Should log error but continue startup mock_handler.assert_called_once() + + +@pytest.mark.asyncio +async def test_webhook_registration_missing_webhook_url(mock_requests_put): + """Test early exit when webhook_url is missing.""" + await register_tenant_webhook( + wallet_id="test", + webhook_url="", # Empty + admin_url="http://acapy", + api_key=None, + admin_api_key=None, + admin_api_key_name=None, + ) + mock_requests_put.assert_not_called() + + +@pytest.mark.asyncio +async def test_webhook_registration_traction_mode_missing_fetcher(mock_requests_put): + """Test early exit in Traction mode if no token_fetcher is provided.""" + await register_tenant_webhook( + wallet_id="ignored", + webhook_url="http://controller", + admin_url="http://acapy", + api_key=None, + admin_api_key=None, + admin_api_key_name=None, + token_fetcher=None, # Missing + use_admin_api=False, + ) + mock_requests_put.assert_not_called() + + +@pytest.mark.asyncio +async def test_register_via_tenant_api_server_error(mock_requests_put): + """Test _register_via_tenant_api handling 500 errors.""" + mock_requests_put.return_value.status_code = 500 + mock_requests_put.return_value.text = "Internal Error" + + fetcher = MagicMock(return_value="token") + + result = await _register_via_tenant_api("http://acapy", {}, fetcher) + assert result is False + + +@pytest.mark.asyncio +async def test_register_via_tenant_api_client_error(mock_requests_put): + """Test _register_via_tenant_api handling 400 errors.""" + mock_requests_put.return_value.status_code = 400 + mock_requests_put.return_value.text = "Bad Request" + + fetcher = MagicMock(return_value="token") + + result = await _register_via_tenant_api("http://acapy", {}, fetcher) + assert result is False + + +@pytest.mark.asyncio +async def test_register_via_tenant_api_exception(mock_requests_put): + """Test _register_via_tenant_api handling exceptions.""" + mock_requests_put.side_effect = Exception("Network Down") + + fetcher = MagicMock(return_value="token") + + result = await _register_via_tenant_api("http://acapy", {}, fetcher) + assert result is False + + +@pytest.mark.asyncio +async def test_webhook_registration_unexpected_status_code(mock_requests_put): + """Test handling of unexpected status codes (e.g. 418).""" + mock_requests_put.return_value.status_code = 418 # I'm a teapot + + await register_tenant_webhook( + wallet_id="test-wallet", + webhook_url="http://controller", + admin_url="http://acapy", + api_key=None, + admin_api_key=None, + admin_api_key_name=None, + use_admin_api=True, + ) + # Should log warning and exit loop (not retry) + assert mock_requests_put.call_count == 1 From 7ae2f218eaebcecbf44b455539f16fb8ce996e0d Mon Sep 17 00:00:00 2001 From: Yuki I Date: Sat, 29 Nov 2025 23:49:21 +0000 Subject: [PATCH 05/13] test: fix traction config test failures by setting instance attributes Signed-off-by: Yuki I --- .../api/core/acapy/tests/test_config.py | 91 +++++++++---------- 1 file changed, 42 insertions(+), 49 deletions(-) diff --git a/oidc-controller/api/core/acapy/tests/test_config.py b/oidc-controller/api/core/acapy/tests/test_config.py index bf31a8d1..e9334682 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -330,34 +330,29 @@ async def test_traction_tenant_api_connection_error_triggers_fallback(requests_m wallet_id = "test-wallet-id" wallet_key = "test-wallet-key" - with mock.patch.object( - settings, "TRACTION_TENANT_ID", tenant_id - ), mock.patch.object( - settings, "TRACTION_TENANT_API_KEY", api_key - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_ID", wallet_id - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", wallet_key - ): + # Traction API raises exception + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", + exc=RequestException("Connection refused"), + ) - # Traction API raises exception - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", - exc=RequestException("Connection refused"), - ) + # Wallet fallback succeeds + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + json={"token": "fallback-token"}, + status_code=200, + ) - # Wallet fallback succeeds - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - json={"token": "fallback-token"}, - status_code=200, - ) + acapy = TractionTenantAcapy() + acapy.tenant_id = tenant_id + acapy.tenant_api_key = api_key + acapy.wallet_id = wallet_id + acapy.wallet_key = wallet_key - acapy = TractionTenantAcapy() - acapy.get_wallet_token.cache_clear() + acapy.get_wallet_token.cache_clear() - token = acapy.get_wallet_token() - assert token == "fallback-token" + token = acapy.get_wallet_token() + assert token == "fallback-token" @pytest.mark.asyncio @@ -366,38 +361,36 @@ async def test_traction_tenant_wallet_fallback_exception(requests_mock): wallet_id = "test-wallet-id" wallet_key = "test-wallet-key" - # Only set wallet vars to go straight to fallback logic - with mock.patch.object(settings, "TRACTION_TENANT_ID", None), mock.patch.object( - settings, "TRACTION_TENANT_API_KEY", None - ), mock.patch.object(settings, "MT_ACAPY_WALLET_ID", wallet_id), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", wallet_key - ): + # Wallet API raises exception + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + exc=RequestException("Wallet DB down"), + ) - # Wallet API raises exception - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - exc=RequestException("Wallet DB down"), - ) + acapy = TractionTenantAcapy() + acapy.tenant_id = None + acapy.tenant_api_key = None + acapy.wallet_id = wallet_id + acapy.wallet_key = wallet_key - acapy = TractionTenantAcapy() - acapy.get_wallet_token.cache_clear() + acapy.get_wallet_token.cache_clear() - with pytest.raises(RequestException): - acapy.get_wallet_token() + with pytest.raises(RequestException): + acapy.get_wallet_token() @pytest.mark.asyncio async def test_traction_tenant_no_credentials_configured(): """Test error when no credentials are provided at all.""" - with mock.patch.object(settings, "TRACTION_TENANT_ID", None), mock.patch.object( - settings, "TRACTION_TENANT_API_KEY", None - ), mock.patch.object(settings, "MT_ACAPY_WALLET_ID", None), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", None - ): - acapy = TractionTenantAcapy() - acapy.get_wallet_token.cache_clear() + acapy = TractionTenantAcapy() + acapy.tenant_id = None + acapy.tenant_api_key = None + acapy.wallet_id = None + acapy.wallet_key = None - with pytest.raises(Exception) as exc: - acapy.get_wallet_token() - assert "Could not acquire token" in str(exc.value) + acapy.get_wallet_token.cache_clear() + + with pytest.raises(Exception) as exc: + acapy.get_wallet_token() + assert "Could not acquire token" in str(exc.value) From ecf72c142283e8bd6ad49ba262d1fb2e93945fb9 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Tue, 2 Dec 2025 17:43:33 +0000 Subject: [PATCH 06/13] feat: unify multi-tenant config and add traction mode support Signed-off-by: Yuki I --- docker/.env.example | 132 +++++++ docker/docker-compose.yaml | 6 +- docker/manage | 7 +- docs/ConfigurationGuide.md | 46 ++- docs/MigrationGuide.md | 23 +- oidc-controller/api/core/acapy/config.py | 107 +++--- .../api/core/acapy/tests/test_config.py | 349 +++++------------- oidc-controller/api/core/config.py | 15 +- oidc-controller/api/main.py | 6 +- 9 files changed, 348 insertions(+), 343 deletions(-) create mode 100644 docker/.env.example diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..cbd12e17 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,132 @@ +############################################ +# Controller Database (Mongo) +############################################ +MONGODB_HOST=controller-db +MONGODB_PORT=27017 +MONGODB_NAME=oidccontroller +OIDC_CONTROLLER_DB_USER=changeme +OIDC_CONTROLLER_DB_USER_PWD=changeme + + +############################################ +# Controller Core Settings +############################################ +DEBUGGER=false +LOG_LEVEL=DEBUG +CONTROLLER_SERVICE_PORT=5000 + +# Public URLs +CONTROLLER_URL=https://your-public-url.example.com +CONTROLLER_WEB_HOOK_URL=https://your-public-url.example.com/webhooks +CONTROLLER_API_KEY= + +# Session & presentation settings +CONTROLLER_CAMERA_REDIRECT_URL=wallet_howto +CONTROLLER_PRESENTATION_EXPIRE_TIME=300 +CONTROLLER_PRESENTATION_CLEANUP_TIME=86400 +CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE=/etc/controller-config/sessiontimeout.json +CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE=/etc/controller-config/user_variable_substitution.py + +# Templates and cleanup limits +CONTROLLER_TEMPLATE_DIR=/app/controller-config/templates +CONTROLLER_PRESENTATION_RECORD_RETENTION_HOURS=1 +CONTROLLER_CLEANUP_MAX_PRESENTATION_RECORDS=1000 +CONTROLLER_CLEANUP_MAX_CONNECTIONS=2000 + +INVITATION_LABEL="VC-AuthN" +SET_NON_REVOKED=true +ACAPY_PROOF_FORMAT=anoncreds + + +############################################ +# Controller Feature Flags +############################################ +USE_REDIS_ADAPTER=false +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 + +USE_CONNECTION_BASED_VERIFICATION=true +USE_OOB_PRESENT_PROOF=true +USE_OOB_LOCAL_DID_SERVICE=true +USE_URL_DEEP_LINK=false +WALLET_DEEP_LINK_PREFIX=bcwallet://aries_proof-request + + +############################################ +# ACA-Py Agent +############################################ +AGENT_TENANT_MODE=traction +AGENT_HOST=localhost +AGENT_NAME="VC-AuthN Agent" + +AGENT_HTTP_PORT=8030 +AGENT_ADMIN_PORT=8077 + +# Traction / ACA-Py admin endpoints +AGENT_ADMIN_URL=https://traction-admin.example.com +AGENT_ENDPOINT=https://traction-acapy-endpoint.example.com + +AGENT_ADMIN_API_KEY=changeme +AGENT_GENESIS_URL=https://test.bcovrin.vonx.io/genesis +AGENT_WALLET_SEED=your-32-char-seed-here-00000000000000 + + +############################################ +# ACA-Py Wallet / Tenant Identity +# +# When AGENT_TENANT_MODE=multi: +# ACAPY_TENANT_WALLET_ID = Wallet ID +# ACAPY_TENANT_WALLET_KEY = Wallet Key +# +# When AGENT_TENANT_MODE=traction: +# ACAPY_TENANT_WALLET_ID = Traction Tenant ID +# ACAPY_TENANT_WALLET_KEY = Traction Tenant API Key +############################################ +ACAPY_TENANT_WALLET_ID=your-tenant-id-here +ACAPY_TENANT_WALLET_KEY=your-tenant-key-here + +# Legacy (ignored when ACAPY_TENANT_* is set) +MT_ACAPY_WALLET_ID=legacy-wallet-id +MT_ACAPY_WALLET_KEY=legacy-wallet-key + + +############################################ +# ACA-Py Single-Tenant Settings (Unused) +############################################ +ST_ACAPY_ADMIN_API_KEY_NAME= +ST_ACAPY_ADMIN_API_KEY= + + +############################################ +# OIDC Client +############################################ +OIDC_CLIENT_ID=your-client-id +OIDC_CLIENT_NAME="Your Application Name" +OIDC_CLIENT_REDIRECT_URI=https://your-redirect-url.example.com +OIDC_CLIENT_SECRET=your-client-secret + + +############################################ +# Keycloak Database +############################################ +KEYCLOAK_DB_NAME=keycloak +KEYCLOAK_DB_USER=keycloak +KEYCLOAK_DB_PASSWORD=changeme + + +############################################ +# Keycloak Service +############################################ +KEYCLOAK_DB_VENDOR=postgres +KEYCLOAK_DB_ADDR=keycloak-db +KEYCLOAK_USER=admin +KEYCLOAK_PASSWORD=admin +KEYCLOAK_LOGLEVEL=WARN +KEYCLOAK_ROOT_LOGLEVEL=WARN + + +############################################ +# Logging +############################################ +LOG_WITH_JSON=false diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cf39908c..dd422a57 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -49,10 +49,12 @@ services: - ACAPY_TENANCY=${AGENT_TENANT_MODE} - ACAPY_AGENT_URL=${AGENT_ENDPOINT} - ACAPY_ADMIN_URL=${AGENT_ADMIN_URL} + # Unified Tenant / Wallet Configuration + - ACAPY_TENANT_WALLET_ID=${ACAPY_TENANT_WALLET_ID} + - ACAPY_TENANT_WALLET_KEY=${ACAPY_TENANT_WALLET_KEY} + # Legacy variables (passed for backward compatibility) - MT_ACAPY_WALLET_ID=${MT_ACAPY_WALLET_ID} - MT_ACAPY_WALLET_KEY=${MT_ACAPY_WALLET_KEY} - - TRACTION_TENANT_ID=${TRACTION_TENANT_ID} - - TRACTION_TENANT_API_KEY=${TRACTION_TENANT_API_KEY} - ST_ACAPY_ADMIN_API_KEY=${AGENT_ADMIN_API_KEY} - ST_ACAPY_ADMIN_API_KEY_NAME=${ST_ACAPY_ADMIN_API_KEY_NAME} - USE_OOB_LOCAL_DID_SERVICE=${USE_OOB_LOCAL_DID_SERVICE} diff --git a/docker/manage b/docker/manage index e3cc737f..31ae9ac7 100755 --- a/docker/manage +++ b/docker/manage @@ -248,12 +248,13 @@ configureEnvironment() { AGENT_ADMIN_MODE="admin-api-key ${AGENT_ADMIN_API_KEY}" fi export AGENT_WALLET_SEED=${AGENT_WALLET_SEED} + # Unified Tenant / Wallet Config + export ACAPY_TENANT_WALLET_ID=${ACAPY_TENANT_WALLET_ID} + export ACAPY_TENANT_WALLET_KEY=${ACAPY_TENANT_WALLET_KEY} + # Legacy Config export MT_ACAPY_WALLET_ID=${MT_ACAPY_WALLET_ID} export MT_ACAPY_WALLET_KEY=${MT_ACAPY_WALLET_KEY} - export TRACTION_TENANT_ID=${TRACTION_TENANT_ID} - export TRACTION_TENANT_API_KEY=${TRACTION_TENANT_API_KEY} - # keycloak-db export KEYCLOAK_DB_NAME="keycloak" export KEYCLOAK_DB_USER="keycloak" diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 131cb935..0acf8303 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -77,9 +77,9 @@ Several functions in ACAPy VC-AuthN can be tweaked by using the following enviro | Variable | Type | What it does | NOTES | | ------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | ACAPY_PROOF_FORMAT | string ("indy" or "anoncreds") | Sets the proof format for ACA-Py presentation requests. Use `anoncreds` for modern AnonCreds support or `indy` for legacy Indy-based ecosystems. | Defaults to `indy` for backward compatibility with existing deployments. | -| ACAPY_TENANCY | string ("single", "multi", "traction") | Determines how the controller authenticates with the ACA-Py agent.
- `single`: No special auth headers (or just admin key).
- `multi`: Uses `MT_ACAPY_WALLET_ID` and Admin API to fetch token.
- `traction`: Uses Traction-specific authentication and Tenant APIs (bypassing Admin endpoints). | Defaults to `single`. Use `traction` when integrating with a secured Traction tenant where Admin APIs are blocked. | -| TRACTION_TENANT_ID | string | **Required for Traction Mode.** The Tenant ID assigned by Traction. | Used to acquire authentication token from `/multitenancy/tenant/{id}/token`. | -| TRACTION_TENANT_API_KEY | string | **Required for Traction Mode.** The Tenant API Key provided by Traction. | Used as the payload `{ "api_key": "..." }` when fetching the token. | +| AGENT_TENANT_MODE | string ("single", "multi", "traction") | **single**: Standard single-tenant agent.
**multi**: Standard ACA-Py Multi-tenancy (authenticates via Admin API using Wallet ID/Key).
**traction**: Traction Multi-tenancy (authenticates via Tenant API using Tenant ID/API Key). | +| ACAPY_TENANT_WALLET_ID | string | **If Mode=multi**: The `wallet_id` of the sub-wallet.
**If Mode=traction**: The `tenant_id` provided by Traction. | +| ACAPY_TENANT_WALLET_KEY | string | **If Mode=multi**: The `wallet_key` used to unlock the sub-wallet.
**If Mode=traction**: The Tenant `api_key` provided by Traction. | | CONTROLLER_WEB_HOOK_URL | string (URL) | **Required for Multi-Tenant Mode.** The public or internal URL where the ACA-Py Agent can reach this Controller to send webhooks (e.g. `http://controller:5000/webhooks`). | The controller will automatically register this URL with the specific tenant wallet on startup. | | SET_NON_REVOKED | bool | if True, the `non_revoked` attributed will be added to each of the present-proof request `requested_attribute` and `requested_predicate` with 'from=0' and'to=`int(time.time())` | | | USE_OOB_LOCAL_DID_SERVICE | bool | Instructs ACAPy VC-AuthN to use a local DID, it must be used when the agent service is not registered on the ledger with a public DID | Use this when `ACAPY_WALLET_LOCAL_DID` is set to `true` in the agent. | @@ -88,35 +88,57 @@ Several functions in ACAPy VC-AuthN can be tweaked by using the following enviro | LOG_TIMESTAMP_FORMAT | string | determines the timestamp formatting used in logs | Default is "iso" | | LOG_LEVEL | "DEBUG", "INFO", "WARNING", or "ERROR" | sets the minimum log level that will be printed to standard out | Defaults to DEBUG | +### Legacy Variable Support + +For backward compatibility with version 2.x deployments, the legacy `MT_` variables are still supported in `multi` mode. However, `ACAPY_TENANT_` variables take precedence if both are set. + +| Legacy Variable | Mapped to | Status | +| -------------------- | ------------------------ | ---------- | +| MT_ACAPY_WALLET_ID | ACAPY_TENANT_WALLET_ID | Deprecated | +| MT_ACAPY_WALLET_KEY | ACAPY_TENANT_WALLET_KEY | Deprecated | + +### Deployment Examples + +**Standard Multi-Tenant (ACA-Py):** +```yaml +environment: + - AGENT_TENANT_MODE=multi + - ACAPY_TENANT_WALLET_ID=3ffd2cf2-5fee-434a-859b-c84f1677e647 + - ACAPY_TENANT_WALLET_KEY=my-secure-wallet-key + - CONTROLLER_WEB_HOOK_URL=http://controller:5000/webhooks +``` + ### Traction Tenancy Mode -The `traction` mode (`ACAPY_TENANCY="traction"`) is designed specifically for environments where the ACA-Py Admin API endpoints (e.g., `/multitenancy/wallet/...`) are restricted or blocked for security reasons. +The `traction` mode (`AGENT_TENANT_MODE="traction"`) is designed specifically for environments where the ACA-Py Admin API endpoints (e.g., `/multitenancy/wallet/...`) are restricted or blocked for security reasons, which is true of many `traction` environments. In this mode: -1. **Authentication:** The controller authenticates directly as the tenant using `TRACTION_TENANT_ID` and `TRACTION_TENANT_API_KEY` to obtain a Bearer token. -2. **Fallback:** If Traction keys are not provided, it falls back to trying to authenticate using the `MT_ACAPY_WALLET_ID` and `MT_ACAPY_WALLET_KEY` via the wallet token endpoint (though this may fail if that endpoint is also blocked). +1. **Authentication:** The controller authenticates directly as the tenant using `ACAPY_TENANT_WALLET_ID` (as the Tenant ID) and `ACAPY_TENANT_WALLET_KEY` (as the Tenant API Key) to obtain a Bearer token. +2. **Fallback:** If these keys are not provided, it falls back to trying to authenticate using the legacy `MT_ACAPY_WALLET_ID` and `MT_ACAPY_WALLET_KEY` via the wallet token endpoint (though this may fail if that endpoint is also blocked). 3. **Webhook Registration:** The controller bypasses the Admin API and uses the authenticated Tenant API (`PUT /tenant/wallet`) to register the `CONTROLLER_WEB_HOOK_URL`. **Example Docker Configuration for Traction:** ```yaml environment: - - ACAPY_TENANCY=traction - - TRACTION_TENANT_ID=00000000-0000-0000-0000-000000000000 - - TRACTION_TENANT_API_KEY=your-traction-api-key + - AGENT_TENANT_MODE=traction + - ACAPY_TENANT_WALLET_ID=00000000-0000-0000-0000-000000000000 + - ACAPY_TENANT_WALLET_KEY=your-traction-api-key - CONTROLLER_WEB_HOOK_URL=https://your-controller-url.com/webhooks - ACAPY_ADMIN_URL=https://traction-tenant-proxy-url ``` -### Multi-Tenant Webhook Registration +### Multi-Tenant and Traction Webhook Registration -When running in Multi-Tenant mode (`ACAPY_TENANCY="multi"`), the ACA-Py Agent may not automatically know where to send verification success notifications for specific tenant wallets. +When running in Multi-Tenant (`AGENT_TENANT_MODE="multi"`) or Traction (`AGENT_TENANT_MODE="traction"`) modes, the ACA-Py Agent may not automatically know where to send verification success notifications for specific tenant wallets. To address this, the VC-AuthN Controller performs an automatic handshake on startup: 1. It checks if `CONTROLLER_WEB_HOOK_URL` is defined. 2. If the Controller is protected by `CONTROLLER_API_KEY`, it appends the key to the URL using the `#` syntax. This instructs ACA-Py to parse the hash and send it as an `x-api-key` header in the webhook request. -3. It attempts to connect to the ACA-Py Admin API using the `ST_ACAPY_ADMIN_API_KEY` to update the specific Tenant Wallet configuration. +3. It attempts to connect to the ACA-Py Agent to update the specific Tenant Wallet configuration. + - **Multi Mode:** Uses the Admin API (`/multitenancy/wallet/{id}`) authenticated with `ST_ACAPY_ADMIN_API_KEY`. + - **Traction Mode:** Uses the Tenant API (`/tenant/wallet`) authenticated with the Tenant Token acquired via `ACAPY_TENANT_WALLET_KEY`. 4. If the Agent is not yet ready, the Controller will retry the registration up to 5 times before logging an error. This ensures that even in complex Docker/Kubernetes network topologies, the Agent sends verification signals to the correct Controller instance. diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index 5f2dbd84..4e949471 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -16,8 +16,21 @@ The functionality has mostly remained unchanged, however there are some details ## For versions after 2.3.2 -### Multi-Tenant Webhook Configuration -If you are running in Multi-Tenant mode (`ACAPY_TENANCY="multi"`), you **must** now define the `CONTROLLER_WEB_HOOK_URL` environment variable. +### Configuration Unification (Multi-Tenant & Traction) + +To simplify configuration and support new integration patterns, the environment variables for multi-tenant identification have been unified. + +**New Variables:** +* `ACAPY_TENANT_WALLET_ID`: Replaces `MT_ACAPY_WALLET_ID`. +* `ACAPY_TENANT_WALLET_KEY`: Replaces `MT_ACAPY_WALLET_KEY`. + +**Impact on Existing Deployments:** +* **No Action Required:** Existing deployments using `MT_ACAPY_WALLET_ID` and `MT_ACAPY_WALLET_KEY` will continue to work. The system automatically falls back to these variables if the new ones are not present. +* **Recommended Action:** We recommend updating your `docker-compose` or Kubernetes configuration to use the new `ACAPY_TENANT_` variables to ensure future compatibility. + +### Multi-Tenant and Traction Webhook Configuration + +If you are running in Multi-Tenant (`AGENT_TENANT_MODE="multi"`) or Traction (`AGENT_TENANT_MODE="traction"`) mode, you **must** now define the `CONTROLLER_WEB_HOOK_URL` environment variable. * **Why:** The controller now explicitly registers this URL with the specific ACA-Py tenant wallet on startup. This fixes issues where OIDC authentication flows would hang because the agent sent verifications to the wrong location or failed authentication. * **Action Required:** Update your `docker-compose` or Kubernetes config: @@ -30,6 +43,6 @@ If you are running in Multi-Tenant mode (`ACAPY_TENANCY="multi"`), you **must** A new mode has been added for integrating with Traction (or secured multi-tenant agents where Admin APIs are blocked). -* **Setting:** `ACAPY_TENANCY="traction"` -* **Requirements:** Requires `TRACTION_TENANT_ID` and `TRACTION_TENANT_API_KEY`. -* **Behavior:** Authenticates directly with the Tenant API and bypasses `multitenancy/wallet` Admin endpoints. \ No newline at end of file +* **Setting:** `AGENT_TENANT_MODE="traction"` +* **Requirements:** Requires `ACAPY_TENANT_WALLET_ID` (as the Traction Tenant ID) and `ACAPY_TENANT_WALLET_KEY` (as the Traction Tenant API Key). +* **Behavior:** Authenticates directly with the Tenant API (`/multitenancy/tenant/{id}/token`) using the provided API Key and bypasses `multitenancy/wallet` Admin endpoints used in standard multi-tenant mode. diff --git a/oidc-controller/api/core/acapy/config.py b/oidc-controller/api/core/acapy/config.py index 100a6b8a..dad26730 100644 --- a/oidc-controller/api/core/acapy/config.py +++ b/oidc-controller/api/core/acapy/config.py @@ -15,12 +15,15 @@ def get_headers() -> dict[str, str]: ... class MultiTenantAcapy: - wallet_id = settings.MT_ACAPY_WALLET_ID - wallet_key = settings.MT_ACAPY_WALLET_KEY + wallet_id = settings.ACAPY_TENANT_WALLET_ID + wallet_key = settings.ACAPY_TENANT_WALLET_KEY @cache def get_wallet_token(self): - logger.debug(">>> get_wallet_token") + logger.debug(">>> get_wallet_token (Multi-Tenant Mode)") + + if not self.wallet_id: + raise ValueError("ACAPY_TENANT_WALLET_ID is required for multi-tenant mode") # Check if admin API key is configured admin_api_key_configured = ( @@ -68,80 +71,54 @@ def get_headers(self) -> dict[str, str]: class TractionTenantAcapy: """ Configuration for Traction Multi-Tenancy. - Attempts to fetch a token using Traction Tenant API credentials first. - Falls back to ACA-Py Wallet Key authentication if Traction credentials are not set. + Uses unified ACAPY_TENANT_WALLET_* variables mapped to Traction Tenant ID and API Key. """ - tenant_id = settings.TRACTION_TENANT_ID - tenant_api_key = settings.TRACTION_TENANT_API_KEY - wallet_id = settings.MT_ACAPY_WALLET_ID - wallet_key = settings.MT_ACAPY_WALLET_KEY + # Map unified variables to Traction concepts + tenant_id = settings.ACAPY_TENANT_WALLET_ID + api_key = settings.ACAPY_TENANT_WALLET_KEY @cache def get_wallet_token(self): logger.debug(">>> get_wallet_token (Traction Mode)") - # Try Traction API Key Flow - if self.tenant_id and self.tenant_api_key: - logger.debug( - "Attempting Traction Token acquisition via tenant_id/api_key", - tenant_id=self.tenant_id, + if not self.tenant_id or not self.api_key: + error_msg = ( + "Traction mode requires ACAPY_TENANT_WALLET_ID (Tenant ID) " + "and ACAPY_TENANT_WALLET_KEY (API Key) to be set." ) - try: - payload = {"api_key": self.tenant_api_key} - resp_raw = requests.post( - settings.ACAPY_ADMIN_URL - + f"/multitenancy/tenant/{self.tenant_id}/token", - json=payload, - ) + logger.error(error_msg) + raise ValueError(error_msg) - if resp_raw.status_code == 200: - resp = json.loads(resp_raw.content) - logger.debug("<<< get_wallet_token (Success via Traction API)") - return resp["token"] - else: - logger.warning( - "Traction API Token fetch failed", - status=resp_raw.status_code, - detail=resp_raw.content.decode(), - ) - except Exception as e: - logger.error("Error connecting to Traction Tenant API", error=str(e)) - - # Fallback to Standard ACA-Py Wallet Key Flow - if self.wallet_id and self.wallet_key: - logger.debug( - "Attempting Wallet Token acquisition via wallet_id/wallet_key", - wallet_id=self.wallet_id, + logger.debug( + "Attempting Traction Token acquisition via tenant_id/api_key", + tenant_id=self.tenant_id, + ) + + try: + payload = {"api_key": self.api_key} + resp_raw = requests.post( + settings.ACAPY_ADMIN_URL + + f"/multitenancy/tenant/{self.tenant_id}/token", + json=payload, ) - try: - # No Admin Headers in Traction mode as we assume Admin API is blocked - payload = {"wallet_key": self.wallet_key} - resp_raw = requests.post( - settings.ACAPY_ADMIN_URL - + f"/multitenancy/wallet/{self.wallet_id}/token", - json=payload, + + if resp_raw.status_code == 200: + resp = json.loads(resp_raw.content) + logger.debug("<<< get_wallet_token (Success via Traction API)") + return resp["token"] + else: + error_detail = resp_raw.content.decode() + logger.error( + "Traction API Token fetch failed", + status=resp_raw.status_code, + detail=error_detail, ) + raise Exception(f"{resp_raw.status_code}::{error_detail}") - if resp_raw.status_code == 200: - resp = json.loads(resp_raw.content) - logger.debug("<<< get_wallet_token (Success via Wallet Key)") - return resp["token"] - else: - error_detail = resp_raw.content.decode() - logger.error( - f"Failed to get wallet token via wallet key fallback. Status: {resp_raw.status_code}, Detail: {error_detail}" - ) - raise Exception(f"{resp_raw.status_code}::{error_detail}") - - except Exception as e: - logger.error("Error fetching token via wallet key", error=str(e)) - raise e - - # No valid credentials found - error_msg = "Could not acquire token. Ensure TRACTION_TENANT_ID/API_KEY or MT_ACAPY_WALLET_ID/KEY are set." - logger.error(error_msg) - raise Exception(error_msg) + except Exception as e: + logger.error("Error connecting to Traction Tenant API", error=str(e)) + raise e def get_headers(self) -> dict[str, str]: return {"Authorization": "Bearer " + self.get_wallet_token()} diff --git a/oidc-controller/api/core/acapy/tests/test_config.py b/oidc-controller/api/core/acapy/tests/test_config.py index e9334682..f59ea5ae 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -1,13 +1,17 @@ import mock import pytest -import json +from requests.exceptions import RequestException from api.core.acapy.config import ( MultiTenantAcapy, SingleTenantAcapy, TractionTenantAcapy, ) from api.core.config import settings -from requests.exceptions import RequestException + + +# ========================================== +# Single Tenant Tests +# ========================================== @pytest.mark.asyncio @@ -23,28 +27,37 @@ async def test_single_tenant_has_expected_headers_configured(): @mock.patch.object(settings, "ST_ACAPY_ADMIN_API_KEY_NAME", "name") @mock.patch.object(settings, "ST_ACAPY_ADMIN_API_KEY", None) async def test_single_tenant_empty_headers_not_configured(): - # Test behavior when API key is missing acapy = SingleTenantAcapy() headers = acapy.get_headers() assert headers == {} +# ========================================== +# Multi-Tenant Tests (Unified Config) +# ========================================== + + @pytest.mark.asyncio -async def test_multi_tenant_get_headers_returns_bearer_token_auth(requests_mock): +async def test_multi_tenant_get_headers_returns_bearer_token_auth(): + """Test that get_headers calls get_wallet_token and formats Bearer string.""" acapy = MultiTenantAcapy() + # Mock the internal method to isolate header logic acapy.get_wallet_token = mock.MagicMock(return_value="token") + headers = acapy.get_headers() assert headers == {"Authorization": "Bearer token"} @pytest.mark.asyncio -async def test_multi_tenant_get_wallet_token_returns_token_at_token_key(requests_mock): - wallet_id = "wallet_id" - wallet_key = "wallet_key" - - with mock.patch.object( - settings, "MT_ACAPY_WALLET_ID", wallet_id - ), mock.patch.object(settings, "MT_ACAPY_WALLET_KEY", wallet_key): +async def test_multi_tenant_uses_unified_variables(requests_mock): + """Test MultiTenantAcapy uses the unified ACAPY_TENANT_WALLET_* vars.""" + wallet_id = "unified-wallet-id" + wallet_key = "unified-wallet-key" + + # Patch class attributes directly because they are bound at module import time + with mock.patch.object(MultiTenantAcapy, "wallet_id", wallet_id), mock.patch.object( + MultiTenantAcapy, "wallet_key", wallet_key + ): requests_mock.post( settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", @@ -54,79 +67,44 @@ async def test_multi_tenant_get_wallet_token_returns_token_at_token_key(requests ) acapy = MultiTenantAcapy() - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key acapy.get_wallet_token.cache_clear() token = acapy.get_wallet_token() assert token == "token" + # Verify it sent the wallet_key in the body + assert requests_mock.last_request.json() == {"wallet_key": wallet_key} -@pytest.mark.asyncio -async def test_multi_tenant_get_wallet_token_includes_auth_headers_and_body( - requests_mock, -): - # Verify headers and body payload - wallet_id = "wallet_id" - wallet_key = "wallet_key" - admin_key = "admin_key" - admin_header = "x-api-key" - - # Mock settings for the duration of this test - with mock.patch.object( - settings, "MT_ACAPY_WALLET_ID", wallet_id - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", wallet_key - ), mock.patch.object( - settings, "ST_ACAPY_ADMIN_API_KEY", admin_key - ), mock.patch.object( - settings, "ST_ACAPY_ADMIN_API_KEY_NAME", admin_header - ): +@pytest.mark.asyncio +async def test_multi_tenant_missing_id_raises_error(): + """Test error validation if ACAPY_TENANT_WALLET_ID is missing in multi-tenant mode.""" + with mock.patch.object(MultiTenantAcapy, "wallet_id", None): acapy = MultiTenantAcapy() - # Ensure we use the values we expect (class init reads settings once) - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key - # Ensure we bypass cache from any previous tests acapy.get_wallet_token.cache_clear() - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - json={"token": "token"}, - status_code=200, - ) - - token = acapy.get_wallet_token() - assert token == "token" - - # Verify request details - last_request = requests_mock.last_request - assert last_request.headers[admin_header] == admin_key - assert last_request.json() == {"wallet_key": wallet_key} + with pytest.raises(ValueError) as exc: + acapy.get_wallet_token() + assert "ACAPY_TENANT_WALLET_ID is required" in str(exc.value) @pytest.mark.asyncio -async def test_multi_tenant_get_wallet_token_no_auth_headers_when_not_configured( - requests_mock, -): - # Test insecure mode behavior - wallet_id = "wallet_id" - wallet_key = "wallet_key" - - # Mock settings with None for admin key - with mock.patch.object( - settings, "MT_ACAPY_WALLET_ID", wallet_id - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", wallet_key +async def test_multi_tenant_includes_admin_api_key_headers(requests_mock): + """Test that ST_ACAPY_ADMIN_API_KEY headers are included in the request if set.""" + wallet_id = "test-wallet-id" + wallet_key = "test-wallet-key" + admin_key = "admin_key" + admin_header = "x-api-key" + + with mock.patch.object(MultiTenantAcapy, "wallet_id", wallet_id), mock.patch.object( + MultiTenantAcapy, "wallet_key", wallet_key ), mock.patch.object( - settings, "ST_ACAPY_ADMIN_API_KEY", None + settings, "ST_ACAPY_ADMIN_API_KEY", admin_key ), mock.patch.object( - settings, "ST_ACAPY_ADMIN_API_KEY_NAME", "x-api-key" + settings, "ST_ACAPY_ADMIN_API_KEY_NAME", admin_header ): acapy = MultiTenantAcapy() - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key acapy.get_wallet_token.cache_clear() requests_mock.post( @@ -138,25 +116,22 @@ async def test_multi_tenant_get_wallet_token_no_auth_headers_when_not_configured token = acapy.get_wallet_token() assert token == "token" - # Verify request details + # Verify request headers included the admin key last_request = requests_mock.last_request - # Headers might contain Content-Type, but should not contain the api key - assert "x-api-key" not in last_request.headers - assert last_request.json() == {"wallet_key": wallet_key} + assert last_request.headers[admin_header] == admin_key @pytest.mark.asyncio -async def test_multi_tenant_throws_exception_for_401_unauthorized(requests_mock): - wallet_id = "wallet_id" - wallet_key = "wallet_key" +async def test_multi_tenant_throws_exception_for_401(requests_mock): + """Test error handling for 401 Unauthorized in multi-tenant mode.""" + wallet_id = "test-wallet-id" + wallet_key = "test-wallet-key" - with mock.patch.object( - settings, "MT_ACAPY_WALLET_ID", wallet_id - ), mock.patch.object(settings, "MT_ACAPY_WALLET_KEY", wallet_key): + with mock.patch.object(MultiTenantAcapy, "wallet_id", wallet_id), mock.patch.object( + MultiTenantAcapy, "wallet_key", wallet_key + ): acapy = MultiTenantAcapy() - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key acapy.get_wallet_token.cache_clear() requests_mock.post( @@ -165,25 +140,33 @@ async def test_multi_tenant_throws_exception_for_401_unauthorized(requests_mock) status_code=401, ) - # Check for generic Exception, as the code now raises Exception(f"{code}::{detail}") with pytest.raises(Exception) as excinfo: acapy.get_wallet_token() assert "401" in str(excinfo.value) - assert "unauthorized" in str(excinfo.value) -@pytest.mark.asyncio -async def test_traction_tenant_api_key_flow_success(requests_mock): - """Test Traction mode getting token using Tenant ID and API Key.""" - tenant_id = "test-tenant-id" - api_key = "test-api-key" +# ========================================== +# Traction Tenant Mode Tests (Unified Config) +# ========================================== + +@pytest.mark.asyncio +async def test_traction_mode_uses_unified_variables_as_tenant_creds(requests_mock): + """ + Test that in Traction mode: + ACAPY_TENANT_WALLET_ID -> Tenant ID + ACAPY_TENANT_WALLET_KEY -> Tenant API Key + """ + tenant_id = "unified-tenant-id" + api_key = "unified-api-key" + + # TractionTenantAcapy reads from settings at class level with mock.patch.object( - settings, "TRACTION_TENANT_ID", tenant_id - ), mock.patch.object(settings, "TRACTION_TENANT_API_KEY", api_key): + TractionTenantAcapy, "tenant_id", tenant_id + ), mock.patch.object(TractionTenantAcapy, "api_key", api_key): - # Mock the Traction token endpoint + # Verify calls /multitenancy/tenant/{id}/token (Traction API) requests_mock.post( settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", json={"token": "traction-token"}, @@ -191,206 +174,76 @@ async def test_traction_tenant_api_key_flow_success(requests_mock): ) acapy = TractionTenantAcapy() - acapy.tenant_id = tenant_id - acapy.tenant_api_key = api_key acapy.get_wallet_token.cache_clear() token = acapy.get_wallet_token() assert token == "traction-token" - # Verify request details + # Verify payload uses "api_key" (Traction style) instead of "wallet_key" last_request = requests_mock.last_request assert last_request.json() == {"api_key": api_key} @pytest.mark.asyncio -async def test_traction_tenant_fallback_to_wallet_key_success(requests_mock): - """Test Traction mode falling back to Wallet Key when Tenant API auth missing/fails.""" - wallet_id = "test-wallet-id" - wallet_key = "test-wallet-key" +async def test_traction_mode_missing_credentials_raises_error(): + """Test that missing credentials in Traction mode raises ValueError.""" - # Set TRACTION_ vars to None to trigger fallback immediately - with mock.patch.object(settings, "TRACTION_TENANT_ID", None), mock.patch.object( - settings, "TRACTION_TENANT_API_KEY", None - ), mock.patch.object(settings, "MT_ACAPY_WALLET_ID", wallet_id), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", wallet_key + with mock.patch.object(TractionTenantAcapy, "tenant_id", None), mock.patch.object( + TractionTenantAcapy, "api_key", None ): - # Mock the Wallet token endpoint (no admin header used in traction mode) - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - json={"token": "fallback-token"}, - status_code=200, - ) - acapy = TractionTenantAcapy() - acapy.tenant_id = None - acapy.tenant_api_key = None - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key acapy.get_wallet_token.cache_clear() - token = acapy.get_wallet_token() - assert token == "fallback-token" + with pytest.raises(ValueError) as exc: + acapy.get_wallet_token() + + # Verify specific error message for unified config + assert "Traction mode requires ACAPY_TENANT_WALLET_ID" in str(exc.value) @pytest.mark.asyncio -async def test_traction_tenant_api_auth_fails_then_fallback_succeeds(requests_mock): - """Test Traction mode tries Tenant API, fails, then succeeds with Wallet Key.""" - tenant_id = "test-tenant-id" - api_key = "test-api-key" - wallet_id = "test-wallet-id" - wallet_key = "test-wallet-key" +async def test_traction_mode_api_failure_raises_exception(requests_mock): + """Test error handling when Traction API returns non-200.""" + tenant_id = "test-tenant" + api_key = "test-key" with mock.patch.object( - settings, "TRACTION_TENANT_ID", tenant_id - ), mock.patch.object( - settings, "TRACTION_TENANT_API_KEY", api_key - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_ID", wallet_id - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", wallet_key - ): + TractionTenantAcapy, "tenant_id", tenant_id + ), mock.patch.object(TractionTenantAcapy, "api_key", api_key): - # Traction API call fails (e.g. 401/403 or server error) requests_mock.post( settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", - status_code=401, - ) - - # Fallback to Wallet Key succeeds - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - json={"token": "fallback-token"}, - status_code=200, + status_code=403, + text="Forbidden", ) acapy = TractionTenantAcapy() - acapy.tenant_id = tenant_id - acapy.tenant_api_key = api_key - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key acapy.get_wallet_token.cache_clear() - token = acapy.get_wallet_token() - assert token == "fallback-token" + with pytest.raises(Exception) as exc: + acapy.get_wallet_token() + + assert "403" in str(exc.value) @pytest.mark.asyncio -async def test_traction_tenant_all_auth_methods_fail(requests_mock): - """Test exception raised when all authentication methods fail in Traction mode.""" - tenant_id = "test-tenant-id" - api_key = "test-api-key" - wallet_id = "test-wallet-id" - wallet_key = "test-wallet-key" +async def test_traction_mode_connection_error_raises_exception(requests_mock): + """Test handling of network exceptions in Traction mode.""" + tenant_id = "test-tenant" + api_key = "test-key" with mock.patch.object( - settings, "TRACTION_TENANT_ID", tenant_id - ), mock.patch.object( - settings, "TRACTION_TENANT_API_KEY", api_key - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_ID", wallet_id - ), mock.patch.object( - settings, "MT_ACAPY_WALLET_KEY", wallet_key - ): + TractionTenantAcapy, "tenant_id", tenant_id + ), mock.patch.object(TractionTenantAcapy, "api_key", api_key): - # Traction API fails requests_mock.post( settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", - status_code=500, - ) - - # Wallet Key fallback fails - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - status_code=404, - content=b"Wallet not found", + exc=RequestException("Connection refused"), ) acapy = TractionTenantAcapy() - acapy.tenant_id = tenant_id - acapy.tenant_api_key = api_key - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key acapy.get_wallet_token.cache_clear() - with pytest.raises(Exception) as excinfo: + with pytest.raises(RequestException): acapy.get_wallet_token() - - # Verify the exception came from the final fallback failure - assert "404" in str(excinfo.value) - assert "Wallet not found" in str(excinfo.value) - - -@pytest.mark.asyncio -async def test_traction_tenant_api_connection_error_triggers_fallback(requests_mock): - """Test that a connection error to Traction API triggers the wallet fallback.""" - tenant_id = "test-tenant-id" - api_key = "test-api-key" - wallet_id = "test-wallet-id" - wallet_key = "test-wallet-key" - - # Traction API raises exception - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", - exc=RequestException("Connection refused"), - ) - - # Wallet fallback succeeds - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - json={"token": "fallback-token"}, - status_code=200, - ) - - acapy = TractionTenantAcapy() - acapy.tenant_id = tenant_id - acapy.tenant_api_key = api_key - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key - - acapy.get_wallet_token.cache_clear() - - token = acapy.get_wallet_token() - assert token == "fallback-token" - - -@pytest.mark.asyncio -async def test_traction_tenant_wallet_fallback_exception(requests_mock): - """Test exception handling when wallet fallback also raises an exception.""" - wallet_id = "test-wallet-id" - wallet_key = "test-wallet-key" - - # Wallet API raises exception - requests_mock.post( - settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", - exc=RequestException("Wallet DB down"), - ) - - acapy = TractionTenantAcapy() - acapy.tenant_id = None - acapy.tenant_api_key = None - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key - - acapy.get_wallet_token.cache_clear() - - with pytest.raises(RequestException): - acapy.get_wallet_token() - - -@pytest.mark.asyncio -async def test_traction_tenant_no_credentials_configured(): - """Test error when no credentials are provided at all.""" - - acapy = TractionTenantAcapy() - acapy.tenant_id = None - acapy.tenant_api_key = None - acapy.wallet_id = None - acapy.wallet_key = None - - acapy.get_wallet_token.cache_clear() - - with pytest.raises(Exception) as exc: - acapy.get_wallet_token() - assert "Could not acquire token" in str(exc.value) diff --git a/oidc-controller/api/core/config.py b/oidc-controller/api/core/config.py index 154737e0..2bd034b7 100644 --- a/oidc-controller/api/core/config.py +++ b/oidc-controller/api/core/config.py @@ -206,12 +206,17 @@ class GlobalConfig(BaseSettings): ACAPY_PROOF_FORMAT: str = os.environ.get("ACAPY_PROOF_FORMAT", "indy") - MT_ACAPY_WALLET_ID: str | None = os.environ.get("MT_ACAPY_WALLET_ID") - MT_ACAPY_WALLET_KEY: str = os.environ.get("MT_ACAPY_WALLET_KEY", "random-key") + # Unified Tenant Configuration with Legacy Fallback + # 1. Try unified variable + # 2. Fallback to legacy MT_ variable + # 3. Default to None + ACAPY_TENANT_WALLET_ID: str | None = os.environ.get( + "ACAPY_TENANT_WALLET_ID", os.environ.get("MT_ACAPY_WALLET_ID") + ) - # Traction Configuration - TRACTION_TENANT_ID: str | None = os.environ.get("TRACTION_TENANT_ID") - TRACTION_TENANT_API_KEY: str | None = os.environ.get("TRACTION_TENANT_API_KEY") + ACAPY_TENANT_WALLET_KEY: str | None = os.environ.get( + "ACAPY_TENANT_WALLET_KEY", os.environ.get("MT_ACAPY_WALLET_KEY", "random-key") + ) ST_ACAPY_ADMIN_API_KEY_NAME: str | None = os.environ.get( "ST_ACAPY_ADMIN_API_KEY_NAME" diff --git a/oidc-controller/api/main.py b/oidc-controller/api/main.py index a562fef6..e9d65edf 100644 --- a/oidc-controller/api/main.py +++ b/oidc-controller/api/main.py @@ -163,11 +163,11 @@ async def on_tenant_startup(): if settings.ACAPY_TENANCY == "multi": logger.info("Starting up in Multi-Tenant Admin Mode") token_fetcher = None - if settings.MT_ACAPY_WALLET_KEY: + if settings.ACAPY_TENANT_WALLET_KEY: token_fetcher = MultiTenantAcapy().get_wallet_token await register_tenant_webhook( - wallet_id=settings.MT_ACAPY_WALLET_ID, + wallet_id=settings.ACAPY_TENANT_WALLET_ID, webhook_url=settings.CONTROLLER_WEB_HOOK_URL, admin_url=settings.ACAPY_ADMIN_URL, api_key=settings.CONTROLLER_API_KEY, @@ -183,7 +183,7 @@ async def on_tenant_startup(): token_fetcher = TractionTenantAcapy().get_wallet_token await register_tenant_webhook( - wallet_id=settings.MT_ACAPY_WALLET_ID, # Optional/Unused for traction mode registration + wallet_id=settings.ACAPY_TENANT_WALLET_ID, # Optional/Unused for traction mode registration webhook_url=settings.CONTROLLER_WEB_HOOK_URL, admin_url=settings.ACAPY_ADMIN_URL, api_key=settings.CONTROLLER_API_KEY, From dcf4d573d982415e85f7a4d273f1e30281745aa5 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Tue, 2 Dec 2025 17:52:58 +0000 Subject: [PATCH 07/13] feat: unify multi-tenant config and add traction mode support Signed-off-by: Yuki I --- oidc-controller/api/main.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/oidc-controller/api/main.py b/oidc-controller/api/main.py index e9d65edf..78eaf031 100644 --- a/oidc-controller/api/main.py +++ b/oidc-controller/api/main.py @@ -161,7 +161,14 @@ async def on_tenant_startup(): # Robust Webhook Registration if settings.ACAPY_TENANCY == "multi": - logger.info("Starting up in Multi-Tenant Admin Mode") + logger.debug( + "Starting up in Multi-Tenant Admin Mode", + mode="multi", + expected_id="Wallet ID (ACAPY_TENANT_WALLET_ID or MT_ACAPY_WALLET_ID)", + expected_key="Wallet Key (ACAPY_TENANT_WALLET_KEY or MT_ACAPY_WALLET_KEY)", + webhook_registration="Admin API (/multitenancy/wallet/{id})", + ) + token_fetcher = None if settings.ACAPY_TENANT_WALLET_KEY: token_fetcher = MultiTenantAcapy().get_wallet_token @@ -178,7 +185,13 @@ async def on_tenant_startup(): ) elif settings.ACAPY_TENANCY == "traction": - logger.info("Starting up in Traction Mode") + logger.debug( + "Starting up in Traction Mode", + mode="traction", + expected_id="Traction Tenant ID (ACAPY_TENANT_WALLET_ID)", + expected_key="Traction Tenant API Key (ACAPY_TENANT_WALLET_KEY)", + webhook_registration="Tenant API (/tenant/wallet)", + ) token_fetcher = TractionTenantAcapy().get_wallet_token From 036192f7cdcdc0649119fcf578364fcf12c09885 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Mon, 8 Dec 2025 12:39:23 +0000 Subject: [PATCH 08/13] refactor: centralize config in .env and clean manage script Signed-off-by: Yuki I --- docker/.env.example | 73 ++++++++++++++------------ docker/manage | 121 ++++++-------------------------------------- 2 files changed, 57 insertions(+), 137 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index cbd12e17..c6be729c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,3 +1,11 @@ +############################################ +# Global / Logging +############################################ +COMPOSE_PROJECT_NAME=vc-authn +LOG_LEVEL=DEBUG +LOG_WITH_JSON=false +DEBUGGER=false + ############################################ # Controller Database (Mongo) ############################################ @@ -9,10 +17,8 @@ OIDC_CONTROLLER_DB_USER_PWD=changeme ############################################ -# Controller Core Settings +# OIDC Controller Service ############################################ -DEBUGGER=false -LOG_LEVEL=DEBUG CONTROLLER_SERVICE_PORT=5000 # Public URLs @@ -20,43 +26,40 @@ CONTROLLER_URL=https://your-public-url.example.com CONTROLLER_WEB_HOOK_URL=https://your-public-url.example.com/webhooks CONTROLLER_API_KEY= -# Session & presentation settings +# Controller Behavior CONTROLLER_CAMERA_REDIRECT_URL=wallet_howto CONTROLLER_PRESENTATION_EXPIRE_TIME=300 CONTROLLER_PRESENTATION_CLEANUP_TIME=86400 -CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE=/etc/controller-config/sessiontimeout.json -CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE=/etc/controller-config/user_variable_substitution.py - -# Templates and cleanup limits -CONTROLLER_TEMPLATE_DIR=/app/controller-config/templates CONTROLLER_PRESENTATION_RECORD_RETENTION_HOURS=1 CONTROLLER_CLEANUP_MAX_PRESENTATION_RECORDS=1000 CONTROLLER_CLEANUP_MAX_CONNECTIONS=2000 +# Configuration Files & Paths +CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE=/etc/controller-config/sessiontimeout.json +CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE=/etc/controller-config/user_variable_substitution.py +CONTROLLER_TEMPLATE_DIR=/app/controller-config/templates + +# Verification Options INVITATION_LABEL="VC-AuthN" SET_NON_REVOKED=true ACAPY_PROOF_FORMAT=anoncreds +USE_OOB_LOCAL_DID_SERVICE=true +USE_CONNECTION_BASED_VERIFICATION=true +USE_URL_DEEP_LINK=false +WALLET_DEEP_LINK_PREFIX=bcwallet://aries_proof-request - -############################################ -# Controller Feature Flags -############################################ +# Scaling & Caching +CONTROLLER_REPLICAS=3 USE_REDIS_ADAPTER=false REDIS_HOST=redis REDIS_PORT=6379 +REDIS_PASSWORD= REDIS_DB=0 -USE_CONNECTION_BASED_VERIFICATION=true -USE_OOB_PRESENT_PROOF=true -USE_OOB_LOCAL_DID_SERVICE=true -USE_URL_DEEP_LINK=false -WALLET_DEEP_LINK_PREFIX=bcwallet://aries_proof-request - ############################################ # ACA-Py Agent ############################################ -AGENT_TENANT_MODE=traction AGENT_HOST=localhost AGENT_NAME="VC-AuthN Agent" @@ -72,7 +75,7 @@ AGENT_GENESIS_URL=https://test.bcovrin.vonx.io/genesis AGENT_WALLET_SEED=your-32-char-seed-here-00000000000000 -############################################ +######################################################## # ACA-Py Wallet / Tenant Identity # # When AGENT_TENANT_MODE=multi: @@ -82,7 +85,9 @@ AGENT_WALLET_SEED=your-32-char-seed-here-00000000000000 # When AGENT_TENANT_MODE=traction: # ACAPY_TENANT_WALLET_ID = Traction Tenant ID # ACAPY_TENANT_WALLET_KEY = Traction Tenant API Key -############################################ +######################################################## +AGENT_TENANT_MODE=traction + ACAPY_TENANT_WALLET_ID=your-tenant-id-here ACAPY_TENANT_WALLET_KEY=your-tenant-key-here @@ -91,13 +96,25 @@ MT_ACAPY_WALLET_ID=legacy-wallet-id MT_ACAPY_WALLET_KEY=legacy-wallet-key -############################################ -# ACA-Py Single-Tenant Settings (Unused) -############################################ +########################################################## +# ACA-Py Single-Tenant Settings (AGENT_TENANT_MODE=single) +########################################################## ST_ACAPY_ADMIN_API_KEY_NAME= ST_ACAPY_ADMIN_API_KEY= +############################## +# Wallet Database (PostgreSQL) +############################## +WALLET_TYPE=postgres_storage +WALLET_ENCRYPTION_KEY=key +POSTGRESQL_WALLET_HOST=wallet-db +POSTGRESQL_WALLET_PORT=5432 +POSTGRESQL_WALLET_DATABASE=wallet_db +POSTGRESQL_WALLET_USER=walletuser +POSTGRESQL_WALLET_PASSWORD=walletpassword + + ############################################ # OIDC Client ############################################ @@ -124,9 +141,3 @@ KEYCLOAK_USER=admin KEYCLOAK_PASSWORD=admin KEYCLOAK_LOGLEVEL=WARN KEYCLOAK_ROOT_LOGLEVEL=WARN - - -############################################ -# Logging -############################################ -LOG_WITH_JSON=false diff --git a/docker/manage b/docker/manage index a12d79c2..c9e43a10 100755 --- a/docker/manage +++ b/docker/manage @@ -166,116 +166,25 @@ configureEnvironment() { esac done - ## global - export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-vc-authn}" - export LOG_LEVEL=${LOG_LEVEL:-"DEBUG"} - - # controller-db - export MONGODB_HOST="controller-db" - export MONGODB_PORT="27017" - export MONGODB_NAME="oidccontroller" - export OIDC_CONTROLLER_DB_USER="oidccontrolleruser" - export OIDC_CONTROLLER_DB_USER_PWD="oidccontrollerpass" - - - # controller - export CONTROLLER_SERVICE_PORT=${CONTROLLER_SERVICE_PORT:-5000} - export CONTROLLER_URL="${CONTROLLER_URL:-http://controller:5000}" - export CONTROLLER_WEB_HOOK_URL=${CONTROLLER_WEB_HOOK_URL:-${CONTROLLER_URL}/webhooks} - if [ ! -z "${CONTROLLER_API_KEY}" ]; then - CONTROLLER_WEB_HOOK_URL="${CONTROLLER_WEB_HOOK_URL}#${CONTROLLER_API_KEY}" + # Controller Webhook URL: Append API Key if present + if [ ! -z "${CONTROLLER_API_KEY}" ] && [[ "${CONTROLLER_WEB_HOOK_URL}" != *"#"* ]]; then + export CONTROLLER_WEB_HOOK_URL="${CONTROLLER_WEB_HOOK_URL}#${CONTROLLER_API_KEY}" fi - export ST_ACAPY_ADMIN_API_KEY_NAME="x-api-key" - - # The redirect url can be a web link or the name of a template - export CONTROLLER_CAMERA_REDIRECT_URL="wallet_howto" - - # The number of time in seconds a proof request will be valid for - export CONTROLLER_PRESENTATION_EXPIRE_TIME=10 - - # How long auth_sessions with matching the states in - # CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE are stored for in seconds - export CONTROLLER_PRESENTATION_CLEANUP_TIME=86400 - - # Presentation record cleanup configuration - # How long to retain presentation records in hours (default: 24 hours) - export CONTROLLER_PRESENTATION_RECORD_RETENTION_HOURS=1 - - # Resource limits for cleanup operations to prevent excessive processing - # Maximum presentation records to process per cleanup cycle (default: 1000) - export CONTROLLER_CLEANUP_MAX_PRESENTATION_RECORDS=1000 - # Maximum connections to process per cleanup cycle (default: 2000) - export CONTROLLER_CLEANUP_MAX_CONNECTIONS=2000 - - # The path to the auth_session timeouts config file - export CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE="/app/controller-config/sessiontimeout.json" - - # Extend Variable Substitutions - export CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE="/app/controller-config/user_variable_substitution.py" - - # template configuration - export CONTROLLER_TEMPLATE_DIR="/app/controller-config/templates" - - #controller app settings - export INVITATION_LABEL=${INVITATION_LABEL:-"VC-AuthN"} - export SET_NON_REVOKED="True" - export ACAPY_PROOF_FORMAT=${ACAPY_PROOF_FORMAT:-indy} - export USE_OOB_LOCAL_DID_SERVICE=${USE_OOB_LOCAL_DID_SERVICE:-"true"} - export USE_CONNECTION_BASED_VERIFICATION=${USE_CONNECTION_BASED_VERIFICATION:-"true"} - export WALLET_DEEP_LINK_PREFIX=${WALLET_DEEP_LINK_PREFIX:-"bcwallet://aries_proof-request"} - - # Multi-pod configuration - export CONTROLLER_REPLICAS=${CONTROLLER_REPLICAS:-3} - - # Redis Configuration (required for multi-pod) - export REDIS_HOST=${REDIS_HOST:-"redis"} - export REDIS_PORT=${REDIS_PORT:-"6379"} - export REDIS_PASSWORD=${REDIS_PASSWORD:-""} - export REDIS_DB=${REDIS_DB:-"0"} - export USE_REDIS_ADAPTER=${USE_REDIS_ADAPTER:-"true"} - - # agent - export AGENT_TENANT_MODE="${AGENT_TENANT_MODE:-single}" - export AGENT_HOST=${AGENT_HOST:-aca-py} - export AGENT_NAME="VC-AuthN Agent" - export AGENT_HTTP_PORT=${AGENT_HTTP_PORT:-8030} - export AGENT_ADMIN_PORT=${AGENT_ADMIN_PORT:-"8077"} - export AGENT_ADMIN_URL=${AGENT_ADMIN_URL:-http://$AGENT_HOST:$AGENT_ADMIN_PORT} - export AGENT_ENDPOINT=${AGENT_ENDPOINT:-http://$AGENT_HOST:$AGENT_HTTP_PORT} - export AGENT_ADMIN_API_KEY=${AGENT_ADMIN_API_KEY} + + # Agent Admin Mode: Append API Key if present export AGENT_ADMIN_MODE="admin-insecure-mode" if [ ! -z "${AGENT_ADMIN_API_KEY}" ]; then - AGENT_ADMIN_MODE="admin-api-key ${AGENT_ADMIN_API_KEY}" + export AGENT_ADMIN_MODE="admin-api-key ${AGENT_ADMIN_API_KEY}" + fi + + # Agent URLs: Construct from Host/Port if not explicitly set + if [ -z "${AGENT_ENDPOINT}" ]; then + export AGENT_ENDPOINT="http://${AGENT_HOST}:${AGENT_HTTP_PORT}" + fi + + if [ -z "${AGENT_ADMIN_URL}" ]; then + export AGENT_ADMIN_URL="http://${AGENT_HOST}:${AGENT_ADMIN_PORT}" fi - export AGENT_WALLET_SEED=${AGENT_WALLET_SEED} - # Unified Tenant / Wallet Config - export ACAPY_TENANT_WALLET_ID=${ACAPY_TENANT_WALLET_ID} - export ACAPY_TENANT_WALLET_KEY=${ACAPY_TENANT_WALLET_KEY} - # Legacy Config - export MT_ACAPY_WALLET_ID=${MT_ACAPY_WALLET_ID} - export MT_ACAPY_WALLET_KEY=${MT_ACAPY_WALLET_KEY} - - # keycloak-db - export KEYCLOAK_DB_NAME="keycloak" - export KEYCLOAK_DB_USER="keycloak" - export KEYCLOAK_DB_PASSWORD="keycloak" - - # keycloak - export KEYCLOAK_DB_VENDOR="postgres" - export KEYCLOAK_DB_ADDR="keycloak-db" - export KEYCLOAK_USER="admin" - export KEYCLOAK_PASSWORD="admin" - export KEYCLOAK_LOGLEVEL="WARN" - export KEYCLOAK_ROOT_LOGLEVEL="WARN" - - # wallet-db - export WALLET_TYPE="postgres_storage" - export WALLET_ENCRYPTION_KEY="key" - export POSTGRESQL_WALLET_HOST="wallet-db" - export POSTGRESQL_WALLET_PORT="5432" - export POSTGRESQL_WALLET_DATABASE="wallet_db" - export POSTGRESQL_WALLET_USER="walletuser" - export POSTGRESQL_WALLET_PASSWORD="walletpassword" } getStartupParams() { From 756c370d8b65b81d2297f6759df514e7bee59c19 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Mon, 8 Dec 2025 13:16:35 +0000 Subject: [PATCH 09/13] fix: address code review feedback on traction mode Signed-off-by: Yuki I --- docs/ConfigurationGuide.md | 6 +-- oidc-controller/api/core/webhook_utils.py | 12 ++++- .../api/tests/test_webhook_registration.py | 46 +++++++++++++++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 0acf8303..0cc2f773 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -113,9 +113,8 @@ environment: The `traction` mode (`AGENT_TENANT_MODE="traction"`) is designed specifically for environments where the ACA-Py Admin API endpoints (e.g., `/multitenancy/wallet/...`) are restricted or blocked for security reasons, which is true of many `traction` environments. In this mode: -1. **Authentication:** The controller authenticates directly as the tenant using `ACAPY_TENANT_WALLET_ID` (as the Tenant ID) and `ACAPY_TENANT_WALLET_KEY` (as the Tenant API Key) to obtain a Bearer token. -2. **Fallback:** If these keys are not provided, it falls back to trying to authenticate using the legacy `MT_ACAPY_WALLET_ID` and `MT_ACAPY_WALLET_KEY` via the wallet token endpoint (though this may fail if that endpoint is also blocked). -3. **Webhook Registration:** The controller bypasses the Admin API and uses the authenticated Tenant API (`PUT /tenant/wallet`) to register the `CONTROLLER_WEB_HOOK_URL`. +1. **Authentication:** The controller authenticates directly as the tenant using `ACAPY_TENANT_WALLET_ID` (as the Tenant ID) and `ACAPY_TENANT_WALLET_KEY` (as the Tenant API Key) to obtain a Bearer token. **Both values must be provided; there is no fallback to legacy keys.** +2. **Webhook Registration:** The controller bypasses the Admin API and uses the authenticated Tenant API (`PUT /tenant/wallet`) to register the `CONTROLLER_WEB_HOOK_URL`. **Example Docker Configuration for Traction:** @@ -136,6 +135,7 @@ To address this, the VC-AuthN Controller performs an automatic handshake on star 1. It checks if `CONTROLLER_WEB_HOOK_URL` is defined. 2. If the Controller is protected by `CONTROLLER_API_KEY`, it appends the key to the URL using the `#` syntax. This instructs ACA-Py to parse the hash and send it as an `x-api-key` header in the webhook request. + * **Important:** The full webhook URL with the fragment (`#`) is sensitive and should be treated as secret data. Do **not** log, expose, or share the full URL with the fragment, as it can be used to access protected endpoints. Always mask or redact the API key in logs and documentation. 3. It attempts to connect to the ACA-Py Agent to update the specific Tenant Wallet configuration. - **Multi Mode:** Uses the Admin API (`/multitenancy/wallet/{id}`) authenticated with `ST_ACAPY_ADMIN_API_KEY`. - **Traction Mode:** Uses the Tenant API (`/tenant/wallet`) authenticated with the Tenant Token acquired via `ACAPY_TENANT_WALLET_KEY`. diff --git a/oidc-controller/api/core/webhook_utils.py b/oidc-controller/api/core/webhook_utils.py index 5e692dbc..9d0bbd66 100644 --- a/oidc-controller/api/core/webhook_utils.py +++ b/oidc-controller/api/core/webhook_utils.py @@ -53,12 +53,22 @@ async def register_tenant_webhook( if api_key and "#" not in webhook_url: webhook_url = f"{webhook_url}#{api_key}" + # Security: Mask the API key in logs + log_safe_url = webhook_url + if "#" in webhook_url: + try: + base, secret = webhook_url.split("#", 1) + if secret: + log_safe_url = f"{base}#*****" + except ValueError: + pass + payload = {"wallet_webhook_urls": [webhook_url]} max_retries = 5 base_delay = 2 # seconds - logger.info(f"Attempting to register webhook: {webhook_url}") + logger.info(f"Attempting to register webhook: {log_safe_url}") for attempt in range(0, max_retries): try: diff --git a/oidc-controller/api/tests/test_webhook_registration.py b/oidc-controller/api/tests/test_webhook_registration.py index 50a359dc..155dffee 100644 --- a/oidc-controller/api/tests/test_webhook_registration.py +++ b/oidc-controller/api/tests/test_webhook_registration.py @@ -22,6 +22,8 @@ def mock_settings(): mock.USE_REDIS_ADAPTER = False mock.ACAPY_TENANCY = "multi" mock.MT_ACAPY_WALLET_KEY = "wallet-key" + mock.ACAPY_TENANT_WALLET_KEY = "wallet-key" + mock.ACAPY_TENANT_WALLET_ID = "test-wallet-id" yield mock @@ -224,7 +226,7 @@ async def test_startup_multi_tenant_injects_fetcher(mock_settings, mock_requests Ensures main.py actually instantiates MultiTenantAcapy and passes the method. """ mock_settings.ACAPY_TENANCY = "multi" - mock_settings.MT_ACAPY_WALLET_KEY = "wallet-key" # Trigger fetcher creation + mock_settings.ACAPY_TENANT_WALLET_KEY = "wallet-key" mock_settings.USE_REDIS_ADAPTER = False # Mock MultiTenantAcapy class to verify instantiation @@ -301,8 +303,10 @@ async def test_startup_single_tenant_skips_registration( @pytest.mark.asyncio -async def test_webhook_registration_fatal_auth_error(mock_requests_put, mock_sleep): - """Test that 401/403 errors stop retries immediately.""" +async def test_webhook_registration_401_stops_immediately( + mock_requests_put, mock_sleep +): + """Test that 401 errors (Unauthorized) stop retries immediately.""" mock_requests_put.return_value.status_code = 401 # Unauthorized await register_tenant_webhook( @@ -466,7 +470,7 @@ async def test_register_via_tenant_api_exception(mock_requests_put): @pytest.mark.asyncio async def test_webhook_registration_unexpected_status_code(mock_requests_put): """Test handling of unexpected status codes (e.g. 418).""" - mock_requests_put.return_value.status_code = 418 # I'm a teapot + mock_requests_put.return_value.status_code = 418 await register_tenant_webhook( wallet_id="test-wallet", @@ -479,3 +483,37 @@ async def test_webhook_registration_unexpected_status_code(mock_requests_put): ) # Should log warning and exit loop (not retry) assert mock_requests_put.call_count == 1 + + +@pytest.mark.asyncio +async def test_webhook_registration_masks_api_key_in_logs(mock_requests_put): + """Test that the API key fragment in webhook URL is masked in logs.""" + mock_requests_put.return_value.status_code = 200 + + # Use a fresh mock for the logger to inspect calls specifically for this test + with patch("api.core.webhook_utils.logger") as mock_logger: + secret_key = "super-secret-key" + base_url = "http://controller/webhooks" + + await register_tenant_webhook( + wallet_id="test-wallet", + webhook_url=base_url, + admin_url="http://acapy", + api_key=secret_key, # This gets appended as #secret-key + admin_api_key=None, + admin_api_key_name=None, + ) + + # Get all arguments passed to info calls + info_calls = [args[0] for args, _ in mock_logger.info.call_args_list] + + # Assert masking happened + expected_log_fragment = f"{base_url}#*****" + assert any( + expected_log_fragment in call for call in info_calls + ), f"Expected masked URL '{expected_log_fragment}' not found in logs: {info_calls}" + + # Assert secret is NOT present + assert not any( + secret_key in call for call in info_calls + ), "SECRET KEY LEAKED IN LOGS!" From 38325fb1c7bceb6da30829efb4c42fee2abd2a14 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Mon, 8 Dec 2025 13:58:21 +0000 Subject: [PATCH 10/13] fix: implement token TTL caching and update deprecation docs Signed-off-by: Yuki I --- docs/ConfigurationGuide.md | 8 +- oidc-controller/api/core/acapy/config.py | 45 +++++-- .../api/core/acapy/tests/test_config.py | 121 ++++++++++++++++-- oidc-controller/api/core/webhook_utils.py | 4 +- 4 files changed, 153 insertions(+), 25 deletions(-) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 0cc2f773..28811ef1 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -92,10 +92,10 @@ Several functions in ACAPy VC-AuthN can be tweaked by using the following enviro For backward compatibility with version 2.x deployments, the legacy `MT_` variables are still supported in `multi` mode. However, `ACAPY_TENANT_` variables take precedence if both are set. -| Legacy Variable | Mapped to | Status | -| -------------------- | ------------------------ | ---------- | -| MT_ACAPY_WALLET_ID | ACAPY_TENANT_WALLET_ID | Deprecated | -| MT_ACAPY_WALLET_KEY | ACAPY_TENANT_WALLET_KEY | Deprecated | +| Legacy Variable | Mapped to | Status | +| -------------------- | ------------------------ | ---------------------- | +| MT_ACAPY_WALLET_ID | ACAPY_TENANT_WALLET_ID | **Deprecated** | +| MT_ACAPY_WALLET_KEY | ACAPY_TENANT_WALLET_KEY | **Deprecated** | ### Deployment Examples diff --git a/oidc-controller/api/core/acapy/config.py b/oidc-controller/api/core/acapy/config.py index dad26730..e1181875 100644 --- a/oidc-controller/api/core/acapy/config.py +++ b/oidc-controller/api/core/acapy/config.py @@ -1,8 +1,8 @@ import requests import structlog import json +import time -from functools import cache from typing import Protocol from ..config import settings @@ -18,9 +18,19 @@ class MultiTenantAcapy: wallet_id = settings.ACAPY_TENANT_WALLET_ID wallet_key = settings.ACAPY_TENANT_WALLET_KEY - @cache + # Class-level cache to share token across instances and manage expiry + _token: str | None = None + _token_expiry: float = 0.0 + # Refresh token every hour (safe for default 1-day expiry) + TOKEN_TTL: int = 3600 + def get_wallet_token(self): - logger.debug(">>> get_wallet_token (Multi-Tenant Mode)") + # Check if valid token exists in cache + now = time.time() + if self._token and now < self._token_expiry: + return self._token + + logger.debug(">>> get_wallet_token (Multi-Tenant Mode) - Fetching new token") if not self.wallet_id: raise ValueError("ACAPY_TENANT_WALLET_ID is required for multi-tenant mode") @@ -59,10 +69,13 @@ def get_wallet_token(self): raise Exception(f"{resp_raw.status_code}::{error_detail}") resp = json.loads(resp_raw.content) - wallet_token = resp["token"] + + # Update class-level cache + MultiTenantAcapy._token = resp["token"] + MultiTenantAcapy._token_expiry = time.time() + self.TOKEN_TTL - logger.debug("<<< get_wallet_token") - return wallet_token + logger.debug("<<< get_wallet_token - Cached new token") + return MultiTenantAcapy._token def get_headers(self) -> dict[str, str]: return {"Authorization": "Bearer " + self.get_wallet_token()} @@ -78,9 +91,18 @@ class TractionTenantAcapy: tenant_id = settings.ACAPY_TENANT_WALLET_ID api_key = settings.ACAPY_TENANT_WALLET_KEY - @cache + # Class-level cache + _token: str | None = None + _token_expiry: float = 0.0 + TOKEN_TTL: int = 3600 + def get_wallet_token(self): - logger.debug(">>> get_wallet_token (Traction Mode)") + # Check if valid token exists in cache + now = time.time() + if self._token and now < self._token_expiry: + return self._token + + logger.debug(">>> get_wallet_token (Traction Mode) - Fetching new token") if not self.tenant_id or not self.api_key: error_msg = ( @@ -105,8 +127,13 @@ def get_wallet_token(self): if resp_raw.status_code == 200: resp = json.loads(resp_raw.content) + + # Update class-level cache + TractionTenantAcapy._token = resp["token"] + TractionTenantAcapy._token_expiry = time.time() + self.TOKEN_TTL + logger.debug("<<< get_wallet_token (Success via Traction API)") - return resp["token"] + return TractionTenantAcapy._token else: error_detail = resp_raw.content.decode() logger.error( diff --git a/oidc-controller/api/core/acapy/tests/test_config.py b/oidc-controller/api/core/acapy/tests/test_config.py index f59ea5ae..9d82fc6a 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -1,5 +1,6 @@ import mock import pytest +import time from requests.exceptions import RequestException from api.core.acapy.config import ( MultiTenantAcapy, @@ -9,6 +10,22 @@ from api.core.config import settings +# Helper to reset class level cache +def reset_acapy_cache(cls): + cls._token = None + cls._token_expiry = 0.0 + + +@pytest.fixture(autouse=True) +def clean_cache(): + """Ensure cache is clean before each test.""" + reset_acapy_cache(MultiTenantAcapy) + reset_acapy_cache(TractionTenantAcapy) + yield + reset_acapy_cache(MultiTenantAcapy) + reset_acapy_cache(TractionTenantAcapy) + + # ========================================== # Single Tenant Tests # ========================================== @@ -67,7 +84,6 @@ async def test_multi_tenant_uses_unified_variables(requests_mock): ) acapy = MultiTenantAcapy() - acapy.get_wallet_token.cache_clear() token = acapy.get_wallet_token() assert token == "token" @@ -81,7 +97,6 @@ async def test_multi_tenant_missing_id_raises_error(): """Test error validation if ACAPY_TENANT_WALLET_ID is missing in multi-tenant mode.""" with mock.patch.object(MultiTenantAcapy, "wallet_id", None): acapy = MultiTenantAcapy() - acapy.get_wallet_token.cache_clear() with pytest.raises(ValueError) as exc: acapy.get_wallet_token() @@ -105,7 +120,6 @@ async def test_multi_tenant_includes_admin_api_key_headers(requests_mock): ): acapy = MultiTenantAcapy() - acapy.get_wallet_token.cache_clear() requests_mock.post( settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", @@ -121,6 +135,62 @@ async def test_multi_tenant_includes_admin_api_key_headers(requests_mock): assert last_request.headers[admin_header] == admin_key +@pytest.mark.asyncio +async def test_multi_tenant_caching_behavior(requests_mock): + """Test that tokens are cached and not fetched repeatedly.""" + wallet_id = "cache-test-id" + wallet_key = "cache-test-key" + + with mock.patch.object(MultiTenantAcapy, "wallet_id", wallet_id), mock.patch.object( + MultiTenantAcapy, "wallet_key", wallet_key + ): + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + json={"token": "cached-token"}, + status_code=200, + ) + + acapy = MultiTenantAcapy() + + # First call hits API + token1 = acapy.get_wallet_token() + assert token1 == "cached-token" + assert requests_mock.call_count == 1 + + # Second call hits cache + token2 = acapy.get_wallet_token() + assert token2 == "cached-token" + assert requests_mock.call_count == 1 # Count should NOT increase + + +@pytest.mark.asyncio +async def test_multi_tenant_token_expiry(requests_mock): + """Test that expired tokens trigger a re-fetch.""" + wallet_id = "expiry-test-id" + wallet_key = "expiry-test-key" + + with mock.patch.object(MultiTenantAcapy, "wallet_id", wallet_id), mock.patch.object( + MultiTenantAcapy, "wallet_key", wallet_key + ): + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", + json={"token": "fresh-token"}, + status_code=200, + ) + + acapy = MultiTenantAcapy() + + # Inject an expired token directly + MultiTenantAcapy._token = "stale-token" + MultiTenantAcapy._token_expiry = time.time() - 100 # Expired 100s ago + + # Call should trigger fetch + token = acapy.get_wallet_token() + + assert token == "fresh-token" + assert requests_mock.call_count == 1 + + @pytest.mark.asyncio async def test_multi_tenant_throws_exception_for_401(requests_mock): """Test error handling for 401 Unauthorized in multi-tenant mode.""" @@ -131,15 +201,13 @@ async def test_multi_tenant_throws_exception_for_401(requests_mock): MultiTenantAcapy, "wallet_key", wallet_key ): - acapy = MultiTenantAcapy() - acapy.get_wallet_token.cache_clear() - requests_mock.post( settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{wallet_id}/token", json={"error": "unauthorized"}, status_code=401, ) + acapy = MultiTenantAcapy() with pytest.raises(Exception) as excinfo: acapy.get_wallet_token() @@ -174,7 +242,6 @@ async def test_traction_mode_uses_unified_variables_as_tenant_creds(requests_moc ) acapy = TractionTenantAcapy() - acapy.get_wallet_token.cache_clear() token = acapy.get_wallet_token() assert token == "traction-token" @@ -184,6 +251,43 @@ async def test_traction_mode_uses_unified_variables_as_tenant_creds(requests_moc assert last_request.json() == {"api_key": api_key} +@pytest.mark.asyncio +async def test_traction_caching_and_expiry(requests_mock): + """Test Traction token caching logic.""" + tenant_id = "traction-cache-id" + api_key = "traction-cache-key" + + with mock.patch.object( + TractionTenantAcapy, "tenant_id", tenant_id + ), mock.patch.object(TractionTenantAcapy, "api_key", api_key): + + requests_mock.post( + settings.ACAPY_ADMIN_URL + f"/multitenancy/tenant/{tenant_id}/token", + json={"token": "traction-token"}, + status_code=200, + ) + + acapy = TractionTenantAcapy() + + # 1. First fetch + t1 = acapy.get_wallet_token() + assert t1 == "traction-token" + assert requests_mock.call_count == 1 + + # 2. Second fetch (Cached) + t2 = acapy.get_wallet_token() + assert t2 == "traction-token" + assert requests_mock.call_count == 1 + + # 3. Simulate Expiry + TractionTenantAcapy._token_expiry = time.time() - 1 + + # 4. Third fetch (Refresh) + t3 = acapy.get_wallet_token() + assert t3 == "traction-token" + assert requests_mock.call_count == 2 + + @pytest.mark.asyncio async def test_traction_mode_missing_credentials_raises_error(): """Test that missing credentials in Traction mode raises ValueError.""" @@ -193,7 +297,6 @@ async def test_traction_mode_missing_credentials_raises_error(): ): acapy = TractionTenantAcapy() - acapy.get_wallet_token.cache_clear() with pytest.raises(ValueError) as exc: acapy.get_wallet_token() @@ -219,7 +322,6 @@ async def test_traction_mode_api_failure_raises_exception(requests_mock): ) acapy = TractionTenantAcapy() - acapy.get_wallet_token.cache_clear() with pytest.raises(Exception) as exc: acapy.get_wallet_token() @@ -243,7 +345,6 @@ async def test_traction_mode_connection_error_raises_exception(requests_mock): ) acapy = TractionTenantAcapy() - acapy.get_wallet_token.cache_clear() with pytest.raises(RequestException): acapy.get_wallet_token() diff --git a/oidc-controller/api/core/webhook_utils.py b/oidc-controller/api/core/webhook_utils.py index 9d0bbd66..3bd7707f 100644 --- a/oidc-controller/api/core/webhook_utils.py +++ b/oidc-controller/api/core/webhook_utils.py @@ -121,11 +121,11 @@ async def register_tenant_webhook( ) return - # STRATEGY 2: Direct Tenant API (Traction Mode) + # STRATEGY 2: Tenant API via Proxy (Traction Mode) else: if not token_fetcher: logger.error( - "Direct Tenant API registration requested but no token_fetcher provided." + "Registration via Tenant API proxy requested but no token_fetcher provided." ) return From 923396d180e9e3372d5cb967dbded94fbe6dd32f Mon Sep 17 00:00:00 2001 From: Yuki I Date: Mon, 8 Dec 2025 14:13:29 +0000 Subject: [PATCH 11/13] fix: implement token TTL caching and update deprecation docs Signed-off-by: Yuki I --- oidc-controller/api/core/acapy/config.py | 6 +++--- oidc-controller/api/core/acapy/tests/test_config.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/oidc-controller/api/core/acapy/config.py b/oidc-controller/api/core/acapy/config.py index e1181875..6c58ea3c 100644 --- a/oidc-controller/api/core/acapy/config.py +++ b/oidc-controller/api/core/acapy/config.py @@ -69,7 +69,7 @@ def get_wallet_token(self): raise Exception(f"{resp_raw.status_code}::{error_detail}") resp = json.loads(resp_raw.content) - + # Update class-level cache MultiTenantAcapy._token = resp["token"] MultiTenantAcapy._token_expiry = time.time() + self.TOKEN_TTL @@ -127,11 +127,11 @@ def get_wallet_token(self): if resp_raw.status_code == 200: resp = json.loads(resp_raw.content) - + # Update class-level cache TractionTenantAcapy._token = resp["token"] TractionTenantAcapy._token_expiry = time.time() + self.TOKEN_TTL - + logger.debug("<<< get_wallet_token (Success via Traction API)") return TractionTenantAcapy._token else: diff --git a/oidc-controller/api/core/acapy/tests/test_config.py b/oidc-controller/api/core/acapy/tests/test_config.py index 9d82fc6a..b1aec3b1 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -151,7 +151,7 @@ async def test_multi_tenant_caching_behavior(requests_mock): ) acapy = MultiTenantAcapy() - + # First call hits API token1 = acapy.get_wallet_token() assert token1 == "cached-token" @@ -179,14 +179,14 @@ async def test_multi_tenant_token_expiry(requests_mock): ) acapy = MultiTenantAcapy() - + # Inject an expired token directly MultiTenantAcapy._token = "stale-token" - MultiTenantAcapy._token_expiry = time.time() - 100 # Expired 100s ago + MultiTenantAcapy._token_expiry = time.time() - 100 # Expired 100s ago # Call should trigger fetch token = acapy.get_wallet_token() - + assert token == "fresh-token" assert requests_mock.call_count == 1 From 708a931e31a9e7578917f7513d1320194e3f5f02 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Thu, 18 Dec 2025 17:19:58 +0000 Subject: [PATCH 12/13] feat: make token cache TTL configurable via env var Signed-off-by: Yuki I --- docker/.env.example | 3 +++ docker/docker-compose.yaml | 1 + docs/ConfigurationGuide.md | 1 + oidc-controller/api/core/acapy/config.py | 4 ++-- .../api/core/acapy/tests/test_config.py | 20 +++++++++++++++++++ oidc-controller/api/core/config.py | 3 +++ 6 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index c6be729c..b7ba3e13 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -91,6 +91,9 @@ AGENT_TENANT_MODE=traction ACAPY_TENANT_WALLET_ID=your-tenant-id-here ACAPY_TENANT_WALLET_KEY=your-tenant-key-here +# Cache TTL for tenant tokens in seconds (default: 3600) +ACAPY_TOKEN_CACHE_TTL=3600 + # Legacy (ignored when ACAPY_TENANT_* is set) MT_ACAPY_WALLET_ID=legacy-wallet-id MT_ACAPY_WALLET_KEY=legacy-wallet-key diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 7567e1ad..a08e8c4b 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -52,6 +52,7 @@ services: # Unified Tenant / Wallet Configuration - ACAPY_TENANT_WALLET_ID=${ACAPY_TENANT_WALLET_ID} - ACAPY_TENANT_WALLET_KEY=${ACAPY_TENANT_WALLET_KEY} + - ACAPY_TOKEN_CACHE_TTL=3600 # Legacy variables (passed for backward compatibility) - MT_ACAPY_WALLET_ID=${MT_ACAPY_WALLET_ID} - MT_ACAPY_WALLET_KEY=${MT_ACAPY_WALLET_KEY} diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 28811ef1..ce6c24ee 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -80,6 +80,7 @@ Several functions in ACAPy VC-AuthN can be tweaked by using the following enviro | AGENT_TENANT_MODE | string ("single", "multi", "traction") | **single**: Standard single-tenant agent.
**multi**: Standard ACA-Py Multi-tenancy (authenticates via Admin API using Wallet ID/Key).
**traction**: Traction Multi-tenancy (authenticates via Tenant API using Tenant ID/API Key). | | ACAPY_TENANT_WALLET_ID | string | **If Mode=multi**: The `wallet_id` of the sub-wallet.
**If Mode=traction**: The `tenant_id` provided by Traction. | | ACAPY_TENANT_WALLET_KEY | string | **If Mode=multi**: The `wallet_key` used to unlock the sub-wallet.
**If Mode=traction**: The Tenant `api_key` provided by Traction. | +| ACAPY_TOKEN_CACHE_TTL | int | The duration (in seconds) to cache authentication tokens in memory for multi/traction modes. Defaults to `3600` (1 hour). | Reduce this if your ACA-Py/Traction instance issues tokens with short expiration times. | | CONTROLLER_WEB_HOOK_URL | string (URL) | **Required for Multi-Tenant Mode.** The public or internal URL where the ACA-Py Agent can reach this Controller to send webhooks (e.g. `http://controller:5000/webhooks`). | The controller will automatically register this URL with the specific tenant wallet on startup. | | SET_NON_REVOKED | bool | if True, the `non_revoked` attributed will be added to each of the present-proof request `requested_attribute` and `requested_predicate` with 'from=0' and'to=`int(time.time())` | | | USE_OOB_LOCAL_DID_SERVICE | bool | Instructs ACAPy VC-AuthN to use a local DID, it must be used when the agent service is not registered on the ledger with a public DID | Use this when `ACAPY_WALLET_LOCAL_DID` is set to `true` in the agent. | diff --git a/oidc-controller/api/core/acapy/config.py b/oidc-controller/api/core/acapy/config.py index 6c58ea3c..f3f6df60 100644 --- a/oidc-controller/api/core/acapy/config.py +++ b/oidc-controller/api/core/acapy/config.py @@ -22,7 +22,7 @@ class MultiTenantAcapy: _token: str | None = None _token_expiry: float = 0.0 # Refresh token every hour (safe for default 1-day expiry) - TOKEN_TTL: int = 3600 + TOKEN_TTL: int = settings.ACAPY_TOKEN_CACHE_TTL def get_wallet_token(self): # Check if valid token exists in cache @@ -94,7 +94,7 @@ class TractionTenantAcapy: # Class-level cache _token: str | None = None _token_expiry: float = 0.0 - TOKEN_TTL: int = 3600 + TOKEN_TTL: int = settings.ACAPY_TOKEN_CACHE_TTL def get_wallet_token(self): # Check if valid token exists in cache diff --git a/oidc-controller/api/core/acapy/tests/test_config.py b/oidc-controller/api/core/acapy/tests/test_config.py index b1aec3b1..3e89271e 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -348,3 +348,23 @@ async def test_traction_mode_connection_error_raises_exception(requests_mock): with pytest.raises(RequestException): acapy.get_wallet_token() + + +def test_token_ttl_configuration(): + """Test that TOKEN_TTL picks up the configuration value.""" + # Since TOKEN_TTL is evaluated at class definition time, we check that it matches + assert MultiTenantAcapy.TOKEN_TTL == 3600 + assert TractionTenantAcapy.TOKEN_TTL == 3600 + + # Verify we can modify it (simulating config load) + original_ttl = MultiTenantAcapy.TOKEN_TTL + try: + MultiTenantAcapy.TOKEN_TTL = 300 + assert MultiTenantAcapy.TOKEN_TTL == 300 + + # Verify get_wallet_token uses the class attribute + # We assume the logic uses self.TOKEN_TTL or Class.TOKEN_TTL + acapy = MultiTenantAcapy() + assert acapy.TOKEN_TTL == 300 + finally: + MultiTenantAcapy.TOKEN_TTL = original_ttl diff --git a/oidc-controller/api/core/config.py b/oidc-controller/api/core/config.py index 2bd034b7..dc6a4708 100644 --- a/oidc-controller/api/core/config.py +++ b/oidc-controller/api/core/config.py @@ -218,6 +218,9 @@ class GlobalConfig(BaseSettings): "ACAPY_TENANT_WALLET_KEY", os.environ.get("MT_ACAPY_WALLET_KEY", "random-key") ) + # Token Cache Configuration (seconds) - Default 1 hour + ACAPY_TOKEN_CACHE_TTL: int = int(os.environ.get("ACAPY_TOKEN_CACHE_TTL", 3600)) + ST_ACAPY_ADMIN_API_KEY_NAME: str | None = os.environ.get( "ST_ACAPY_ADMIN_API_KEY_NAME" ) From 5b32eef74c6abb2e73da3ff6ed60cae23ed8bc26 Mon Sep 17 00:00:00 2001 From: Yuki I Date: Thu, 18 Dec 2025 17:47:01 +0000 Subject: [PATCH 13/13] fix: add validation for token cache TTL Signed-off-by: Yuki I --- oidc-controller/api/core/config.py | 6 ++++++ .../api/core/tests/test_config_coverage.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/oidc-controller/api/core/config.py b/oidc-controller/api/core/config.py index dc6a4708..366b5844 100644 --- a/oidc-controller/api/core/config.py +++ b/oidc-controller/api/core/config.py @@ -332,3 +332,9 @@ def get_configuration() -> GlobalConfig: "The controller will not be able to register webhooks with the tenant wallet, " "which may cause verification flows to hang." ) + +# Startup validation for ACAPY_TOKEN_CACHE_TTL +if settings.ACAPY_TOKEN_CACHE_TTL <= 0: + raise ValueError( + f"ACAPY_TOKEN_CACHE_TTL must be a positive integer, got '{settings.ACAPY_TOKEN_CACHE_TTL}'" + ) diff --git a/oidc-controller/api/core/tests/test_config_coverage.py b/oidc-controller/api/core/tests/test_config_coverage.py index 3c76637a..9fcdf7af 100644 --- a/oidc-controller/api/core/tests/test_config_coverage.py +++ b/oidc-controller/api/core/tests/test_config_coverage.py @@ -94,3 +94,17 @@ def test_webhook_warning_not_triggered_single_tenant(self): for call in mock_logger.warning.call_args_list: assert "ACAPY_TENANCY is set to 'multi'" not in call[0][0] + + def test_token_ttl_validation_failure(self): + """ + Test that ValueError is raised when ACAPY_TOKEN_CACHE_TTL is invalid (<= 0). + """ + with patch.dict(os.environ, {"ACAPY_TOKEN_CACHE_TTL": "0"}): + with pytest.raises(ValueError) as exc: + self.reload_config() + assert "ACAPY_TOKEN_CACHE_TTL must be a positive integer" in str(exc.value) + + with patch.dict(os.environ, {"ACAPY_TOKEN_CACHE_TTL": "-10"}): + with pytest.raises(ValueError) as exc: + self.reload_config() + assert "ACAPY_TOKEN_CACHE_TTL must be a positive integer" in str(exc.value)