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!"