diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..b7ba3e13 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,146 @@ +############################################ +# Global / Logging +############################################ +COMPOSE_PROJECT_NAME=vc-authn +LOG_LEVEL=DEBUG +LOG_WITH_JSON=false +DEBUGGER=false + +############################################ +# Controller Database (Mongo) +############################################ +MONGODB_HOST=controller-db +MONGODB_PORT=27017 +MONGODB_NAME=oidccontroller +OIDC_CONTROLLER_DB_USER=changeme +OIDC_CONTROLLER_DB_USER_PWD=changeme + + +############################################ +# OIDC Controller Service +############################################ +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= + +# Controller Behavior +CONTROLLER_CAMERA_REDIRECT_URL=wallet_howto +CONTROLLER_PRESENTATION_EXPIRE_TIME=300 +CONTROLLER_PRESENTATION_CLEANUP_TIME=86400 +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 + +# Scaling & Caching +CONTROLLER_REPLICAS=3 +USE_REDIS_ADAPTER=false +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + + +############################################ +# ACA-Py Agent +############################################ +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 +######################################################## +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 + + +########################################################## +# 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 +############################################ +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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0314f537..a08e8c4b 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -49,6 +49,11 @@ 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} + - 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} - ST_ACAPY_ADMIN_API_KEY=${AGENT_ADMIN_API_KEY} diff --git a/docker/manage b/docker/manage index b7694e17..e42dca18 100755 --- a/docker/manage +++ b/docker/manage @@ -169,112 +169,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} - 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() { diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 9b7c9b79..ce6c24ee 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -77,6 +77,10 @@ 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. | +| 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. | @@ -85,15 +89,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 | -### Multi-Tenant Webhook Registration +### Legacy Variable Support -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. +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 (`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. **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:** + +```yaml +environment: + - 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 and Traction Webhook Registration + +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. + * **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`. 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 c3ab6cd1..4e949471 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -16,12 +16,33 @@ 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: ```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:** `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/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..f3f6df60 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 @@ -15,12 +15,25 @@ 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 + + # 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 = settings.ACAPY_TOKEN_CACHE_TTL - @cache def get_wallet_token(self): - logger.debug(">>> get_wallet_token") + # 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") # Check if admin API key is configured admin_api_key_configured = ( @@ -56,10 +69,83 @@ def get_wallet_token(self): raise Exception(f"{resp_raw.status_code}::{error_detail}") resp = json.loads(resp_raw.content) - wallet_token = resp["token"] - logger.debug("<<< get_wallet_token") - return wallet_token + # Update class-level cache + MultiTenantAcapy._token = resp["token"] + MultiTenantAcapy._token_expiry = time.time() + self.TOKEN_TTL + + 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()} + + +class TractionTenantAcapy: + """ + Configuration for Traction Multi-Tenancy. + Uses unified ACAPY_TENANT_WALLET_* variables mapped to Traction Tenant ID and API Key. + """ + + # Map unified variables to Traction concepts + tenant_id = settings.ACAPY_TENANT_WALLET_ID + api_key = settings.ACAPY_TENANT_WALLET_KEY + + # Class-level cache + _token: str | None = None + _token_expiry: float = 0.0 + TOKEN_TTL: int = settings.ACAPY_TOKEN_CACHE_TTL + + def get_wallet_token(self): + # 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 = ( + "Traction mode requires ACAPY_TENANT_WALLET_ID (Tenant ID) " + "and ACAPY_TENANT_WALLET_KEY (API Key) to be set." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + 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, + ) + + 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: + 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}") + + 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_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..3e89271e 100644 --- a/oidc-controller/api/core/acapy/tests/test_config.py +++ b/oidc-controller/api/core/acapy/tests/test_config.py @@ -1,9 +1,36 @@ import mock import pytest -from api.core.acapy.config import MultiTenantAcapy, SingleTenantAcapy +import time +from requests.exceptions import RequestException +from api.core.acapy.config import ( + MultiTenantAcapy, + SingleTenantAcapy, + TractionTenantAcapy, +) 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 +# ========================================== + + @pytest.mark.asyncio @mock.patch.object(settings, "ST_ACAPY_ADMIN_API_KEY_NAME", "name") @mock.patch.object(settings, "ST_ACAPY_ADMIN_API_KEY", "key") @@ -17,28 +44,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", @@ -48,29 +84,35 @@ 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" +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() + + 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_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" - # 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 + 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", admin_key ), mock.patch.object( @@ -78,11 +120,6 @@ async def test_multi_tenant_get_wallet_token_includes_auth_headers_and_body( ): 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", @@ -93,65 +130,76 @@ async def test_multi_tenant_get_wallet_token_includes_auth_headers_and_body( token = acapy.get_wallet_token() assert token == "token" - # Verify request details + # Verify request headers included the admin key last_request = requests_mock.last_request assert last_request.headers[admin_header] == admin_key - assert last_request.json() == {"wallet_key": wallet_key} @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 - ), mock.patch.object( - settings, "ST_ACAPY_ADMIN_API_KEY", None - ), mock.patch.object( - settings, "ST_ACAPY_ADMIN_API_KEY_NAME", "x-api-key" +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() - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key - acapy.get_wallet_token.cache_clear() + # 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": "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 == "token" - # Verify request details - 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 token == "fresh-token" + assert requests_mock.call_count == 1 @pytest.mark.asyncio -async def test_multi_tenant_throws_exception_for_401_unauthorized(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_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" - acapy = MultiTenantAcapy() - acapy.wallet_id = wallet_id - acapy.wallet_key = wallet_key - acapy.get_wallet_token.cache_clear() + 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", @@ -159,9 +207,164 @@ 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}") + acapy = MultiTenantAcapy() with pytest.raises(Exception) as excinfo: acapy.get_wallet_token() assert "401" in str(excinfo.value) - assert "unauthorized" in str(excinfo.value) + + +# ========================================== +# 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( + TractionTenantAcapy, "tenant_id", tenant_id + ), mock.patch.object(TractionTenantAcapy, "api_key", api_key): + + # 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"}, + status_code=200, + ) + + acapy = TractionTenantAcapy() + + token = acapy.get_wallet_token() + assert token == "traction-token" + + # 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_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.""" + + with mock.patch.object(TractionTenantAcapy, "tenant_id", None), mock.patch.object( + TractionTenantAcapy, "api_key", None + ): + + acapy = TractionTenantAcapy() + + 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_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( + 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", + status_code=403, + text="Forbidden", + ) + + acapy = TractionTenantAcapy() + + with pytest.raises(Exception) as exc: + acapy.get_wallet_token() + + assert "403" in str(exc.value) + + +@pytest.mark.asyncio +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( + 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", + exc=RequestException("Connection refused"), + ) + + acapy = TractionTenantAcapy() + + 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 3f0d4822..366b5844 100644 --- a/oidc-controller/api/core/config.py +++ b/oidc-controller/api/core/config.py @@ -199,16 +199,27 @@ 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") 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") + ) + + ACAPY_TENANT_WALLET_KEY: str | None = os.environ.get( + "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" @@ -312,9 +323,18 @@ 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." ) + +# 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) diff --git a/oidc-controller/api/core/webhook_utils.py b/oidc-controller/api/core/webhook_utils.py index 2331684e..3bd7707f 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,18 +14,33 @@ 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, + use_admin_api: bool = True, ): """ Registers the controller's webhook URL with the ACA-Py Agent Tenant. - Includes retries for agent startup and validation for configuration. + + 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://" @@ -37,42 +53,86 @@ 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 + # 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 - 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: {log_safe_url}") for attempt in range(0, max_retries): try: - 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 - elif response.status_code in [401, 403]: - logger.error( - f"Webhook registration failed: Unauthorized (401/403). Check AGENT_ADMIN_API_KEY configuration." - ) - 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..." + target_url = f"{admin_url}/multitenancy/wallet/{wallet_id}" + logger.debug(f"Attempting Admin API update at {target_url}") + + response = requests.put( + target_url, json=payload, headers=headers, timeout=5 ) + + if response.status_code == 200: + logger.info( + "Successfully registered webhook URL with ACA-Py tenant via Admin API" + ) + 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: + 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: Tenant API via Proxy (Traction Mode) else: - logger.warning( - f"Webhook registration returned status {response.status_code}: {response.text}" - ) - return + if not token_fetcher: + logger.error( + "Registration via Tenant API proxy 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}") @@ -90,3 +150,43 @@ 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/Direct: 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 + 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 API Update failed. Status: {update_res.status_code} Body: {update_res.text}" + ) + return False + + except Exception as 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 fa99f231..78eaf031 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, TractionTenantAcapy logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) @@ -160,13 +161,49 @@ async def on_tenant_startup(): # Robust Webhook Registration if settings.ACAPY_TENANCY == "multi": + 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 + 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, 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.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 + + await register_tenant_webhook( + 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, + 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 344cb582..155dffee 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 @@ -20,6 +21,9 @@ def mock_settings(): # Default safe values 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 @@ -38,8 +42,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,20 +55,107 @@ 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_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.""" + 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 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", @@ -82,7 +173,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, @@ -129,8 +220,93 @@ async def test_webhook_registration_retry_logic_with_backoff( @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_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.ACAPY_TENANT_WALLET_KEY = "wallet-key" + 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" + 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 +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_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( @@ -182,40 +358,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_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.""" @@ -258,3 +400,120 @@ 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 + + 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 + + +@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!"