From 75659472b415e4299d130d4e5656fd4fb8a8f0b9 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:08 -0300 Subject: [PATCH 01/33] feat(.serena/memories): add authentication plugin --- .serena/memories/project_overview.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index d1f90fa..062fac9 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -80,6 +80,18 @@ tcloud-mcp-platform/ - Email: admin@example.com - Password: `kubectl -n mcp-dev get secret mcp-stack-gateway-secret -o jsonpath="{.data.BASIC_AUTH_PASSWORD}" | base64 -d` +## Authentication Plugin + +| Plugin | Purpose | Status | +|--------|---------|--------| +| tcloud_cognito_auth | JWT validation via Cognito + TCloud API permissions | โœ… Implemented | + +**Location:** `plugins/tcloud_cognito_auth/` + +**Headers Propagated:** +- `X-User-Email` - User email +- `X-User-Customers` - JSON array of cloud_ids + ## Registered Agents | Agent | URL | Status | From dba08e8fa88b9847169101dd37a6e0b9118353f5 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:10 -0300 Subject: [PATCH 02/33] feat(templates): add plugin management commands and secrets for Cognito auth --- Makefile | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Makefile b/Makefile index 566440d..9212d49 100644 --- a/Makefile +++ b/Makefile @@ -157,6 +157,71 @@ list-agents: ## List registered agents (run port-forward first) @echo "๐Ÿ“‹ Listing registered agents..." curl -s http://localhost:8080/admin/gateways | jq +# ==================== Plugin Management ==================== + +build-plugin-configmap: ## Build ConfigMap from plugin code + @echo "๐Ÿ“ฆ Building plugin ConfigMap..." + @kubectl create configmap tcloud-cognito-auth-plugin \ + --from-file=plugins/tcloud_cognito_auth/__init__.py \ + --from-file=plugins/tcloud_cognito_auth/config.py \ + --from-file=plugins/tcloud_cognito_auth/exceptions.py \ + --from-file=plugins/tcloud_cognito_auth/models.py \ + --from-file=plugins/tcloud_cognito_auth/cognito.py \ + --from-file=plugins/tcloud_cognito_auth/tcloud_api.py \ + --from-file=plugins/tcloud_cognito_auth/cache.py \ + --from-file=plugins/tcloud_cognito_auth/tcloud_cognito_auth.py \ + --from-file=plugins/tcloud_cognito_auth/plugin-manifest.yaml \ + --dry-run=client -o yaml > infrastructure/context-forge/plugin-configmap.yaml + @echo "โœ… ConfigMap saved to infrastructure/context-forge/plugin-configmap.yaml" + +deploy-plugin-configmap: build-plugin-configmap ## Deploy plugin ConfigMap (ENV=dev|prod) +ifeq ($(ENV),prod) + kubectl apply -f infrastructure/context-forge/plugin-configmap.yaml -n $(NAMESPACE_PROD) +else + kubectl apply -f infrastructure/context-forge/plugin-configmap.yaml -n $(NAMESPACE_DEV) +endif + @echo "โœ… Plugin ConfigMap deployed" + +create-auth-secret: ## Create TCloud Cognito auth secret (interactive) +ifndef COGNITO_USER_POOL_ID + $(error COGNITO_USER_POOL_ID is required) +endif +ifndef COGNITO_APP_CLIENT_ID + $(error COGNITO_APP_CLIENT_ID is required) +endif +ifndef TCLOUD_API_URL + $(error TCLOUD_API_URL is required) +endif +ifndef TCLOUD_API_KEY + $(error TCLOUD_API_KEY is required) +endif +ifeq ($(ENV),prod) + @echo "๐Ÿ” Creating auth secret in PRODUCTION..." + kubectl create secret generic tcloud-cognito-auth-secret \ + --from-literal=COGNITO_USER_POOL_ID="$(COGNITO_USER_POOL_ID)" \ + --from-literal=COGNITO_REGION="$(COGNITO_REGION)" \ + --from-literal=COGNITO_APP_CLIENT_ID="$(COGNITO_APP_CLIENT_ID)" \ + --from-literal=TCLOUD_API_URL="$(TCLOUD_API_URL)" \ + --from-literal=TCLOUD_API_KEY="$(TCLOUD_API_KEY)" \ + -n $(NAMESPACE_PROD) --dry-run=client -o yaml | kubectl apply -f - +else + @echo "๐Ÿ” Creating auth secret in DEV..." + kubectl create secret generic tcloud-cognito-auth-secret \ + --from-literal=COGNITO_USER_POOL_ID="$(COGNITO_USER_POOL_ID)" \ + --from-literal=COGNITO_REGION="$(COGNITO_REGION)" \ + --from-literal=COGNITO_APP_CLIENT_ID="$(COGNITO_APP_CLIENT_ID)" \ + --from-literal=TCLOUD_API_URL="$(TCLOUD_API_URL)" \ + --from-literal=TCLOUD_API_KEY="$(TCLOUD_API_KEY)" \ + -n $(NAMESPACE_DEV) --dry-run=client -o yaml | kubectl apply -f - +endif + @echo "โœ… Auth secret created" + +test-plugin: ## Run plugin unit tests + @echo "๐Ÿงช Running plugin tests..." + cd plugins/tcloud_cognito_auth && \ + pip install -r requirements.txt -q && \ + PYTHONPATH=.. pytest tests/ -v + # ==================== Development ==================== template: clone-chart ## Render Helm templates locally (ENV=dev|prod) From a4ae535f30ca4f4b1006d8aa52ad02e0ce5aab42 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:12 -0300 Subject: [PATCH 03/33] feat(docs): add authentication architecture documentation --- docs/authentication.md | 219 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/authentication.md diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..10fa1e8 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,219 @@ +# Authentication Architecture + +This document describes the authentication architecture for the TCloud MCP Platform. + +## Overview + +Authentication is handled centrally at the **MCP Context Forge gateway**, which validates JWT tokens from AWS Cognito and propagates user context to downstream MCP agents. + +## Authentication Flow + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ AUTHENTICATION FLOW โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” OAuth 2.1 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Client โ”‚ (Cognito + Fluig) โ”‚ MCP Context Forge โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ 1. Validates JWT (Cognito) โ”‚ โ”‚ +โ”‚ โ”‚ 2. Fetches permissions (TCloud) โ”‚ โ”‚ +โ”‚ โ”‚ 3. Caches in Redis โ”‚ โ”‚ +โ”‚ โ”‚ 4. Propagates headers to agents โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ Headers propagated: โ”‚ โ”‚ +โ”‚ โ€ข X-User-Email โ”‚ โ”‚ +โ”‚ โ€ข X-User-Customers โ”‚ โ”‚ +โ”‚ โ–ผ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Orchestrator Agent โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ–ผ โ–ผ โ–ผ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ CPU/RAM โ”‚ โ”‚ DB โ”‚ ... โ”‚ App โ”‚ โ”‚ +โ”‚ โ”‚ Agent โ”‚ โ”‚ Agent โ”‚ โ”‚ Agent โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Components + +### 1. AWS Cognito + +- **Purpose**: Identity provider for user authentication +- **Token Type**: JWT (access tokens and ID tokens) +- **Region**: us-east-2 +- **Integration**: OAuth 2.1 with Fluig Identity + +### 2. TCloud Cognito Auth Plugin + +Custom plugin for MCP Context Forge that: + +1. **Validates JWTs** using Cognito JWKS (with caching) +2. **Fetches Permissions** from TCloud API +3. **Caches Permissions** in Redis (5 min TTL) +4. **Propagates Headers** to downstream agents + +See [Plugin README](../plugins/tcloud_cognito_auth/README.md) for details. + +### 3. TCloud API + +- **Endpoint**: `/customer` +- **Returns**: List of customer/cloud IDs the user can access +- **Authentication**: Bearer token + API key + +### 4. Redis Cache + +- **Purpose**: Cache user permissions to reduce API calls +- **TTL**: 5 minutes (configurable) +- **Key Pattern**: `tcloud:auth:permissions:{email_hash}` + +## Propagated Headers + +Headers injected by the plugin for downstream agents: + +| Header | Description | Example | +|--------|-------------|---------| +| `X-User-Email` | Authenticated user's email | `user@totvs.com.br` | +| `X-User-Customers` | JSON array of cloud_ids | `["cloud-001", "cloud-002"]` | +| `X-Request-ID` | Request correlation ID | `uuid` | + +## Agent Authorization + +Agents should use propagated headers for authorization: + +```python +import json + +async def check_authorization(request, cloud_id: str): + """Check if user can access the given cloud.""" + user_email = request.headers.get("X-User-Email") + customers_json = request.headers.get("X-User-Customers", "[]") + user_customers = json.loads(customers_json) + + if cloud_id not in user_customers: + raise PermissionError( + f"User {user_email} cannot access cloud {cloud_id}" + ) +``` + +## Dual-Mode Authentication + +Agents support two authentication modes: + +### 1. Via Gateway (Recommended) + +When called through Context Forge, agents receive pre-validated headers: + +```python +user_email = request.headers.get("X-User-Email") +# Already validated by gateway - safe to use +``` + +### 2. Standalone Mode + +When agents are accessed directly, they validate JWT themselves: + +```python +async def authenticate(request): + # Check for gateway headers first + if "X-User-Email" in request.headers: + return GatewayAuth(request.headers) + + # Fallback to direct JWT validation + auth_header = request.headers.get("Authorization") + if auth_header: + return await validate_jwt(auth_header) + + raise Unauthorized() +``` + +## Configuration + +### Environment Variables + +```bash +# Cognito +COGNITO_USER_POOL_ID=sa-east-1_xxx +COGNITO_REGION=us-east-2 +COGNITO_APP_CLIENT_ID=xxx + +# TCloud API +TCLOUD_API_URL=https://api.tcloud.cloudtotvs.com.br/dev +TCLOUD_API_KEY=xxx + +# Cache +REDIS_URL=redis://redis:6379/0 +PERMISSION_CACHE_TTL=300 +``` + +### Kubernetes Secret + +```bash +kubectl create secret generic tcloud-cognito-auth-secret \ + --from-literal=COGNITO_USER_POOL_ID=sa-east-1_xxx \ + --from-literal=COGNITO_REGION=us-east-2 \ + --from-literal=COGNITO_APP_CLIENT_ID=xxx \ + --from-literal=TCLOUD_API_URL=https://api.tcloud.cloudtotvs.com.br/dev \ + --from-literal=TCLOUD_API_KEY=xxx \ + -n mcp-dev +``` + +## Security Considerations + +1. **Token Validation**: Full JWT validation including signature, issuer, audience, and expiration +2. **Secrets Management**: All credentials stored in Kubernetes Secrets +3. **Cache Security**: Redis should use authentication and TLS in production +4. **Header Trust**: Agents should validate that requests come from known gateway IPs +5. **Audit Logging**: All authentication events are logged + +## Troubleshooting + +### Token Expired + +```json +{ + "error": { + "code": "TOKEN_EXPIRED", + "message": "Token has expired" + } +} +``` + +**Solution**: Client needs to refresh the access token. + +### Invalid Token + +```json +{ + "error": { + "code": "INVALID_TOKEN", + "message": "Invalid token signature" + } +} +``` + +**Solution**: Verify the token was issued by the correct Cognito User Pool. + +### Permission Denied + +```json +{ + "error": { + "code": "FORBIDDEN", + "message": "User does not have access to this cloud" + } +} +``` + +**Solution**: Verify user has the correct permissions in TCloud. + +## References + +- [AWS Cognito JWT Verification](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) +- [MCP Context Forge Plugin Framework](https://github.com/IBM/mcp-context-forge) +- [TCloud API Documentation](https://api.tcloud.cloudtotvs.com.br/docs) From c82943275f4a490b8449d691f7a0775a34a7df7c Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:13 -0300 Subject: [PATCH 04/33] feat(infrastructure/context-forge): add plugin-configmap.yaml --- .../context-forge/plugin-configmap.yaml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 infrastructure/context-forge/plugin-configmap.yaml diff --git a/infrastructure/context-forge/plugin-configmap.yaml b/infrastructure/context-forge/plugin-configmap.yaml new file mode 100644 index 0000000..2cc05b4 --- /dev/null +++ b/infrastructure/context-forge/plugin-configmap.yaml @@ -0,0 +1,26 @@ +# TCloud Cognito Auth Plugin - ConfigMap +# +# This ConfigMap is auto-generated by: +# make build-plugin-configmap +# +# It contains the plugin Python code to be mounted into the Context Forge container. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: tcloud-cognito-auth-plugin + labels: + app.kubernetes.io/name: tcloud-cognito-auth + app.kubernetes.io/component: plugin + app.kubernetes.io/part-of: mcp-context-forge +data: + # Plugin files will be populated by 'make build-plugin-configmap' + # Each .py file from plugins/tcloud_cognito_auth/ will be a key + + plugin-manifest.yaml: | + name: "TCloudCognitoAuthPlugin" + description: "TCloud Cognito Authentication Plugin" + version: "1.0.0" + hooks: + - "http_auth_resolve_user" + - "agent_pre_invoke" From 4fd872efb0176308007ec38ca9df4de3b11a3272 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:14 -0300 Subject: [PATCH 05/33] feat(infrastructure/context-forge): add new plugin configuration for TCloud Cognito Authentication --- .../context-forge/plugins-config.yaml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 infrastructure/context-forge/plugins-config.yaml diff --git a/infrastructure/context-forge/plugins-config.yaml b/infrastructure/context-forge/plugins-config.yaml new file mode 100644 index 0000000..c00a924 --- /dev/null +++ b/infrastructure/context-forge/plugins-config.yaml @@ -0,0 +1,25 @@ +# MCP Context Forge - Plugin Configuration +# +# This file configures which plugins are enabled and their settings. +# Mount this file to /etc/mcpgateway/plugins/config.yaml in the container. + +plugins: + # TCloud Cognito Authentication Plugin + - name: "TCloudCognitoAuthPlugin" + kind: "tcloud_cognito_auth.tcloud_cognito_auth.TCloudCognitoAuthPlugin" + description: "Authenticate via Cognito JWT and fetch TCloud API permissions" + version: "1.0.0" + author: "TCloud Platform Team" + hooks: + - "http_auth_resolve_user" + - "agent_pre_invoke" + - "tool_pre_invoke" + mode: "enforce" # enforce | audit | disabled + priority: 10 # Lower priority runs first + config: + # These can be overridden, but typically use environment variables + cognito_region: "${COGNITO_REGION:-us-east-2}" + permission_cache_ttl: 300 + jwks_cache_ttl: 3600 + enable_header_propagation: true + clock_skew_tolerance: 300 From 80fa69ec0323ff5ae0bb509d08bf26681fb24978 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:15 -0300 Subject: [PATCH 06/33] feat(infrastructure/context-forge/secrets): add tcloud-cognito-auth-secret.yaml template --- .../tcloud-cognito-auth-secret.yaml.template | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 infrastructure/context-forge/secrets/tcloud-cognito-auth-secret.yaml.template diff --git a/infrastructure/context-forge/secrets/tcloud-cognito-auth-secret.yaml.template b/infrastructure/context-forge/secrets/tcloud-cognito-auth-secret.yaml.template new file mode 100644 index 0000000..1a677ae --- /dev/null +++ b/infrastructure/context-forge/secrets/tcloud-cognito-auth-secret.yaml.template @@ -0,0 +1,34 @@ +# TCloud Cognito Auth Plugin - Kubernetes Secret Template +# +# Usage: +# 1. Copy this file to tcloud-cognito-auth-secret.yaml +# 2. Replace placeholder values with actual credentials +# 3. Apply: kubectl apply -f tcloud-cognito-auth-secret.yaml -n ${NAMESPACE} +# +# Or use kubectl create secret: +# kubectl -n ${NAMESPACE} create secret generic tcloud-cognito-auth-secret \ +# --from-literal=COGNITO_USER_POOL_ID=sa-east-1_xxx \ +# --from-literal=COGNITO_REGION=sa-east-1 \ +# --from-literal=COGNITO_APP_CLIENT_ID=xxx \ +# --from-literal=TCLOUD_API_URL=https://api.tcloud.cloudtotvs.com.br/dev \ +# --from-literal=TCLOUD_API_KEY=xxx + +apiVersion: v1 +kind: Secret +metadata: + name: tcloud-cognito-auth-secret + namespace: ${NAMESPACE} + labels: + app.kubernetes.io/name: tcloud-cognito-auth + app.kubernetes.io/component: authentication + app.kubernetes.io/part-of: mcp-context-forge +type: Opaque +stringData: + # AWS Cognito Configuration + COGNITO_USER_POOL_ID: "${COGNITO_USER_POOL_ID}" + COGNITO_REGION: "${COGNITO_REGION:-us-east-2}" + COGNITO_APP_CLIENT_ID: "${COGNITO_APP_CLIENT_ID}" + + # TCloud API Configuration + TCLOUD_API_URL: "${TCLOUD_API_URL}" + TCLOUD_API_KEY: "${TCLOUD_API_KEY}" From 9cb5e69ee8ffffd7d4c040c75dc5054d1102b7d6 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:17 -0300 Subject: [PATCH 07/33] feat( infrastructure/context-forge): update tcloud-cognito-auth plugin configuration --- infrastructure/context-forge/values.yaml | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/infrastructure/context-forge/values.yaml b/infrastructure/context-forge/values.yaml index 4d69dc7..919fe23 100644 --- a/infrastructure/context-forge/values.yaml +++ b/infrastructure/context-forge/values.yaml @@ -44,11 +44,63 @@ mcpContextForge: HTTP_SERVER: "gunicorn" CACHE_TYPE: redis ENVIRONMENT: production + # Plugin configuration + PLUGINS_ENABLED: "true" secret: BASIC_AUTH_USER: admin AUTH_REQUIRED: "true" + # TCloud Cognito Auth Plugin - Environment Variables + # These are loaded from tcloud-cognito-auth-secret + extraEnv: + - name: COGNITO_USER_POOL_ID + valueFrom: + secretKeyRef: + name: tcloud-cognito-auth-secret + key: COGNITO_USER_POOL_ID + optional: true + - name: COGNITO_REGION + valueFrom: + secretKeyRef: + name: tcloud-cognito-auth-secret + key: COGNITO_REGION + optional: true + - name: COGNITO_APP_CLIENT_ID + valueFrom: + secretKeyRef: + name: tcloud-cognito-auth-secret + key: COGNITO_APP_CLIENT_ID + optional: true + - name: TCLOUD_API_URL + valueFrom: + secretKeyRef: + name: tcloud-cognito-auth-secret + key: TCLOUD_API_URL + optional: true + - name: TCLOUD_API_KEY + valueFrom: + secretKeyRef: + name: tcloud-cognito-auth-secret + key: TCLOUD_API_KEY + optional: true + - name: PERMISSION_CACHE_TTL + value: "300" + - name: JWKS_CACHE_TTL + value: "3600" + + # Mount plugin code from ConfigMap + extraVolumeMounts: + - name: tcloud-cognito-plugin + mountPath: /etc/mcpgateway/plugins/tcloud_cognito_auth + readOnly: true + + extraVolumes: + - name: tcloud-cognito-plugin + configMap: + name: tcloud-cognito-auth-plugin + optional: true + postgres: enabled: true persistence: From 91385e83c8849d11ed40c69f88e038ee1d0fcb93 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:18 -0300 Subject: [PATCH 08/33] build: add .env.example for TCloud Cognito Auth --- plugins/tcloud_cognito_auth/.env.example | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/.env.example diff --git a/plugins/tcloud_cognito_auth/.env.example b/plugins/tcloud_cognito_auth/.env.example new file mode 100644 index 0000000..a37272a --- /dev/null +++ b/plugins/tcloud_cognito_auth/.env.example @@ -0,0 +1,19 @@ +# TCloud Cognito Auth Plugin - Environment Variables +# Copy to .env and fill in the values + +# AWS Cognito Configuration +COGNITO_REGION=us-east-2 +COGNITO_USER_POOL_ID=us-east-2_bc3j3KKan +COGNITO_APP_CLIENT_ID=1k7kn2jrqua4t39f97a1hedj2o +COGNITO_DOMAIN=cloudtotvs-portalcloud + +# TCloud API Configuration +TCLOUD_API_URL=https://http-api.tcloud.cloudtotvs.com.br/dev +TCLOUD_API_KEY=your-api-key-here + +# Redis Configuration +REDIS_URL=redis://localhost:6379/0 + +# Cache Configuration +PERMISSION_CACHE_TTL=300 +JWKS_CACHE_TTL=3600 From d000a1abab2e864cac7c8186ca7297ddc8cdd1d4 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:20 -0300 Subject: [PATCH 09/33] fix(plugins/tcloud_cognito_auth): add README.md --- plugins/tcloud_cognito_auth/README.md | 188 ++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/README.md diff --git a/plugins/tcloud_cognito_auth/README.md b/plugins/tcloud_cognito_auth/README.md new file mode 100644 index 0000000..be24761 --- /dev/null +++ b/plugins/tcloud_cognito_auth/README.md @@ -0,0 +1,188 @@ +# TCloud Cognito Auth Plugin + +Authentication plugin for MCP Context Forge that validates AWS Cognito JWTs and fetches user permissions from the TCloud API. + +## Features + +- **JWT Validation**: Validates tokens against Cognito JWKS with caching +- **Permission Caching**: Redis-based caching (5 min TTL) for user permissions +- **TCloud API Integration**: Fetches customer/cloud permissions from TCloud API +- **Header Propagation**: Injects `X-User-Email` and `X-User-Customers` headers for downstream agents + +## Architecture + +``` +[Client + JWT] โ†’ [Context Forge] โ†’ [TCloud Cognito Auth Plugin] + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ†“ โ†“ โ†“ + [Cognito JWKS] [Redis Cache] [TCloud API] + โ”‚ โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ + [Authenticated User] + โ†“ + [Header Injection to Agents] +``` + +## Hooks Implemented + +| Hook | Purpose | +|------|---------| +| `http_auth_resolve_user` | Validates JWT, fetches permissions, returns user info | +| `agent_pre_invoke` | Injects user headers before agent calls | +| `tool_pre_invoke` | Injects user headers before tool calls | + +## Configuration + +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `COGNITO_USER_POOL_ID` | Yes | - | Cognito User Pool ID | +| `COGNITO_REGION` | No | `us-east-2` | AWS region | +| `COGNITO_APP_CLIENT_ID` | Yes | - | Cognito App Client ID | +| `TCLOUD_API_URL` | Yes | - | TCloud API base URL | +| `TCLOUD_API_KEY` | Yes | - | TCloud API key | +| `REDIS_URL` | No | `redis://localhost:6379/0` | Redis connection URL | +| `PERMISSION_CACHE_TTL` | No | `300` | Cache TTL in seconds | +| `JWKS_CACHE_TTL` | No | `3600` | JWKS cache TTL in seconds | + +### Kubernetes Secret + +```bash +kubectl create secret generic tcloud-cognito-auth-secret \ + --from-literal=COGNITO_USER_POOL_ID=sa-east-1_xxx \ + --from-literal=COGNITO_REGION=us-east-2 \ + --from-literal=COGNITO_APP_CLIENT_ID=xxx \ + --from-literal=TCLOUD_API_URL=https://api.tcloud.cloudtotvs.com.br/dev \ + --from-literal=TCLOUD_API_KEY=xxx \ + -n mcp-dev +``` + +## Propagated Headers + +Headers injected to downstream agents: + +| Header | Description | Example | +|--------|-------------|---------| +| `X-User-Email` | Authenticated user's email | `user@example.com` | +| `X-User-Customers` | JSON array of customer IDs | `["cloud-001", "cloud-002"]` | +| `X-Request-ID` | Request tracking ID | `uuid` | + +## Development + +### Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### Run Tests + +```bash +pytest tests/ -v +``` + +### Run Tests with Coverage + +```bash +pytest tests/ -v --cov=. --cov-report=html +``` + +## Deployment + +### Build ConfigMap + +```bash +make build-plugin-configmap +``` + +### Deploy to Kubernetes + +```bash +# Create secret (one-time) +make create-auth-secret \ + COGNITO_USER_POOL_ID=sa-east-1_xxx \ + COGNITO_APP_CLIENT_ID=xxx \ + TCLOUD_API_URL=https://api.tcloud.cloudtotvs.com.br/dev \ + TCLOUD_API_KEY=xxx \ + ENV=dev + +# Deploy ConfigMap +make deploy-plugin-configmap ENV=dev + +# Redeploy Context Forge to pick up changes +make deploy-context-forge ENV=dev +``` + +## Agent Integration + +Agents can read propagated headers to get user context: + +```python +async def handle_request(request): + user_email = request.headers.get("X-User-Email") + user_customers_json = request.headers.get("X-User-Customers", "[]") + user_customers = json.loads(user_customers_json) + + # Use for authorization + if requested_cloud_id not in user_customers: + raise PermissionDenied("Access denied to this cloud") +``` + +## Dual-Mode Support + +Agents can work both via Context Forge (using headers) and standalone (validating JWT directly): + +```python +async def get_user_context(request): + # Try headers first (via gateway) + user_email = request.headers.get("X-User-Email") + if user_email: + return { + "email": user_email, + "customers": json.loads(request.headers.get("X-User-Customers", "[]")) + } + + # Fallback: validate JWT directly (standalone mode) + auth_header = request.headers.get("Authorization") + if auth_header: + return await validate_jwt_standalone(auth_header) + + return None +``` + +## Troubleshooting + +### Common Issues + +**JWKS Fetch Error** +- Check network connectivity to Cognito +- Verify `COGNITO_USER_POOL_ID` and `COGNITO_REGION` are correct + +**Token Expired** +- Client needs to refresh the token +- Check `clock_skew_tolerance` setting + +**TCloud API Error** +- Verify `TCLOUD_API_URL` and `TCLOUD_API_KEY` +- Check API connectivity + +**Cache Issues** +- Verify Redis connectivity +- Check `REDIS_URL` configuration + +### Debug Mode + +Enable debug logging: + +```yaml +mcpContextForge: + config: + LOG_LEVEL: DEBUG +``` + +## License + +Proprietary - TCloud Platform Team From fc22ea4d8832e5cde848b30c16a60a91488f49b0 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:21 -0300 Subject: [PATCH 10/33] feat(tcloud_cognito_auth): add initial implementation of JWT validation and user permissions fetching from TCloud API --- plugins/tcloud_cognito_auth/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/__init__.py diff --git a/plugins/tcloud_cognito_auth/__init__.py b/plugins/tcloud_cognito_auth/__init__.py new file mode 100644 index 0000000..dedfb14 --- /dev/null +++ b/plugins/tcloud_cognito_auth/__init__.py @@ -0,0 +1,15 @@ +"""TCloud Cognito Authentication Plugin for MCP Context Forge. + +This plugin provides: +- JWT validation via AWS Cognito +- User permissions fetching from TCloud API +- Redis caching for permissions +- Header propagation to downstream agents +""" + +__version__ = "1.0.0" +__author__ = "TCloud Platform Team" + +from .tcloud_cognito_auth import TCloudCognitoAuthPlugin + +__all__ = ["TCloudCognitoAuthPlugin"] From 7788df7f2041a69119ac1ccaf539caf7dfc916fd Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:23 -0300 Subject: [PATCH 11/33] fix(cache): add Redis cache for user permissions --- plugins/tcloud_cognito_auth/cache.py | 277 +++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/cache.py diff --git a/plugins/tcloud_cognito_auth/cache.py b/plugins/tcloud_cognito_auth/cache.py new file mode 100644 index 0000000..4be852b --- /dev/null +++ b/plugins/tcloud_cognito_auth/cache.py @@ -0,0 +1,277 @@ +"""Redis cache for user permissions.""" + +import hashlib +import json +import logging +from typing import Any + +import redis.asyncio as redis + +from .config import PluginSettings +from .exceptions import CacheError +from .models import UserPermissions + +logger = logging.getLogger(__name__) + + +class PermissionCache: + """Redis-based cache for user permissions.""" + + CACHE_PREFIX = "tcloud:auth:permissions:" + + def __init__(self, settings: PluginSettings): + """Initialize the permission cache. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._redis: redis.Redis | None = None + + async def initialize(self) -> None: + """Initialize Redis connection.""" + try: + self._redis = redis.from_url( + self.settings.redis_url, + encoding="utf-8", + decode_responses=True, + ) + # Test connection + await self._redis.ping() + logger.info("Redis cache initialized successfully") + except redis.RedisError as e: + logger.warning(f"Failed to connect to Redis: {e}. Cache disabled.") + self._redis = None + + async def shutdown(self) -> None: + """Close Redis connection.""" + if self._redis: + await self._redis.close() + self._redis = None + + @property + def is_available(self) -> bool: + """Check if cache is available.""" + return self._redis is not None + + async def get_permissions(self, email: str) -> UserPermissions | None: + """Get cached permissions for a user. + + Args: + email: User email address. + + Returns: + Cached UserPermissions or None if not found/expired. + """ + if not self._redis: + return None + + cache_key = self._make_key(email) + + try: + data = await self._redis.get(cache_key) + if data: + parsed = json.loads(data) + logger.debug(f"Cache hit for {email}") + return UserPermissions.from_cache_dict(parsed) + logger.debug(f"Cache miss for {email}") + return None + except redis.RedisError as e: + logger.warning(f"Redis get error: {e}") + return None + except (json.JSONDecodeError, ValueError) as e: + logger.warning(f"Cache data parse error: {e}") + return None + + async def set_permissions( + self, + email: str, + permissions: UserPermissions, + ttl: int | None = None, + ) -> bool: + """Cache user permissions. + + Args: + email: User email address. + permissions: UserPermissions to cache. + ttl: Optional TTL override in seconds. + + Returns: + True if cached successfully, False otherwise. + """ + if not self._redis: + return False + + cache_key = self._make_key(email) + cache_ttl = ttl or self.settings.permission_cache_ttl + + try: + data = json.dumps(permissions.to_cache_dict()) + await self._redis.set(cache_key, data, ex=cache_ttl) + logger.debug(f"Cached permissions for {email} (TTL: {cache_ttl}s)") + return True + except redis.RedisError as e: + logger.warning(f"Redis set error: {e}") + return False + + async def invalidate(self, email: str) -> bool: + """Invalidate cached permissions for a user. + + Args: + email: User email address. + + Returns: + True if invalidated, False otherwise. + """ + if not self._redis: + return False + + cache_key = self._make_key(email) + + try: + result = await self._redis.delete(cache_key) + logger.debug(f"Invalidated cache for {email}: {result}") + return result > 0 + except redis.RedisError as e: + logger.warning(f"Redis delete error: {e}") + return False + + async def get_or_fetch( + self, + email: str, + fetch_func, + ttl: int | None = None, + ) -> UserPermissions: + """Get permissions from cache or fetch and cache. + + Args: + email: User email address. + fetch_func: Async function to fetch permissions if not cached. + ttl: Optional TTL override in seconds. + + Returns: + UserPermissions from cache or freshly fetched. + """ + # Try cache first + cached = await self.get_permissions(email) + if cached: + return cached + + # Fetch fresh data + permissions = await fetch_func() + + # Cache the result (fire and forget) + await self.set_permissions(email, permissions, ttl) + + return permissions + + def _make_key(self, email: str) -> str: + """Generate cache key for email. + + Args: + email: User email address. + + Returns: + Cache key string. + """ + # Use hash to handle special characters and keep keys short + email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16] + return f"{self.CACHE_PREFIX}{email_hash}" + + +class TokenCache: + """Redis-based cache for validated tokens.""" + + CACHE_PREFIX = "tcloud:auth:token:" + + def __init__(self, settings: PluginSettings): + """Initialize the token cache. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._redis: redis.Redis | None = None + + async def initialize(self, redis_client: redis.Redis | None = None) -> None: + """Initialize Redis connection. + + Args: + redis_client: Optional existing Redis client to reuse. + """ + if redis_client: + self._redis = redis_client + else: + try: + self._redis = redis.from_url( + self.settings.redis_url, + encoding="utf-8", + decode_responses=True, + ) + await self._redis.ping() + except redis.RedisError as e: + logger.warning(f"Failed to connect to Redis: {e}. Token cache disabled.") + self._redis = None + + async def shutdown(self) -> None: + """Close Redis connection.""" + if self._redis: + await self._redis.close() + self._redis = None + + async def is_token_valid(self, token_hash: str) -> bool | None: + """Check if token was previously validated. + + Args: + token_hash: Hash of the token. + + Returns: + True if valid, False if invalid, None if not cached. + """ + if not self._redis: + return None + + cache_key = f"{self.CACHE_PREFIX}{token_hash}" + + try: + result = await self._redis.get(cache_key) + if result is None: + return None + return result == "1" + except redis.RedisError: + return None + + async def cache_token_result( + self, + token_hash: str, + is_valid: bool, + ttl: int = 60, + ) -> None: + """Cache token validation result. + + Args: + token_hash: Hash of the token. + is_valid: Whether the token is valid. + ttl: Cache TTL in seconds. + """ + if not self._redis: + return + + cache_key = f"{self.CACHE_PREFIX}{token_hash}" + + try: + await self._redis.set(cache_key, "1" if is_valid else "0", ex=ttl) + except redis.RedisError: + pass + + @staticmethod + def hash_token(token: str) -> str: + """Generate hash for token. + + Args: + token: JWT token string. + + Returns: + SHA256 hash of the token. + """ + return hashlib.sha256(token.encode()).hexdigest() From ebdb91b4a70040334dafaa51f77b791e3c3ef5d0 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:25 -0300 Subject: [PATCH 12/33] feat(cognito_auth): add AWS Cognito JWT validation --- plugins/tcloud_cognito_auth/cognito.py | 190 +++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/cognito.py diff --git a/plugins/tcloud_cognito_auth/cognito.py b/plugins/tcloud_cognito_auth/cognito.py new file mode 100644 index 0000000..d316799 --- /dev/null +++ b/plugins/tcloud_cognito_auth/cognito.py @@ -0,0 +1,190 @@ +"""AWS Cognito JWT validation for TCloud Auth Plugin.""" + +import time +from typing import Any + +import httpx +from jose import jwt, JWTError +from jose.exceptions import ExpiredSignatureError, JWTClaimsError + +from .config import PluginSettings +from .exceptions import ( + InvalidAudienceError, + InvalidIssuerError, + InvalidSignatureError, + JWKSFetchError, + KeyNotFoundError, + TokenExpiredError, + TokenValidationError, +) +from .models import CognitoClaims + + +class CognitoJWTValidator: + """Validates JWT tokens issued by AWS Cognito.""" + + def __init__(self, settings: PluginSettings): + """Initialize the validator with settings. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._jwks_cache: dict[str, Any] | None = None + self._jwks_cache_time: float = 0 + self._http_client: httpx.AsyncClient | None = None + + async def initialize(self) -> None: + """Initialize the HTTP client and pre-fetch JWKS.""" + self._http_client = httpx.AsyncClient(timeout=10.0) + await self._refresh_jwks() + + async def shutdown(self) -> None: + """Clean up resources.""" + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + async def validate_token(self, token: str) -> CognitoClaims: + """Validate a Cognito JWT token. + + Args: + token: The JWT token string (without 'Bearer ' prefix). + + Returns: + Parsed and validated token claims. + + Raises: + TokenValidationError: If token validation fails. + TokenExpiredError: If token has expired. + InvalidSignatureError: If signature verification fails. + InvalidIssuerError: If issuer is invalid. + InvalidAudienceError: If audience/client_id is invalid. + """ + try: + # Get the key ID from the token header + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + + if not kid: + raise TokenValidationError("Token header missing 'kid'") + + # Get the signing key from JWKS + signing_key = await self._get_signing_key(kid) + + # Decode and validate the token + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + issuer=self.settings.cognito_issuer, + options={ + "verify_aud": False, # We'll verify client_id manually for access tokens + "leeway": self.settings.clock_skew_tolerance, + }, + ) + + # Parse claims into model + parsed_claims = CognitoClaims(**claims) + + # Validate client_id for access tokens + if parsed_claims.token_use == "access": + if parsed_claims.client_id != self.settings.cognito_app_client_id: + raise InvalidAudienceError( + f"Invalid client_id: {parsed_claims.client_id}" + ) + + return parsed_claims + + except ExpiredSignatureError as e: + raise TokenExpiredError(str(e)) + except JWTClaimsError as e: + if "issuer" in str(e).lower(): + raise InvalidIssuerError(str(e)) + raise TokenValidationError(str(e)) + except JWTError as e: + error_msg = str(e).lower() + if "signature" in error_msg: + raise InvalidSignatureError(str(e)) + raise TokenValidationError(str(e)) + + async def _get_signing_key(self, kid: str) -> dict[str, Any]: + """Get the signing key from JWKS by key ID. + + Args: + kid: The key ID from the JWT header. + + Returns: + The JWK for the given key ID. + + Raises: + KeyNotFoundError: If key is not found in JWKS. + """ + jwks = await self._get_jwks() + + for key in jwks.get("keys", []): + if key.get("kid") == kid: + return key + + # Key not found, try refreshing JWKS (key rotation may have occurred) + await self._refresh_jwks() + jwks = self._jwks_cache + + for key in jwks.get("keys", []): + if key.get("kid") == kid: + return key + + raise KeyNotFoundError(f"Key with kid '{kid}' not found in JWKS") + + async def _get_jwks(self) -> dict[str, Any]: + """Get JWKS, using cache if available and not expired. + + Returns: + The JWKS dictionary. + """ + now = time.time() + cache_age = now - self._jwks_cache_time + + if self._jwks_cache and cache_age < self.settings.jwks_cache_ttl: + return self._jwks_cache + + await self._refresh_jwks() + return self._jwks_cache + + async def _refresh_jwks(self) -> None: + """Fetch fresh JWKS from Cognito. + + Raises: + JWKSFetchError: If JWKS cannot be fetched. + """ + if not self._http_client: + self._http_client = httpx.AsyncClient(timeout=10.0) + + try: + response = await self._http_client.get(self.settings.cognito_jwks_url) + response.raise_for_status() + self._jwks_cache = response.json() + self._jwks_cache_time = time.time() + except httpx.HTTPError as e: + # If we have cached JWKS, use it as fallback + if self._jwks_cache: + return + raise JWKSFetchError(f"Failed to fetch JWKS: {e}") + + def extract_token_from_header(self, auth_header: str | None) -> str | None: + """Extract Bearer token from Authorization header. + + Args: + auth_header: The Authorization header value. + + Returns: + The token string, or None if not found/invalid. + """ + if not auth_header: + return None + + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + + return parts[1] From d900bcc58cc809282a5806acbe19d21802f70364 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:26 -0300 Subject: [PATCH 13/33] fix(config): add new configuration management for TCloud Cognito Auth Plugin --- plugins/tcloud_cognito_auth/config.py | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/config.py diff --git a/plugins/tcloud_cognito_auth/config.py b/plugins/tcloud_cognito_auth/config.py new file mode 100644 index 0000000..04b2376 --- /dev/null +++ b/plugins/tcloud_cognito_auth/config.py @@ -0,0 +1,79 @@ +"""Configuration management for TCloud Cognito Auth Plugin.""" + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class PluginSettings(BaseSettings): + """Plugin configuration loaded from environment variables.""" + + # Cognito settings + cognito_user_pool_id: str = Field( + ..., + description="AWS Cognito User Pool ID", + ) + cognito_region: str = Field( + default="us-east-2", + description="AWS region for Cognito", + ) + cognito_app_client_id: str = Field( + ..., + description="Cognito App Client ID for audience validation", + ) + + # TCloud API settings + tcloud_api_url: str = Field( + ..., + description="TCloud API base URL", + ) + tcloud_api_key: str = Field( + ..., + description="TCloud API authentication key", + ) + + # Cache settings + redis_url: str = Field( + default="redis://localhost:6379/0", + description="Redis connection URL", + ) + permission_cache_ttl: int = Field( + default=300, + description="Permission cache TTL in seconds", + ) + + # JWKS cache settings + jwks_cache_ttl: int = Field( + default=3600, + description="JWKS cache TTL in seconds (1 hour)", + ) + + # Plugin behavior + enable_header_propagation: bool = Field( + default=True, + description="Enable header propagation to downstream agents", + ) + clock_skew_tolerance: int = Field( + default=300, + description="Clock skew tolerance in seconds for token validation", + ) + + model_config = { + "env_prefix": "", + "case_sensitive": False, + "extra": "ignore", + } + + @property + def cognito_issuer(self) -> str: + """Get the Cognito issuer URL.""" + return f"https://cognito-idp.{self.cognito_region}.amazonaws.com/{self.cognito_user_pool_id}" + + @property + def cognito_jwks_url(self) -> str: + """Get the Cognito JWKS URL.""" + return f"{self.cognito_issuer}/.well-known/jwks.json" + + +def get_settings() -> PluginSettings: + """Get plugin settings from environment variables.""" + return PluginSettings() From 782c6ddd8a27ade4222362d8267d038344388d4a Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:28 -0300 Subject: [PATCH 14/33] fix(plugins/tcloud_cognito_auth): add custom exceptions for TCloud authentication errors --- plugins/tcloud_cognito_auth/exceptions.py | 75 +++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/exceptions.py diff --git a/plugins/tcloud_cognito_auth/exceptions.py b/plugins/tcloud_cognito_auth/exceptions.py new file mode 100644 index 0000000..ed11cb3 --- /dev/null +++ b/plugins/tcloud_cognito_auth/exceptions.py @@ -0,0 +1,75 @@ +"""Custom exceptions for TCloud Cognito Auth Plugin.""" + + +class TCloudAuthError(Exception): + """Base exception for TCloud authentication errors.""" + + def __init__(self, message: str, code: str = "AUTH_ERROR"): + self.message = message + self.code = code + super().__init__(self.message) + + +class TokenValidationError(TCloudAuthError): + """Raised when JWT token validation fails.""" + + def __init__(self, message: str, code: str = "INVALID_TOKEN"): + super().__init__(message, code) + + +class TokenExpiredError(TokenValidationError): + """Raised when JWT token has expired.""" + + def __init__(self, message: str = "Token has expired"): + super().__init__(message, "TOKEN_EXPIRED") + + +class InvalidSignatureError(TokenValidationError): + """Raised when JWT signature is invalid.""" + + def __init__(self, message: str = "Invalid token signature"): + super().__init__(message, "INVALID_SIGNATURE") + + +class InvalidIssuerError(TokenValidationError): + """Raised when JWT issuer is invalid.""" + + def __init__(self, message: str = "Invalid token issuer"): + super().__init__(message, "INVALID_ISSUER") + + +class InvalidAudienceError(TokenValidationError): + """Raised when JWT audience is invalid.""" + + def __init__(self, message: str = "Invalid token audience"): + super().__init__(message, "INVALID_AUDIENCE") + + +class JWKSFetchError(TCloudAuthError): + """Raised when JWKS cannot be fetched.""" + + def __init__(self, message: str = "Failed to fetch JWKS"): + super().__init__(message, "JWKS_FETCH_ERROR") + + +class KeyNotFoundError(TCloudAuthError): + """Raised when signing key is not found in JWKS.""" + + def __init__(self, message: str = "Signing key not found in JWKS"): + super().__init__(message, "KEY_NOT_FOUND") + + +class TCloudAPIError(TCloudAuthError): + """Raised when TCloud API request fails.""" + + def __init__(self, message: str, status_code: int | None = None): + self.status_code = status_code + code = f"TCLOUD_API_ERROR_{status_code}" if status_code else "TCLOUD_API_ERROR" + super().__init__(message, code) + + +class CacheError(TCloudAuthError): + """Raised when cache operations fail.""" + + def __init__(self, message: str = "Cache operation failed"): + super().__init__(message, "CACHE_ERROR") From 7a6c193da183c67cad2ef38bc0ca13658b5f9c81 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:30 -0300 Subject: [PATCH 15/33] fix(tcloud_cognito_auth/models.py): add new data models and user permissions --- plugins/tcloud_cognito_auth/models.py | 123 ++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/models.py diff --git a/plugins/tcloud_cognito_auth/models.py b/plugins/tcloud_cognito_auth/models.py new file mode 100644 index 0000000..6e9e2e0 --- /dev/null +++ b/plugins/tcloud_cognito_auth/models.py @@ -0,0 +1,123 @@ +"""Data models for TCloud Cognito Auth Plugin.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class CognitoClaims(BaseModel): + """Parsed claims from a Cognito JWT token.""" + + sub: str = Field(..., description="Subject (unique user ID)") + iss: str = Field(..., description="Issuer URL") + token_use: str = Field(..., description="Token use (access or id)") + exp: int = Field(..., description="Expiration timestamp") + iat: int = Field(..., description="Issued at timestamp") + client_id: str | None = Field(None, description="Client ID (for access tokens)") + username: str | None = Field(None, description="Username") + email: str | None = Field(None, description="User email (for id tokens)") + name: str | None = Field(None, description="User full name") + + @property + def user_email(self) -> str: + """Extract user email from claims.""" + if self.email: + return self.email + # For access tokens, extract email from username (format: provider_email) + if self.username and "_" in self.username: + return self.username.split("_", 1)[1] + return self.username or self.sub + + +class UserPermissions(BaseModel): + """User permissions fetched from TCloud API.""" + + email: str = Field(..., description="User email") + customers: list[str] = Field( + default_factory=list, description="List of customer/cloud IDs" + ) + roles: list[str] = Field(default_factory=list, description="User roles") + permissions: list[str] = Field( + default_factory=list, description="Specific permissions" + ) + fetched_at: datetime = Field( + default_factory=datetime.utcnow, description="When permissions were fetched" + ) + + def to_cache_dict(self) -> dict[str, Any]: + """Convert to dictionary for caching.""" + return { + "email": self.email, + "customers": self.customers, + "roles": self.roles, + "permissions": self.permissions, + "fetched_at": self.fetched_at.isoformat(), + } + + @classmethod + def from_cache_dict(cls, data: dict[str, Any]) -> "UserPermissions": + """Create from cached dictionary.""" + if isinstance(data.get("fetched_at"), str): + data["fetched_at"] = datetime.fromisoformat(data["fetched_at"]) + return cls(**data) + + +class AuthenticatedUser(BaseModel): + """Represents an authenticated user with permissions.""" + + email: str = Field(..., description="User email") + full_name: str | None = Field(None, description="User full name") + cognito_sub: str = Field(..., description="Cognito subject ID") + is_admin: bool = Field(default=False, description="Whether user is admin") + is_active: bool = Field(default=True, description="Whether user is active") + customers: list[str] = Field( + default_factory=list, description="Allowed customer IDs" + ) + roles: list[str] = Field(default_factory=list, description="User roles") + permissions: list[str] = Field(default_factory=list, description="User permissions") + auth_method: str = Field(default="cognito", description="Authentication method") + + def to_gateway_user(self) -> dict[str, Any]: + """Convert to Context Forge user format.""" + return { + "email": self.email, + "full_name": self.full_name or self.email, + "is_admin": self.is_admin, + "is_active": self.is_active, + } + + def to_metadata(self) -> dict[str, Any]: + """Convert to metadata for header propagation.""" + return { + "auth_method": self.auth_method, + "cognito_sub": self.cognito_sub, + "customers": self.customers, + "roles": self.roles, + "permissions": self.permissions, + } + + +class PropagatedHeaders(BaseModel): + """Headers to propagate to downstream agents.""" + + x_user_email: str = Field(..., alias="X-User-Email") + x_user_customers: str = Field(..., alias="X-User-Customers") + x_request_id: str | None = Field(None, alias="X-Request-ID") + + model_config = {"populate_by_name": True} + + @classmethod + def from_authenticated_user( + cls, user: AuthenticatedUser, request_id: str | None = None + ) -> "PropagatedHeaders": + """Create headers from authenticated user.""" + import json + + return cls( + **{ + "X-User-Email": user.email, + "X-User-Customers": json.dumps(user.customers), + "X-Request-ID": request_id, + } + ) From f3d088a28efb5d15d77924d8dfd5a7079fcccabc Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:31 -0300 Subject: [PATCH 16/33] feat(tcloud_cognito_auth): add plugin manifest configuration and dependencies --- .../tcloud_cognito_auth/plugin-manifest.yaml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/plugin-manifest.yaml diff --git a/plugins/tcloud_cognito_auth/plugin-manifest.yaml b/plugins/tcloud_cognito_auth/plugin-manifest.yaml new file mode 100644 index 0000000..6241a8a --- /dev/null +++ b/plugins/tcloud_cognito_auth/plugin-manifest.yaml @@ -0,0 +1,39 @@ +name: "TCloudCognitoAuthPlugin" +description: "TCloud Cognito Authentication Plugin - JWT validation, TCloud API permissions, and header propagation" +author: "TCloud Platform Team" +version: "1.0.0" + +# Hooks this plugin implements +available_hooks: + - "http_auth_resolve_user" + - "agent_pre_invoke" + +# Default configuration +default_configs: + cognito_region: "us-east-2" + permission_cache_ttl: 300 + jwks_cache_ttl: 3600 + enable_header_propagation: true + clock_skew_tolerance: 300 + +# Required environment variables +required_env: + - COGNITO_USER_POOL_ID + - COGNITO_APP_CLIENT_ID + - TCLOUD_API_URL + - TCLOUD_API_KEY + +# Optional environment variables with defaults +optional_env: + COGNITO_REGION: "us-east-2" + REDIS_URL: "redis://localhost:6379/0" + PERMISSION_CACHE_TTL: "300" + JWKS_CACHE_TTL: "3600" + +# Dependencies +dependencies: + - python-jose[cryptography]>=3.3.0 + - httpx>=0.27.0 + - redis>=5.0.0 + - pydantic>=2.0.0 + - pydantic-settings>=2.0.0 From 7c3dfb0b191ce48fe9cd14ba78a652fbbf6239f3 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:31 -0300 Subject: [PATCH 17/33] build: update dependencies --- plugins/tcloud_cognito_auth/requirements.txt | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/requirements.txt diff --git a/plugins/tcloud_cognito_auth/requirements.txt b/plugins/tcloud_cognito_auth/requirements.txt new file mode 100644 index 0000000..a316837 --- /dev/null +++ b/plugins/tcloud_cognito_auth/requirements.txt @@ -0,0 +1,21 @@ +# TCloud Cognito Auth Plugin Dependencies + +# JWT handling +python-jose[cryptography]>=3.3.0 + +# Async HTTP client +httpx>=0.27.0 + +# Redis client +redis>=5.0.0 + +# Data validation +pydantic>=2.0.0 +pydantic-settings>=2.0.0 + +# Testing (dev dependencies) +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +pytest-cov>=4.0.0 +respx>=0.21.0 # Mock httpx +fakeredis>=2.20.0 # Mock redis From 7a408dbdd66512e7eaa33e0ec08272a25a644a3a Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:34 -0300 Subject: [PATCH 18/33] fix(tcloud_cognito_auth): add new TCloud API client --- plugins/tcloud_cognito_auth/tcloud_api.py | 210 ++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/tcloud_api.py diff --git a/plugins/tcloud_cognito_auth/tcloud_api.py b/plugins/tcloud_cognito_auth/tcloud_api.py new file mode 100644 index 0000000..7929cea --- /dev/null +++ b/plugins/tcloud_cognito_auth/tcloud_api.py @@ -0,0 +1,210 @@ +"""TCloud API client for user permissions.""" + +import logging +from typing import Any + +import httpx + +from .config import PluginSettings +from .exceptions import TCloudAPIError +from .models import UserPermissions + +logger = logging.getLogger(__name__) + + +class TCloudAPIClient: + """Client for TCloud API to fetch user permissions.""" + + def __init__(self, settings: PluginSettings): + """Initialize the TCloud API client. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._http_client: httpx.AsyncClient | None = None + + async def initialize(self) -> None: + """Initialize the HTTP client.""" + self._http_client = httpx.AsyncClient( + base_url=self.settings.tcloud_api_url, + timeout=30.0, + headers={ + "x-api-key": self.settings.tcloud_api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + async def shutdown(self) -> None: + """Clean up resources.""" + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + async def get_user_permissions( + self, email: str, bearer_token: str | None = None + ) -> UserPermissions: + """Fetch user permissions from TCloud API. + + Args: + email: User email address. + bearer_token: Optional Bearer token to forward to API. + + Returns: + UserPermissions object with user's permissions. + + Raises: + TCloudAPIError: If API request fails. + """ + if not self._http_client: + await self.initialize() + + headers = {} + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + + try: + # Try /customer endpoint first (main source of permissions) + response = await self._http_client.get("/customer", headers=headers) + + if response.status_code == 200: + data = response.json() + customers = self._extract_customers(data) + return UserPermissions( + email=email, + customers=customers, + roles=self._extract_roles(data), + permissions=self._extract_permissions(data), + ) + elif response.status_code == 401: + raise TCloudAPIError( + "Unauthorized access to TCloud API", status_code=401 + ) + elif response.status_code == 403: + # User authenticated but has no permissions + logger.warning(f"User {email} has no customer permissions") + return UserPermissions(email=email, customers=[], roles=[], permissions=[]) + else: + raise TCloudAPIError( + f"TCloud API error: {response.status_code} - {response.text}", + status_code=response.status_code, + ) + + except httpx.TimeoutException as e: + raise TCloudAPIError(f"TCloud API timeout: {e}") + except httpx.HTTPError as e: + raise TCloudAPIError(f"TCloud API request failed: {e}") + + async def get_user_profile( + self, email: str, bearer_token: str | None = None + ) -> dict[str, Any]: + """Fetch user profile from TCloud API. + + Args: + email: User email address. + bearer_token: Optional Bearer token to forward to API. + + Returns: + User profile data. + + Raises: + TCloudAPIError: If API request fails. + """ + if not self._http_client: + await self.initialize() + + headers = {} + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + + try: + response = await self._http_client.get("/user/profile", headers=headers) + + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + return {"email": email, "name": email} + else: + logger.warning( + f"Failed to fetch profile for {email}: {response.status_code}" + ) + return {"email": email, "name": email} + + except httpx.HTTPError as e: + logger.warning(f"Failed to fetch user profile: {e}") + return {"email": email, "name": email} + + def _extract_customers(self, data: Any) -> list[str]: + """Extract customer/cloud IDs from API response. + + Args: + data: API response data. + + Returns: + List of customer/cloud IDs. + """ + customers = [] + + if isinstance(data, list): + # Response is a list of customer objects + for item in data: + if isinstance(item, dict): + cloud_id = item.get("cloud_id") or item.get("cloudId") or item.get("id") + if cloud_id: + customers.append(str(cloud_id)) + elif isinstance(data, dict): + # Response might have a customers/data array + items = data.get("customers") or data.get("data") or [] + for item in items: + if isinstance(item, dict): + cloud_id = item.get("cloud_id") or item.get("cloudId") or item.get("id") + if cloud_id: + customers.append(str(cloud_id)) + + return customers + + def _extract_roles(self, data: Any) -> list[str]: + """Extract user roles from API response. + + Args: + data: API response data. + + Returns: + List of role names. + """ + roles = set() + + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + role = item.get("role") or item.get("permission_level") + if role: + roles.add(str(role)) + elif isinstance(data, dict): + user_roles = data.get("roles") or [] + roles.update(str(r) for r in user_roles if r) + + return list(roles) + + def _extract_permissions(self, data: Any) -> list[str]: + """Extract specific permissions from API response. + + Args: + data: API response data. + + Returns: + List of permission strings. + """ + permissions = set() + + # Default read permissions if user has any customers + if isinstance(data, list) and len(data) > 0: + permissions.add("read:metrics") + permissions.add("read:logs") + + if isinstance(data, dict): + user_perms = data.get("permissions") or [] + permissions.update(str(p) for p in user_perms if p) + + return list(permissions) From dfea14b816d7c8f4213dd155fdfc5e2b67a91fe7 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:37 -0300 Subject: [PATCH 19/33] feat(tcloud_cognito_auth): add TCloud Cognito authentication plugin --- .../tcloud_cognito_auth.py | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/tcloud_cognito_auth.py diff --git a/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py b/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py new file mode 100644 index 0000000..97a25f3 --- /dev/null +++ b/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py @@ -0,0 +1,283 @@ +"""TCloud Cognito Authentication Plugin for MCP Context Forge. + +This plugin implements authentication via AWS Cognito JWT validation +and fetches user permissions from the TCloud API. +""" + +import json +import logging +from typing import Any + +from .cache import PermissionCache +from .cognito import CognitoJWTValidator +from .config import PluginSettings, get_settings +from .exceptions import ( + TCloudAPIError, + TCloudAuthError, + TokenExpiredError, + TokenValidationError, +) +from .models import AuthenticatedUser, PropagatedHeaders +from .tcloud_api import TCloudAPIClient + +logger = logging.getLogger(__name__) + + +class TCloudCognitoAuthPlugin: + """TCloud Cognito Authentication Plugin. + + Implements http_auth_resolve_user and agent_pre_invoke hooks for + MCP Context Forge to provide JWT-based authentication with + permission caching. + """ + + def __init__(self, config: dict[str, Any] | None = None): + """Initialize the plugin. + + Args: + config: Optional configuration dictionary from Context Forge. + """ + self._config = config or {} + self._settings: PluginSettings | None = None + self._cognito_validator: CognitoJWTValidator | None = None + self._tcloud_client: TCloudAPIClient | None = None + self._permission_cache: PermissionCache | None = None + self._initialized = False + + @property + def settings(self) -> PluginSettings: + """Get plugin settings, loading from environment if needed.""" + if not self._settings: + self._settings = get_settings() + return self._settings + + async def initialize(self) -> None: + """Initialize plugin resources. + + Called by Context Forge when the plugin is loaded. + """ + if self._initialized: + return + + logger.info("Initializing TCloud Cognito Auth Plugin") + + # Initialize components + self._cognito_validator = CognitoJWTValidator(self.settings) + await self._cognito_validator.initialize() + + self._tcloud_client = TCloudAPIClient(self.settings) + await self._tcloud_client.initialize() + + self._permission_cache = PermissionCache(self.settings) + await self._permission_cache.initialize() + + self._initialized = True + logger.info("TCloud Cognito Auth Plugin initialized successfully") + + async def shutdown(self) -> None: + """Clean up plugin resources. + + Called by Context Forge when the plugin is unloaded. + """ + logger.info("Shutting down TCloud Cognito Auth Plugin") + + if self._cognito_validator: + await self._cognito_validator.shutdown() + if self._tcloud_client: + await self._tcloud_client.shutdown() + if self._permission_cache: + await self._permission_cache.shutdown() + + self._initialized = False + + async def http_auth_resolve_user( + self, + payload: dict[str, Any], + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Resolve user from HTTP authentication credentials. + + This hook is called by Context Forge to authenticate incoming requests. + It validates the Cognito JWT and fetches user permissions. + + Args: + payload: Contains 'credentials' dict with auth info. + - credentials.credentials: The Bearer token + - credentials.scheme: The auth scheme (bearer) + context: Optional context from Context Forge. + + Returns: + Dict with: + - modified_payload: User info for Context Forge + - metadata: Additional info including permissions + - continue_processing: Whether to continue auth chain + """ + if not self._initialized: + await self.initialize() + + # Extract credentials from payload + credentials = payload.get("credentials", {}) + token = credentials.get("credentials") + scheme = credentials.get("scheme", "").lower() + + # Skip if no bearer token + if not token or scheme != "bearer": + logger.debug("No bearer token found, continuing auth chain") + return {"continue_processing": True} + + try: + # Validate JWT with Cognito + claims = await self._cognito_validator.validate_token(token) + email = claims.user_email + + logger.info(f"JWT validated for user: {email}") + + # Fetch permissions (with cache) + async def fetch_permissions(): + return await self._tcloud_client.get_user_permissions( + email, bearer_token=token + ) + + permissions = await self._permission_cache.get_or_fetch( + email, fetch_permissions + ) + + # Build authenticated user + user = AuthenticatedUser( + email=email, + full_name=claims.name, + cognito_sub=claims.sub, + is_admin=False, + is_active=True, + customers=permissions.customers, + roles=permissions.roles, + permissions=permissions.permissions, + ) + + logger.info( + f"Authenticated user {email} with {len(permissions.customers)} customers" + ) + + return { + "modified_payload": user.to_gateway_user(), + "metadata": user.to_metadata(), + "continue_processing": True, + } + + except TokenExpiredError as e: + logger.warning(f"Token expired: {e}") + return { + "error": { + "message": "Token expired", + "code": "TOKEN_EXPIRED", + }, + "continue_processing": False, + } + + except TokenValidationError as e: + logger.warning(f"Token validation failed: {e}") + return { + "error": { + "message": str(e), + "code": e.code, + }, + "continue_processing": False, + } + + except TCloudAPIError as e: + logger.error(f"TCloud API error: {e}") + # Continue with basic auth if API fails + return {"continue_processing": True} + + except Exception as e: + logger.error(f"Unexpected auth error: {e}", exc_info=True) + return {"continue_processing": True} + + async def agent_pre_invoke( + self, + payload: dict[str, Any], + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Inject user context headers before agent invocation. + + This hook is called before each agent request to inject + user identity headers that agents can use for authorization. + + Args: + payload: Contains request info including headers. + context: Context with authenticated user info. + + Returns: + Dict with: + - modified_payload: Updated payload with injected headers + - continue_processing: Always True to continue + """ + if not self.settings.enable_header_propagation: + return {"continue_processing": True} + + # Get user info from context + user_metadata = (context or {}).get("metadata", {}) + if not user_metadata or user_metadata.get("auth_method") != "cognito": + return {"continue_processing": True} + + user_email = (context or {}).get("user", {}).get("email") + if not user_email: + return {"continue_processing": True} + + # Build headers to inject + customers = user_metadata.get("customers", []) + headers_to_inject = { + "X-User-Email": user_email, + "X-User-Customers": json.dumps(customers), + } + + # Get existing headers from payload + existing_headers = payload.get("headers", {}) + merged_headers = {**existing_headers, **headers_to_inject} + + # Get request ID if available + request_id = (context or {}).get("request_id") + if request_id: + merged_headers["X-Request-ID"] = request_id + + logger.debug( + f"Injecting headers for {user_email}: " + f"X-User-Customers={len(customers)} customers" + ) + + return { + "modified_payload": {**payload, "headers": merged_headers}, + "continue_processing": True, + } + + async def tool_pre_invoke( + self, + payload: dict[str, Any], + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Inject user context before tool invocation. + + Similar to agent_pre_invoke but for tool calls. + + Args: + payload: Tool invocation payload. + context: Context with authenticated user info. + + Returns: + Dict with modified payload including user context. + """ + # Reuse the same logic as agent_pre_invoke + return await self.agent_pre_invoke(payload, context) + + +# Plugin factory function for Context Forge +def create_plugin(config: dict[str, Any] | None = None) -> TCloudCognitoAuthPlugin: + """Create a new plugin instance. + + Args: + config: Configuration from Context Forge. + + Returns: + Configured plugin instance. + """ + return TCloudCognitoAuthPlugin(config) From 711e6224654f827130becd9a4c347f033ee9977c Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:37 -0300 Subject: [PATCH 20/33] build(test): add TCloud Cognito Auth tests --- plugins/tcloud_cognito_auth/tests/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 plugins/tcloud_cognito_auth/tests/__init__.py diff --git a/plugins/tcloud_cognito_auth/tests/__init__.py b/plugins/tcloud_cognito_auth/tests/__init__.py new file mode 100644 index 0000000..39d88a5 --- /dev/null +++ b/plugins/tcloud_cognito_auth/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for TCloud Cognito Auth Plugin.""" From 8a1f107b5c7315ae067b27877db3515ba70d34c5 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:40 -0300 Subject: [PATCH 21/33] feat(tests): add test fixtures for TCloud Cognito Auth Plugin tests --- plugins/tcloud_cognito_auth/tests/conftest.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/tests/conftest.py diff --git a/plugins/tcloud_cognito_auth/tests/conftest.py b/plugins/tcloud_cognito_auth/tests/conftest.py new file mode 100644 index 0000000..740a4b4 --- /dev/null +++ b/plugins/tcloud_cognito_auth/tests/conftest.py @@ -0,0 +1,120 @@ +"""Pytest fixtures for TCloud Cognito Auth Plugin tests.""" + +import json +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Generator +from unittest.mock import AsyncMock, MagicMock + +import pytest +from jose import jwt + +# Set test environment variables before importing config +os.environ.setdefault("COGNITO_USER_POOL_ID", "us-east-2_TestPool123") +os.environ.setdefault("COGNITO_REGION", "us-east-2") +os.environ.setdefault("COGNITO_APP_CLIENT_ID", "test-client-id-123") +os.environ.setdefault("TCLOUD_API_URL", "https://api.tcloud.test") +os.environ.setdefault("TCLOUD_API_KEY", "test-api-key") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") + + +@pytest.fixture +def test_settings(): + """Create test settings.""" + from tcloud_cognito_auth.config import PluginSettings + + return PluginSettings( + cognito_user_pool_id="us-east-2_TestPool123", + cognito_region="us-east-2", + cognito_app_client_id="test-client-id-123", + tcloud_api_url="https://api.tcloud.test", + tcloud_api_key="test-api-key", + redis_url="redis://localhost:6379/0", + permission_cache_ttl=300, + ) + + +@pytest.fixture +def sample_jwks() -> dict[str, Any]: + """Sample JWKS for testing.""" + return { + "keys": [ + { + "kty": "RSA", + "kid": "test-key-id-1", + "use": "sig", + "alg": "RS256", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + } + ] + } + + +@pytest.fixture +def sample_access_token_claims() -> dict[str, Any]: + """Sample access token claims.""" + now = datetime.now(timezone.utc) + return { + "sub": "12345678-1234-1234-1234-123456789012", + "iss": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_TestPool123", + "token_use": "access", + "client_id": "test-client-id-123", + "username": "google_user@example.com", + "exp": int((now + timedelta(hours=1)).timestamp()), + "iat": int(now.timestamp()), + "scope": "openid email profile", + } + + +@pytest.fixture +def sample_id_token_claims() -> dict[str, Any]: + """Sample ID token claims.""" + now = datetime.now(timezone.utc) + return { + "sub": "12345678-1234-1234-1234-123456789012", + "iss": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_TestPool123", + "token_use": "id", + "aud": "test-client-id-123", + "email": "user@example.com", + "name": "Test User", + "exp": int((now + timedelta(hours=1)).timestamp()), + "iat": int(now.timestamp()), + } + + +@pytest.fixture +def expired_token_claims(sample_access_token_claims) -> dict[str, Any]: + """Expired token claims.""" + now = datetime.now(timezone.utc) + claims = sample_access_token_claims.copy() + claims["exp"] = int((now - timedelta(hours=1)).timestamp()) + return claims + + +@pytest.fixture +def sample_user_permissions() -> dict[str, Any]: + """Sample user permissions from TCloud API.""" + return { + "email": "user@example.com", + "customers": ["cloud-001", "cloud-002", "cloud-003"], + "roles": ["viewer", "operator"], + "permissions": ["read:metrics", "read:logs", "write:alerts"], + } + + +@pytest.fixture +def mock_http_client(): + """Create a mock HTTP client.""" + client = AsyncMock() + return client + + +@pytest.fixture +def mock_redis_client(): + """Create a mock Redis client.""" + client = AsyncMock() + client.get = AsyncMock(return_value=None) + client.set = AsyncMock(return_value=True) + client.delete = AsyncMock(return_value=1) + return client From 0ebeb72835ec11c8c58197f078cc96dbc136a7c2 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:42 -0300 Subject: [PATCH 22/33] fix(test_cache): add tests for Redis cache --- .../tcloud_cognito_auth/tests/test_cache.py | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/tests/test_cache.py diff --git a/plugins/tcloud_cognito_auth/tests/test_cache.py b/plugins/tcloud_cognito_auth/tests/test_cache.py new file mode 100644 index 0000000..798f3f6 --- /dev/null +++ b/plugins/tcloud_cognito_auth/tests/test_cache.py @@ -0,0 +1,250 @@ +"""Tests for Redis cache.""" + +import json +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tcloud_cognito_auth.cache import PermissionCache, TokenCache +from tcloud_cognito_auth.models import UserPermissions + + +@pytest.fixture +def permission_cache(test_settings): + """Create a permission cache instance.""" + return PermissionCache(test_settings) + + +@pytest.fixture +def token_cache(test_settings): + """Create a token cache instance.""" + return TokenCache(test_settings) + + +@pytest.fixture +def sample_permissions(): + """Create sample permissions.""" + return UserPermissions( + email="user@example.com", + customers=["cloud-001", "cloud-002"], + roles=["viewer"], + permissions=["read:metrics"], + fetched_at=datetime(2024, 1, 15, 12, 0, 0), + ) + + +class TestPermissionCache: + """Tests for PermissionCache.""" + + @pytest.mark.asyncio + async def test_is_available_false_when_no_redis(self, permission_cache): + """Test is_available returns False without Redis.""" + assert permission_cache.is_available is False + + @pytest.mark.asyncio + async def test_is_available_true_with_redis(self, permission_cache): + """Test is_available returns True with Redis.""" + permission_cache._redis = AsyncMock() + assert permission_cache.is_available is True + + @pytest.mark.asyncio + async def test_get_permissions_returns_none_without_redis(self, permission_cache): + """Test get returns None when Redis is unavailable.""" + result = await permission_cache.get_permissions("user@example.com") + assert result is None + + @pytest.mark.asyncio + async def test_get_permissions_cache_miss(self, permission_cache): + """Test get returns None on cache miss.""" + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value=None) + permission_cache._redis = mock_redis + + result = await permission_cache.get_permissions("user@example.com") + assert result is None + + @pytest.mark.asyncio + async def test_get_permissions_cache_hit( + self, permission_cache, sample_permissions + ): + """Test get returns cached permissions.""" + mock_redis = AsyncMock() + mock_redis.get = AsyncMock( + return_value=json.dumps(sample_permissions.to_cache_dict()) + ) + permission_cache._redis = mock_redis + + result = await permission_cache.get_permissions("user@example.com") + + assert result is not None + assert result.email == "user@example.com" + assert "cloud-001" in result.customers + + @pytest.mark.asyncio + async def test_set_permissions_success( + self, permission_cache, sample_permissions + ): + """Test setting permissions in cache.""" + mock_redis = AsyncMock() + mock_redis.set = AsyncMock(return_value=True) + permission_cache._redis = mock_redis + + result = await permission_cache.set_permissions( + "user@example.com", sample_permissions + ) + + assert result is True + mock_redis.set.assert_called_once() + + @pytest.mark.asyncio + async def test_set_permissions_with_custom_ttl( + self, permission_cache, sample_permissions + ): + """Test setting permissions with custom TTL.""" + mock_redis = AsyncMock() + mock_redis.set = AsyncMock(return_value=True) + permission_cache._redis = mock_redis + + await permission_cache.set_permissions( + "user@example.com", sample_permissions, ttl=600 + ) + + call_args = mock_redis.set.call_args + assert call_args[1]["ex"] == 600 + + @pytest.mark.asyncio + async def test_invalidate_success(self, permission_cache): + """Test invalidating cached permissions.""" + mock_redis = AsyncMock() + mock_redis.delete = AsyncMock(return_value=1) + permission_cache._redis = mock_redis + + result = await permission_cache.invalidate("user@example.com") + + assert result is True + + @pytest.mark.asyncio + async def test_invalidate_not_found(self, permission_cache): + """Test invalidating non-existent cache entry.""" + mock_redis = AsyncMock() + mock_redis.delete = AsyncMock(return_value=0) + permission_cache._redis = mock_redis + + result = await permission_cache.invalidate("user@example.com") + + assert result is False + + @pytest.mark.asyncio + async def test_get_or_fetch_from_cache( + self, permission_cache, sample_permissions + ): + """Test get_or_fetch returns cached value.""" + mock_redis = AsyncMock() + mock_redis.get = AsyncMock( + return_value=json.dumps(sample_permissions.to_cache_dict()) + ) + permission_cache._redis = mock_redis + + fetch_func = AsyncMock() + + result = await permission_cache.get_or_fetch( + "user@example.com", fetch_func + ) + + assert result.email == "user@example.com" + fetch_func.assert_not_called() + + @pytest.mark.asyncio + async def test_get_or_fetch_fetches_on_miss( + self, permission_cache, sample_permissions + ): + """Test get_or_fetch fetches and caches on miss.""" + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value=None) + mock_redis.set = AsyncMock(return_value=True) + permission_cache._redis = mock_redis + + fetch_func = AsyncMock(return_value=sample_permissions) + + result = await permission_cache.get_or_fetch( + "user@example.com", fetch_func + ) + + assert result.email == "user@example.com" + fetch_func.assert_called_once() + mock_redis.set.assert_called_once() + + def test_make_key_consistent(self, permission_cache): + """Test key generation is consistent.""" + key1 = permission_cache._make_key("user@example.com") + key2 = permission_cache._make_key("user@example.com") + key3 = permission_cache._make_key("USER@EXAMPLE.COM") + + assert key1 == key2 + assert key1 == key3 # Case insensitive + + def test_make_key_different_emails(self, permission_cache): + """Test different emails produce different keys.""" + key1 = permission_cache._make_key("user1@example.com") + key2 = permission_cache._make_key("user2@example.com") + + assert key1 != key2 + + +class TestTokenCache: + """Tests for TokenCache.""" + + def test_hash_token(self): + """Test token hashing.""" + hash1 = TokenCache.hash_token("token123") + hash2 = TokenCache.hash_token("token123") + hash3 = TokenCache.hash_token("token456") + + assert hash1 == hash2 + assert hash1 != hash3 + assert len(hash1) == 64 # SHA256 hex length + + @pytest.mark.asyncio + async def test_is_token_valid_not_cached(self, token_cache): + """Test checking uncached token.""" + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value=None) + token_cache._redis = mock_redis + + result = await token_cache.is_token_valid("somehash") + assert result is None + + @pytest.mark.asyncio + async def test_is_token_valid_cached_valid(self, token_cache): + """Test checking cached valid token.""" + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value="1") + token_cache._redis = mock_redis + + result = await token_cache.is_token_valid("somehash") + assert result is True + + @pytest.mark.asyncio + async def test_is_token_valid_cached_invalid(self, token_cache): + """Test checking cached invalid token.""" + mock_redis = AsyncMock() + mock_redis.get = AsyncMock(return_value="0") + token_cache._redis = mock_redis + + result = await token_cache.is_token_valid("somehash") + assert result is False + + @pytest.mark.asyncio + async def test_cache_token_result(self, token_cache): + """Test caching token validation result.""" + mock_redis = AsyncMock() + mock_redis.set = AsyncMock(return_value=True) + token_cache._redis = mock_redis + + await token_cache.cache_token_result("somehash", True, ttl=120) + + mock_redis.set.assert_called_once() + call_args = mock_redis.set.call_args + assert call_args[0][1] == "1" + assert call_args[1]["ex"] == 120 From 4c7b48186e4ed657ff52115287946edfcd72037f Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:45 -0300 Subject: [PATCH 23/33] fix(tcloud_cognito_auth): add test_cognito.py --- .../tcloud_cognito_auth/tests/test_cognito.py | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/tests/test_cognito.py diff --git a/plugins/tcloud_cognito_auth/tests/test_cognito.py b/plugins/tcloud_cognito_auth/tests/test_cognito.py new file mode 100644 index 0000000..5a56439 --- /dev/null +++ b/plugins/tcloud_cognito_auth/tests/test_cognito.py @@ -0,0 +1,155 @@ +"""Tests for Cognito JWT validation.""" + +import time +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from tcloud_cognito_auth.cognito import CognitoJWTValidator +from tcloud_cognito_auth.exceptions import ( + InvalidAudienceError, + JWKSFetchError, + KeyNotFoundError, + TokenExpiredError, + TokenValidationError, +) + + +@pytest.fixture +def validator(test_settings): + """Create a validator instance.""" + return CognitoJWTValidator(test_settings) + + +class TestCognitoJWTValidator: + """Tests for CognitoJWTValidator.""" + + @pytest.mark.asyncio + async def test_initialize_and_shutdown(self, validator): + """Test validator initialization and shutdown.""" + with patch.object(validator, "_refresh_jwks", new_callable=AsyncMock): + await validator.initialize() + assert validator._http_client is not None + + await validator.shutdown() + assert validator._http_client is None + + @pytest.mark.asyncio + async def test_get_jwks_uses_cache(self, validator, sample_jwks): + """Test that JWKS is cached.""" + validator._jwks_cache = sample_jwks + validator._jwks_cache_time = time.time() + + jwks = await validator._get_jwks() + assert jwks == sample_jwks + + @pytest.mark.asyncio + async def test_get_jwks_refreshes_when_expired(self, validator, sample_jwks): + """Test that JWKS is refreshed when cache expires.""" + validator._jwks_cache = sample_jwks + validator._jwks_cache_time = time.time() - 7200 # 2 hours ago + + with patch.object(validator, "_refresh_jwks", new_callable=AsyncMock) as mock: + await validator._get_jwks() + mock.assert_called_once() + + @pytest.mark.asyncio + async def test_refresh_jwks_success(self, validator, sample_jwks): + """Test successful JWKS refresh.""" + mock_response = AsyncMock() + mock_response.json.return_value = sample_jwks + mock_response.raise_for_status = AsyncMock() + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + validator._http_client = mock_client + + await validator._refresh_jwks() + + assert validator._jwks_cache == sample_jwks + assert validator._jwks_cache_time > 0 + + @pytest.mark.asyncio + async def test_refresh_jwks_failure_without_cache(self, validator): + """Test JWKS refresh failure without cached data.""" + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection failed")) + validator._http_client = mock_client + validator._jwks_cache = None + + with pytest.raises(JWKSFetchError): + await validator._refresh_jwks() + + @pytest.mark.asyncio + async def test_refresh_jwks_failure_with_cache_fallback( + self, validator, sample_jwks + ): + """Test JWKS refresh failure falls back to cache.""" + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection failed")) + validator._http_client = mock_client + validator._jwks_cache = sample_jwks + + # Should not raise, just use cached value + await validator._refresh_jwks() + assert validator._jwks_cache == sample_jwks + + @pytest.mark.asyncio + async def test_get_signing_key_found(self, validator, sample_jwks): + """Test getting signing key by kid.""" + validator._jwks_cache = sample_jwks + validator._jwks_cache_time = time.time() + + key = await validator._get_signing_key("test-key-id-1") + assert key["kid"] == "test-key-id-1" + + @pytest.mark.asyncio + async def test_get_signing_key_not_found(self, validator, sample_jwks): + """Test key not found error.""" + validator._jwks_cache = sample_jwks + validator._jwks_cache_time = time.time() + + with patch.object(validator, "_refresh_jwks", new_callable=AsyncMock): + with pytest.raises(KeyNotFoundError): + await validator._get_signing_key("non-existent-key") + + def test_extract_token_from_header_valid(self, validator): + """Test extracting token from valid header.""" + token = validator.extract_token_from_header("Bearer abc123") + assert token == "abc123" + + def test_extract_token_from_header_invalid(self, validator): + """Test extracting token from invalid headers.""" + assert validator.extract_token_from_header(None) is None + assert validator.extract_token_from_header("") is None + assert validator.extract_token_from_header("Basic abc123") is None + assert validator.extract_token_from_header("Bearer") is None + assert validator.extract_token_from_header("abc123") is None + + +class TestTokenValidation: + """Tests for token validation logic.""" + + @pytest.mark.asyncio + async def test_validate_token_missing_kid(self, validator): + """Test validation fails for token without kid.""" + # Create a token without kid in header + with patch("jose.jwt.get_unverified_header", return_value={}): + with pytest.raises(TokenValidationError, match="missing 'kid'"): + await validator.validate_token("invalid.token.here") + + @pytest.mark.asyncio + async def test_validate_token_invalid_client_id( + self, validator, sample_jwks, sample_access_token_claims + ): + """Test validation fails for invalid client_id.""" + # This would require a properly signed token which is complex to mock + # In real tests, we'd use a test RSA key pair + pass # Placeholder for integration test + + @pytest.mark.asyncio + async def test_validate_token_expired(self, validator): + """Test validation fails for expired token.""" + # Placeholder - requires properly signed expired token + pass From cb5586a39d7e2dadd81a46c5d549f599bbffd0ed Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:48 -0300 Subject: [PATCH 24/33] fix(test_plugin): add tests for TCloudCognitoAuthPlugin --- .../tcloud_cognito_auth/tests/test_plugin.py | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/tests/test_plugin.py diff --git a/plugins/tcloud_cognito_auth/tests/test_plugin.py b/plugins/tcloud_cognito_auth/tests/test_plugin.py new file mode 100644 index 0000000..31ea7d1 --- /dev/null +++ b/plugins/tcloud_cognito_auth/tests/test_plugin.py @@ -0,0 +1,273 @@ +"""Tests for the main plugin.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tcloud_cognito_auth.exceptions import TokenExpiredError, TokenValidationError +from tcloud_cognito_auth.models import AuthenticatedUser, CognitoClaims, UserPermissions +from tcloud_cognito_auth.tcloud_cognito_auth import TCloudCognitoAuthPlugin + + +@pytest.fixture +def plugin(test_settings): + """Create a plugin instance with test settings.""" + plugin = TCloudCognitoAuthPlugin() + plugin._settings = test_settings + return plugin + + +@pytest.fixture +def mock_cognito_validator(): + """Create a mock Cognito validator.""" + return AsyncMock() + + +@pytest.fixture +def mock_tcloud_client(): + """Create a mock TCloud client.""" + return AsyncMock() + + +@pytest.fixture +def mock_permission_cache(): + """Create a mock permission cache.""" + cache = AsyncMock() + cache.get_or_fetch = AsyncMock() + return cache + + +@pytest.fixture +def sample_claims(): + """Create sample Cognito claims.""" + return CognitoClaims( + sub="12345678-1234-1234-1234-123456789012", + iss="https://cognito-idp.sa-east-1.amazonaws.com/sa-east-1_TestPool123", + token_use="access", + client_id="test-client-id-123", + username="google_user@example.com", + exp=9999999999, + iat=1000000000, + name="Test User", + ) + + +@pytest.fixture +def sample_user_permissions(): + """Create sample user permissions.""" + return UserPermissions( + email="user@example.com", + customers=["cloud-001", "cloud-002"], + roles=["viewer"], + permissions=["read:metrics"], + ) + + +class TestTCloudCognitoAuthPlugin: + """Tests for TCloudCognitoAuthPlugin.""" + + @pytest.mark.asyncio + async def test_initialize_and_shutdown(self, plugin): + """Test plugin initialization and shutdown.""" + with patch.object(plugin, "_cognito_validator") as mock_validator, \ + patch.object(plugin, "_tcloud_client") as mock_client, \ + patch.object(plugin, "_permission_cache") as mock_cache: + + mock_validator = AsyncMock() + mock_client = AsyncMock() + mock_cache = AsyncMock() + + plugin._cognito_validator = mock_validator + plugin._tcloud_client = mock_client + plugin._permission_cache = mock_cache + plugin._initialized = True + + await plugin.shutdown() + + assert plugin._initialized is False + + @pytest.mark.asyncio + async def test_http_auth_resolve_user_no_token(self, plugin): + """Test auth with no bearer token.""" + plugin._initialized = True + + result = await plugin.http_auth_resolve_user({ + "credentials": {} + }) + + assert result["continue_processing"] is True + assert "modified_payload" not in result + + @pytest.mark.asyncio + async def test_http_auth_resolve_user_non_bearer(self, plugin): + """Test auth with non-bearer scheme.""" + plugin._initialized = True + + result = await plugin.http_auth_resolve_user({ + "credentials": { + "credentials": "sometoken", + "scheme": "basic" + } + }) + + assert result["continue_processing"] is True + + @pytest.mark.asyncio + async def test_http_auth_resolve_user_success( + self, plugin, sample_claims, sample_user_permissions + ): + """Test successful authentication.""" + plugin._initialized = True + + # Mock dependencies + mock_validator = AsyncMock() + mock_validator.validate_token = AsyncMock(return_value=sample_claims) + plugin._cognito_validator = mock_validator + + mock_cache = AsyncMock() + mock_cache.get_or_fetch = AsyncMock(return_value=sample_user_permissions) + plugin._permission_cache = mock_cache + + result = await plugin.http_auth_resolve_user({ + "credentials": { + "credentials": "valid.jwt.token", + "scheme": "bearer" + } + }) + + assert result["continue_processing"] is True + assert "modified_payload" in result + assert result["modified_payload"]["email"] == "user@example.com" + assert "metadata" in result + assert result["metadata"]["auth_method"] == "cognito" + + @pytest.mark.asyncio + async def test_http_auth_resolve_user_expired_token(self, plugin): + """Test authentication with expired token.""" + plugin._initialized = True + + mock_validator = AsyncMock() + mock_validator.validate_token = AsyncMock( + side_effect=TokenExpiredError("Token expired") + ) + plugin._cognito_validator = mock_validator + + result = await plugin.http_auth_resolve_user({ + "credentials": { + "credentials": "expired.jwt.token", + "scheme": "bearer" + } + }) + + assert result["continue_processing"] is False + assert "error" in result + assert result["error"]["code"] == "TOKEN_EXPIRED" + + @pytest.mark.asyncio + async def test_http_auth_resolve_user_invalid_token(self, plugin): + """Test authentication with invalid token.""" + plugin._initialized = True + + mock_validator = AsyncMock() + mock_validator.validate_token = AsyncMock( + side_effect=TokenValidationError("Invalid token", "INVALID_TOKEN") + ) + plugin._cognito_validator = mock_validator + + result = await plugin.http_auth_resolve_user({ + "credentials": { + "credentials": "invalid.jwt.token", + "scheme": "bearer" + } + }) + + assert result["continue_processing"] is False + assert "error" in result + + +class TestAgentPreInvoke: + """Tests for agent_pre_invoke hook.""" + + @pytest.mark.asyncio + async def test_agent_pre_invoke_no_user(self, plugin): + """Test pre_invoke with no authenticated user.""" + plugin._initialized = True + + result = await plugin.agent_pre_invoke( + {"headers": {}}, + context={} + ) + + assert result["continue_processing"] is True + assert "modified_payload" not in result + + @pytest.mark.asyncio + async def test_agent_pre_invoke_with_user(self, plugin): + """Test pre_invoke injects headers.""" + plugin._initialized = True + + context = { + "user": {"email": "user@example.com"}, + "metadata": { + "auth_method": "cognito", + "customers": ["cloud-001", "cloud-002"], + }, + "request_id": "req-123", + } + + result = await plugin.agent_pre_invoke( + {"headers": {"Existing": "header"}}, + context=context + ) + + assert result["continue_processing"] is True + assert "modified_payload" in result + + headers = result["modified_payload"]["headers"] + assert headers["Existing"] == "header" + assert headers["X-User-Email"] == "user@example.com" + assert headers["X-Request-ID"] == "req-123" + + customers = json.loads(headers["X-User-Customers"]) + assert "cloud-001" in customers + assert "cloud-002" in customers + + @pytest.mark.asyncio + async def test_agent_pre_invoke_disabled(self, plugin, test_settings): + """Test pre_invoke when header propagation is disabled.""" + test_settings.enable_header_propagation = False + plugin._settings = test_settings + plugin._initialized = True + + context = { + "user": {"email": "user@example.com"}, + "metadata": {"auth_method": "cognito", "customers": []}, + } + + result = await plugin.agent_pre_invoke({}, context=context) + + assert result["continue_processing"] is True + assert "modified_payload" not in result + + +class TestCreatePlugin: + """Tests for plugin factory function.""" + + def test_create_plugin(self): + """Test creating plugin via factory function.""" + from tcloud_cognito_auth.tcloud_cognito_auth import create_plugin + + plugin = create_plugin({"custom": "config"}) + + assert isinstance(plugin, TCloudCognitoAuthPlugin) + assert plugin._config == {"custom": "config"} + + def test_create_plugin_no_config(self): + """Test creating plugin without config.""" + from tcloud_cognito_auth.tcloud_cognito_auth import create_plugin + + plugin = create_plugin() + + assert isinstance(plugin, TCloudCognitoAuthPlugin) + assert plugin._config == {} From 83ac6a5bb94b6451987d7e0b248f4b7713c6ae48 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:16:50 -0300 Subject: [PATCH 25/33] fix(tcloud_cognito_auth): add tests for API client --- .../tests/test_tcloud_api.py | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 plugins/tcloud_cognito_auth/tests/test_tcloud_api.py diff --git a/plugins/tcloud_cognito_auth/tests/test_tcloud_api.py b/plugins/tcloud_cognito_auth/tests/test_tcloud_api.py new file mode 100644 index 0000000..18b1b84 --- /dev/null +++ b/plugins/tcloud_cognito_auth/tests/test_tcloud_api.py @@ -0,0 +1,166 @@ +"""Tests for TCloud API client.""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from tcloud_cognito_auth.exceptions import TCloudAPIError +from tcloud_cognito_auth.tcloud_api import TCloudAPIClient + + +@pytest.fixture +def api_client(test_settings): + """Create an API client instance.""" + return TCloudAPIClient(test_settings) + + +class TestTCloudAPIClient: + """Tests for TCloudAPIClient.""" + + @pytest.mark.asyncio + async def test_initialize_and_shutdown(self, api_client): + """Test client initialization and shutdown.""" + await api_client.initialize() + assert api_client._http_client is not None + + await api_client.shutdown() + assert api_client._http_client is None + + @pytest.mark.asyncio + async def test_get_user_permissions_success( + self, api_client, sample_user_permissions + ): + """Test successful permission fetch.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"cloud_id": "cloud-001", "role": "viewer"}, + {"cloud_id": "cloud-002", "role": "operator"}, + {"cloud_id": "cloud-003", "role": "viewer"}, + ] + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + api_client._http_client = mock_client + + permissions = await api_client.get_user_permissions("user@example.com") + + assert permissions.email == "user@example.com" + assert "cloud-001" in permissions.customers + assert "cloud-002" in permissions.customers + assert "cloud-003" in permissions.customers + + @pytest.mark.asyncio + async def test_get_user_permissions_unauthorized(self, api_client): + """Test unauthorized response.""" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + api_client._http_client = mock_client + + with pytest.raises(TCloudAPIError) as exc_info: + await api_client.get_user_permissions("user@example.com") + + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_get_user_permissions_forbidden(self, api_client): + """Test forbidden response (no permissions).""" + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "Forbidden" + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + api_client._http_client = mock_client + + permissions = await api_client.get_user_permissions("user@example.com") + + assert permissions.email == "user@example.com" + assert permissions.customers == [] + + @pytest.mark.asyncio + async def test_get_user_permissions_timeout(self, api_client): + """Test timeout handling.""" + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) + api_client._http_client = mock_client + + with pytest.raises(TCloudAPIError, match="timeout"): + await api_client.get_user_permissions("user@example.com") + + @pytest.mark.asyncio + async def test_get_user_permissions_with_bearer_token(self, api_client): + """Test passing bearer token to API.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + api_client._http_client = mock_client + + await api_client.get_user_permissions( + "user@example.com", bearer_token="test-token" + ) + + # Verify Authorization header was passed + call_args = mock_client.get.call_args + assert call_args[1]["headers"]["Authorization"] == "Bearer test-token" + + +class TestExtractMethods: + """Tests for data extraction methods.""" + + @pytest.fixture + def client(self, test_settings): + """Create a client instance.""" + return TCloudAPIClient(test_settings) + + def test_extract_customers_from_list(self, client): + """Test extracting customers from list response.""" + data = [ + {"cloud_id": "cloud-001"}, + {"cloud_id": "cloud-002"}, + {"cloudId": "cloud-003"}, + {"id": "cloud-004"}, + ] + customers = client._extract_customers(data) + assert set(customers) == {"cloud-001", "cloud-002", "cloud-003", "cloud-004"} + + def test_extract_customers_from_dict(self, client): + """Test extracting customers from dict response.""" + data = { + "customers": [ + {"cloud_id": "cloud-001"}, + {"cloud_id": "cloud-002"}, + ] + } + customers = client._extract_customers(data) + assert set(customers) == {"cloud-001", "cloud-002"} + + def test_extract_roles(self, client): + """Test extracting roles.""" + data = [ + {"cloud_id": "cloud-001", "role": "viewer"}, + {"cloud_id": "cloud-002", "role": "operator"}, + {"cloud_id": "cloud-003", "role": "viewer"}, + ] + roles = client._extract_roles(data) + assert set(roles) == {"viewer", "operator"} + + def test_extract_permissions_default(self, client): + """Test default permissions for users with customers.""" + data = [{"cloud_id": "cloud-001"}] + permissions = client._extract_permissions(data) + assert "read:metrics" in permissions + assert "read:logs" in permissions + + def test_extract_permissions_empty(self, client): + """Test no default permissions for empty customer list.""" + permissions = client._extract_permissions([]) + assert permissions == [] From b1d7bee51db675355c42c942a51a242631debde4 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Thu, 22 Jan 2026 11:20:21 -0300 Subject: [PATCH 26/33] chore: --- CLAUDE.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e9b233e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TCloud MCP Platform is a centralized orchestration platform for MCP (Model Context Protocol) Agents. It manages the MCP Context Forge gateway deployment (IBM) and provides templates for teams to create new agents. + +## Common Commands + +```bash +# Deploy Context Forge gateway +make deploy-context-forge ENV=dev # or ENV=prod + +# Check deployment status +make status ENV=dev + +# View gateway logs +make logs ENV=dev + +# Port forward for local testing +make port-forward ENV=dev # Access at http://localhost:9080 + +# Test endpoints +make test-health # Requires port-forward first +make test-mcp # Test MCP tools/list + +# Create new agent from template +make new-agent NAME=my-agent + +# Plugin management +make test-plugin # Run plugin unit tests +make build-plugin-configmap # Build ConfigMap from plugin code +make deploy-plugin-configmap ENV=dev # Deploy plugin to cluster + +# Render Helm templates locally (dry-run) +make template ENV=dev +``` + +## Architecture + +``` +Clients โ†’ Orchestrator Agent โ†’ MCP Context Forge (Gateway) โ†’ Specialist Agents + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ†“ โ†“ โ†“ + CPU/RAM Agent DB Agent App Agent +``` + +**Key Components:** +- **MCP Context Forge**: Central gateway that federates multiple MCP servers (IBM upstream chart) +- **Specialist Agents**: Domain-specific agents (CPU/RAM, Database, Network, etc.) +- **Authentication Plugin**: `plugins/tcloud_cognito_auth/` - Cognito JWT validation + TCloud API permissions + +## Project Structure + +- `infrastructure/context-forge/` - Helm values for Context Forge deployment + - `values.yaml` - Base configuration + - `values-dev.yaml` - Dev environment overrides + - `values-prod.yaml` - Prod environment overrides +- `plugins/tcloud_cognito_auth/` - Authentication plugin (Cognito + TCloud API) +- `templates/mcp-agent-docker/` - Template for creating new MCP agents +- `docs/` - Architecture, agent creation guide, authentication docs + +## Environments + +| Environment | Namespace | Gateway URL | +|-------------|-----------|-------------| +| Dev | mcp-dev | https://mcp-gateway.tbf8b9d.k8s.sp06.te.tks.sh | +| Prod | mcp | https://mcp-gateway.tcloud.internal (planned) | + +## Code Conventions + +**MCP Agent Tool Response Format** (all diagnostic tools must use): +```python +{ + "agent": "agent-name", + "timestamp": "ISO8601", + "severity": "critical|warning|normal", + "summary": "One-line summary", + "findings": [{"type": "...", "severity": "...", "details": "...", "evidence": {}}], + "recommendations": ["Action 1", "Action 2"] +} +``` + +**Git**: Use conventional commits (`feat:`, `fix:`, `docs:`, `chore:`) + +## Serena MCP Integration + +This project has Serena MCP configured. When Serena is active, memories are available in `.serena/memories/`: +- `project_overview` - Architecture and structure +- `suggested_commands` - Common kubectl and make commands +- `code_style_conventions` - Code style guidelines From 489186f740a4097bce7dbfde1b05861c22c15d40 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Fri, 23 Jan 2026 13:11:47 -0300 Subject: [PATCH 27/33] fix(plugins/tcloud_cognito_auth): add tcloud_cognito_auth plugin to context-forge --- infrastructure/context-forge/Dockerfile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 infrastructure/context-forge/Dockerfile diff --git a/infrastructure/context-forge/Dockerfile b/infrastructure/context-forge/Dockerfile new file mode 100644 index 0000000..aba1b69 --- /dev/null +++ b/infrastructure/context-forge/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim AS builder + +# Install dependencies to a directory we can copy +RUN pip install --target=/deps --no-cache-dir \ + python-jose[cryptography]>=3.3.0 \ + pydantic-settings>=2.0.0 + +FROM ghcr.io/ibm/mcp-context-forge:latest + +# Copy installed dependencies +COPY --from=builder /deps /app/.venv/lib/python3.12/site-packages/ + +# Copy plugin code +COPY plugins/tcloud_cognito_auth /app/plugins/tcloud_cognito_auth From 8b6df05372966f7942eeb7ba624c203f928dc835 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Fri, 23 Jan 2026 13:11:53 -0300 Subject: [PATCH 28/33] fix(tcloud_cognito_auth): update Cognito JWT validator and TCloud API client --- .../tcloud_cognito_auth.py | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py b/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py index 97a25f3..d088c3a 100644 --- a/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py +++ b/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py @@ -8,6 +8,17 @@ import logging from typing import Any +# MCP Context Forge imports +try: + from mcpgateway.plugins.framework.base import Plugin + from mcpgateway.plugins.framework.models import PluginConfig, PluginContext, PluginResult +except ImportError: + # Fallback for standalone testing + Plugin = object + PluginConfig = Any + PluginContext = Any + PluginResult = dict + from .cache import PermissionCache from .cognito import CognitoJWTValidator from .config import PluginSettings, get_settings @@ -23,7 +34,7 @@ logger = logging.getLogger(__name__) -class TCloudCognitoAuthPlugin: +class TCloudCognitoAuthPlugin(Plugin): """TCloud Cognito Authentication Plugin. Implements http_auth_resolve_user and agent_pre_invoke hooks for @@ -31,47 +42,47 @@ class TCloudCognitoAuthPlugin: permission caching. """ - def __init__(self, config: dict[str, Any] | None = None): + def __init__(self, config: PluginConfig): """Initialize the plugin. Args: - config: Optional configuration dictionary from Context Forge. + config: Plugin configuration from Context Forge. """ - self._config = config or {} - self._settings: PluginSettings | None = None + super().__init__(config) + self._plugin_settings: PluginSettings | None = None self._cognito_validator: CognitoJWTValidator | None = None self._tcloud_client: TCloudAPIClient | None = None self._permission_cache: PermissionCache | None = None - self._initialized = False + self._plugin_initialized = False @property - def settings(self) -> PluginSettings: + def plugin_settings(self) -> PluginSettings: """Get plugin settings, loading from environment if needed.""" - if not self._settings: - self._settings = get_settings() - return self._settings + if not self._plugin_settings: + self._plugin_settings = get_settings() + return self._plugin_settings async def initialize(self) -> None: """Initialize plugin resources. Called by Context Forge when the plugin is loaded. """ - if self._initialized: + if self._plugin_initialized: return logger.info("Initializing TCloud Cognito Auth Plugin") # Initialize components - self._cognito_validator = CognitoJWTValidator(self.settings) + self._cognito_validator = CognitoJWTValidator(self.plugin_settings) await self._cognito_validator.initialize() - self._tcloud_client = TCloudAPIClient(self.settings) + self._tcloud_client = TCloudAPIClient(self.plugin_settings) await self._tcloud_client.initialize() - self._permission_cache = PermissionCache(self.settings) + self._permission_cache = PermissionCache(self.plugin_settings) await self._permission_cache.initialize() - self._initialized = True + self._plugin_initialized = True logger.info("TCloud Cognito Auth Plugin initialized successfully") async def shutdown(self) -> None: @@ -88,7 +99,7 @@ async def shutdown(self) -> None: if self._permission_cache: await self._permission_cache.shutdown() - self._initialized = False + self._plugin_initialized = False async def http_auth_resolve_user( self, @@ -112,7 +123,7 @@ async def http_auth_resolve_user( - metadata: Additional info including permissions - continue_processing: Whether to continue auth chain """ - if not self._initialized: + if not self._plugin_initialized: await self.initialize() # Extract credentials from payload @@ -212,7 +223,7 @@ async def agent_pre_invoke( - modified_payload: Updated payload with injected headers - continue_processing: Always True to continue """ - if not self.settings.enable_header_propagation: + if not self.plugin_settings.enable_header_propagation: return {"continue_processing": True} # Get user info from context From 2dd213962857ccf23c13e81fd7cc245111f58a94 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Fri, 23 Jan 2026 13:11:58 -0300 Subject: [PATCH 29/33] fix(infrastructure/context-forge): update plugin configuration --- infrastructure/context-forge/values.yaml | 37 +++++++++++++++--------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/infrastructure/context-forge/values.yaml b/infrastructure/context-forge/values.yaml index 919fe23..0052739 100644 --- a/infrastructure/context-forge/values.yaml +++ b/infrastructure/context-forge/values.yaml @@ -1,6 +1,10 @@ # MCP Context Forge - Base Configuration # Chart: https://github.com/IBM/mcp-context-forge +global: + imagePullSecrets: + - ghcr-secret + mcpContextForge: env: postgres: @@ -8,9 +12,9 @@ mcpContextForge: replicaCount: 2 image: - repository: ghcr.io/ibm/mcp-context-forge - tag: latest - pullPolicy: IfNotPresent + repository: ghcr.io/tcloud-dev/mcp-context-forge + tag: with-auth + pullPolicy: Always service: type: ClusterIP @@ -89,17 +93,22 @@ mcpContextForge: - name: JWKS_CACHE_TTL value: "3600" - # Mount plugin code from ConfigMap - extraVolumeMounts: - - name: tcloud-cognito-plugin - mountPath: /etc/mcpgateway/plugins/tcloud_cognito_auth - readOnly: true - - extraVolumes: - - name: tcloud-cognito-plugin - configMap: - name: tcloud-cognito-auth-plugin - optional: true + # Plugin configuration + pluginConfig: + enabled: true + plugins: | + plugin_settings: + enable_plugin_api: true + plugin_timeout: 30 + fail_on_plugin_error: false + plugins: + - name: TCloudCognitoAuthPlugin + kind: plugins.tcloud_cognito_auth.tcloud_cognito_auth.TCloudCognitoAuthPlugin + hooks: + - http_auth_resolve_user + - agent_pre_invoke + mode: enforce + priority: 10 postgres: enabled: true From 46ceb278a1c811f073bf491fde1245dece3bab20 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Fri, 23 Jan 2026 13:12:03 -0300 Subject: [PATCH 30/33] refactor(infrastructure/context-forge): remove class name from ingress configuration --- infrastructure/context-forge/values-dev.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/context-forge/values-dev.yaml b/infrastructure/context-forge/values-dev.yaml index 22322d4..d1eba54 100644 --- a/infrastructure/context-forge/values-dev.yaml +++ b/infrastructure/context-forge/values-dev.yaml @@ -20,7 +20,7 @@ mcpContextForge: ingress: enabled: true - className: "nginx" # Patched out after deploy to use public ingress + # className removed - uses default external ingress (201.157.216.223) host: mcp-gateway.tbf8b9d.k8s.sp06.te.tks.sh path: / pathType: Prefix From 4a65272fc4b5a32295ba79f07c0776e2203b36c9 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Fri, 23 Jan 2026 13:12:09 -0300 Subject: [PATCH 31/33] build: add plugin configmap with Cognito authentication capabilities --- .../context-forge/plugin-configmap.yaml | 1317 ++++++++++++++++- 1 file changed, 1298 insertions(+), 19 deletions(-) diff --git a/infrastructure/context-forge/plugin-configmap.yaml b/infrastructure/context-forge/plugin-configmap.yaml index 2cc05b4..84f059d 100644 --- a/infrastructure/context-forge/plugin-configmap.yaml +++ b/infrastructure/context-forge/plugin-configmap.yaml @@ -1,26 +1,1305 @@ -# TCloud Cognito Auth Plugin - ConfigMap -# -# This ConfigMap is auto-generated by: -# make build-plugin-configmap -# -# It contains the plugin Python code to be mounted into the Context Forge container. - apiVersion: v1 -kind: ConfigMap -metadata: - name: tcloud-cognito-auth-plugin - labels: - app.kubernetes.io/name: tcloud-cognito-auth - app.kubernetes.io/component: plugin - app.kubernetes.io/part-of: mcp-context-forge data: - # Plugin files will be populated by 'make build-plugin-configmap' - # Each .py file from plugins/tcloud_cognito_auth/ will be a key - + __init__.py: | + """TCloud Cognito Authentication Plugin for MCP Context Forge. + + This plugin provides: + - JWT validation via AWS Cognito + - User permissions fetching from TCloud API + - Redis caching for permissions + - Header propagation to downstream agents + """ + + __version__ = "1.0.0" + __author__ = "TCloud Platform Team" + + from .tcloud_cognito_auth import TCloudCognitoAuthPlugin + + __all__ = ["TCloudCognitoAuthPlugin"] + cache.py: | + """Redis cache for user permissions.""" + + import hashlib + import json + import logging + from typing import Any + + import redis.asyncio as redis + + from .config import PluginSettings + from .exceptions import CacheError + from .models import UserPermissions + + logger = logging.getLogger(__name__) + + + class PermissionCache: + """Redis-based cache for user permissions.""" + + CACHE_PREFIX = "tcloud:auth:permissions:" + + def __init__(self, settings: PluginSettings): + """Initialize the permission cache. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._redis: redis.Redis | None = None + + async def initialize(self) -> None: + """Initialize Redis connection.""" + try: + self._redis = redis.from_url( + self.settings.redis_url, + encoding="utf-8", + decode_responses=True, + ) + # Test connection + await self._redis.ping() + logger.info("Redis cache initialized successfully") + except redis.RedisError as e: + logger.warning(f"Failed to connect to Redis: {e}. Cache disabled.") + self._redis = None + + async def shutdown(self) -> None: + """Close Redis connection.""" + if self._redis: + await self._redis.close() + self._redis = None + + @property + def is_available(self) -> bool: + """Check if cache is available.""" + return self._redis is not None + + async def get_permissions(self, email: str) -> UserPermissions | None: + """Get cached permissions for a user. + + Args: + email: User email address. + + Returns: + Cached UserPermissions or None if not found/expired. + """ + if not self._redis: + return None + + cache_key = self._make_key(email) + + try: + data = await self._redis.get(cache_key) + if data: + parsed = json.loads(data) + logger.debug(f"Cache hit for {email}") + return UserPermissions.from_cache_dict(parsed) + logger.debug(f"Cache miss for {email}") + return None + except redis.RedisError as e: + logger.warning(f"Redis get error: {e}") + return None + except (json.JSONDecodeError, ValueError) as e: + logger.warning(f"Cache data parse error: {e}") + return None + + async def set_permissions( + self, + email: str, + permissions: UserPermissions, + ttl: int | None = None, + ) -> bool: + """Cache user permissions. + + Args: + email: User email address. + permissions: UserPermissions to cache. + ttl: Optional TTL override in seconds. + + Returns: + True if cached successfully, False otherwise. + """ + if not self._redis: + return False + + cache_key = self._make_key(email) + cache_ttl = ttl or self.settings.permission_cache_ttl + + try: + data = json.dumps(permissions.to_cache_dict()) + await self._redis.set(cache_key, data, ex=cache_ttl) + logger.debug(f"Cached permissions for {email} (TTL: {cache_ttl}s)") + return True + except redis.RedisError as e: + logger.warning(f"Redis set error: {e}") + return False + + async def invalidate(self, email: str) -> bool: + """Invalidate cached permissions for a user. + + Args: + email: User email address. + + Returns: + True if invalidated, False otherwise. + """ + if not self._redis: + return False + + cache_key = self._make_key(email) + + try: + result = await self._redis.delete(cache_key) + logger.debug(f"Invalidated cache for {email}: {result}") + return result > 0 + except redis.RedisError as e: + logger.warning(f"Redis delete error: {e}") + return False + + async def get_or_fetch( + self, + email: str, + fetch_func, + ttl: int | None = None, + ) -> UserPermissions: + """Get permissions from cache or fetch and cache. + + Args: + email: User email address. + fetch_func: Async function to fetch permissions if not cached. + ttl: Optional TTL override in seconds. + + Returns: + UserPermissions from cache or freshly fetched. + """ + # Try cache first + cached = await self.get_permissions(email) + if cached: + return cached + + # Fetch fresh data + permissions = await fetch_func() + + # Cache the result (fire and forget) + await self.set_permissions(email, permissions, ttl) + + return permissions + + def _make_key(self, email: str) -> str: + """Generate cache key for email. + + Args: + email: User email address. + + Returns: + Cache key string. + """ + # Use hash to handle special characters and keep keys short + email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16] + return f"{self.CACHE_PREFIX}{email_hash}" + + + class TokenCache: + """Redis-based cache for validated tokens.""" + + CACHE_PREFIX = "tcloud:auth:token:" + + def __init__(self, settings: PluginSettings): + """Initialize the token cache. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._redis: redis.Redis | None = None + + async def initialize(self, redis_client: redis.Redis | None = None) -> None: + """Initialize Redis connection. + + Args: + redis_client: Optional existing Redis client to reuse. + """ + if redis_client: + self._redis = redis_client + else: + try: + self._redis = redis.from_url( + self.settings.redis_url, + encoding="utf-8", + decode_responses=True, + ) + await self._redis.ping() + except redis.RedisError as e: + logger.warning(f"Failed to connect to Redis: {e}. Token cache disabled.") + self._redis = None + + async def shutdown(self) -> None: + """Close Redis connection.""" + if self._redis: + await self._redis.close() + self._redis = None + + async def is_token_valid(self, token_hash: str) -> bool | None: + """Check if token was previously validated. + + Args: + token_hash: Hash of the token. + + Returns: + True if valid, False if invalid, None if not cached. + """ + if not self._redis: + return None + + cache_key = f"{self.CACHE_PREFIX}{token_hash}" + + try: + result = await self._redis.get(cache_key) + if result is None: + return None + return result == "1" + except redis.RedisError: + return None + + async def cache_token_result( + self, + token_hash: str, + is_valid: bool, + ttl: int = 60, + ) -> None: + """Cache token validation result. + + Args: + token_hash: Hash of the token. + is_valid: Whether the token is valid. + ttl: Cache TTL in seconds. + """ + if not self._redis: + return + + cache_key = f"{self.CACHE_PREFIX}{token_hash}" + + try: + await self._redis.set(cache_key, "1" if is_valid else "0", ex=ttl) + except redis.RedisError: + pass + + @staticmethod + def hash_token(token: str) -> str: + """Generate hash for token. + + Args: + token: JWT token string. + + Returns: + SHA256 hash of the token. + """ + return hashlib.sha256(token.encode()).hexdigest() + cognito.py: | + """AWS Cognito JWT validation for TCloud Auth Plugin.""" + + import time + from typing import Any + + import httpx + from jose import jwt, JWTError + from jose.exceptions import ExpiredSignatureError, JWTClaimsError + + from .config import PluginSettings + from .exceptions import ( + InvalidAudienceError, + InvalidIssuerError, + InvalidSignatureError, + JWKSFetchError, + KeyNotFoundError, + TokenExpiredError, + TokenValidationError, + ) + from .models import CognitoClaims + + + class CognitoJWTValidator: + """Validates JWT tokens issued by AWS Cognito.""" + + def __init__(self, settings: PluginSettings): + """Initialize the validator with settings. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._jwks_cache: dict[str, Any] | None = None + self._jwks_cache_time: float = 0 + self._http_client: httpx.AsyncClient | None = None + + async def initialize(self) -> None: + """Initialize the HTTP client and pre-fetch JWKS.""" + self._http_client = httpx.AsyncClient(timeout=10.0) + await self._refresh_jwks() + + async def shutdown(self) -> None: + """Clean up resources.""" + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + async def validate_token(self, token: str) -> CognitoClaims: + """Validate a Cognito JWT token. + + Args: + token: The JWT token string (without 'Bearer ' prefix). + + Returns: + Parsed and validated token claims. + + Raises: + TokenValidationError: If token validation fails. + TokenExpiredError: If token has expired. + InvalidSignatureError: If signature verification fails. + InvalidIssuerError: If issuer is invalid. + InvalidAudienceError: If audience/client_id is invalid. + """ + try: + # Get the key ID from the token header + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + + if not kid: + raise TokenValidationError("Token header missing 'kid'") + + # Get the signing key from JWKS + signing_key = await self._get_signing_key(kid) + + # Decode and validate the token + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + issuer=self.settings.cognito_issuer, + options={ + "verify_aud": False, # We'll verify client_id manually for access tokens + "leeway": self.settings.clock_skew_tolerance, + }, + ) + + # Parse claims into model + parsed_claims = CognitoClaims(**claims) + + # Validate client_id for access tokens + if parsed_claims.token_use == "access": + if parsed_claims.client_id != self.settings.cognito_app_client_id: + raise InvalidAudienceError( + f"Invalid client_id: {parsed_claims.client_id}" + ) + + return parsed_claims + + except ExpiredSignatureError as e: + raise TokenExpiredError(str(e)) + except JWTClaimsError as e: + if "issuer" in str(e).lower(): + raise InvalidIssuerError(str(e)) + raise TokenValidationError(str(e)) + except JWTError as e: + error_msg = str(e).lower() + if "signature" in error_msg: + raise InvalidSignatureError(str(e)) + raise TokenValidationError(str(e)) + + async def _get_signing_key(self, kid: str) -> dict[str, Any]: + """Get the signing key from JWKS by key ID. + + Args: + kid: The key ID from the JWT header. + + Returns: + The JWK for the given key ID. + + Raises: + KeyNotFoundError: If key is not found in JWKS. + """ + jwks = await self._get_jwks() + + for key in jwks.get("keys", []): + if key.get("kid") == kid: + return key + + # Key not found, try refreshing JWKS (key rotation may have occurred) + await self._refresh_jwks() + jwks = self._jwks_cache + + for key in jwks.get("keys", []): + if key.get("kid") == kid: + return key + + raise KeyNotFoundError(f"Key with kid '{kid}' not found in JWKS") + + async def _get_jwks(self) -> dict[str, Any]: + """Get JWKS, using cache if available and not expired. + + Returns: + The JWKS dictionary. + """ + now = time.time() + cache_age = now - self._jwks_cache_time + + if self._jwks_cache and cache_age < self.settings.jwks_cache_ttl: + return self._jwks_cache + + await self._refresh_jwks() + return self._jwks_cache + + async def _refresh_jwks(self) -> None: + """Fetch fresh JWKS from Cognito. + + Raises: + JWKSFetchError: If JWKS cannot be fetched. + """ + if not self._http_client: + self._http_client = httpx.AsyncClient(timeout=10.0) + + try: + response = await self._http_client.get(self.settings.cognito_jwks_url) + response.raise_for_status() + self._jwks_cache = response.json() + self._jwks_cache_time = time.time() + except httpx.HTTPError as e: + # If we have cached JWKS, use it as fallback + if self._jwks_cache: + return + raise JWKSFetchError(f"Failed to fetch JWKS: {e}") + + def extract_token_from_header(self, auth_header: str | None) -> str | None: + """Extract Bearer token from Authorization header. + + Args: + auth_header: The Authorization header value. + + Returns: + The token string, or None if not found/invalid. + """ + if not auth_header: + return None + + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + + return parts[1] + config.py: | + """Configuration management for TCloud Cognito Auth Plugin.""" + + from pydantic import Field + from pydantic_settings import BaseSettings + + + class PluginSettings(BaseSettings): + """Plugin configuration loaded from environment variables.""" + + # Cognito settings + cognito_user_pool_id: str = Field( + ..., + description="AWS Cognito User Pool ID", + ) + cognito_region: str = Field( + default="us-east-2", + description="AWS region for Cognito", + ) + cognito_app_client_id: str = Field( + ..., + description="Cognito App Client ID for audience validation", + ) + + # TCloud API settings + tcloud_api_url: str = Field( + ..., + description="TCloud API base URL", + ) + tcloud_api_key: str = Field( + ..., + description="TCloud API authentication key", + ) + + # Cache settings + redis_url: str = Field( + default="redis://localhost:6379/0", + description="Redis connection URL", + ) + permission_cache_ttl: int = Field( + default=300, + description="Permission cache TTL in seconds", + ) + + # JWKS cache settings + jwks_cache_ttl: int = Field( + default=3600, + description="JWKS cache TTL in seconds (1 hour)", + ) + + # Plugin behavior + enable_header_propagation: bool = Field( + default=True, + description="Enable header propagation to downstream agents", + ) + clock_skew_tolerance: int = Field( + default=300, + description="Clock skew tolerance in seconds for token validation", + ) + + model_config = { + "env_prefix": "", + "case_sensitive": False, + "extra": "ignore", + } + + @property + def cognito_issuer(self) -> str: + """Get the Cognito issuer URL.""" + return f"https://cognito-idp.{self.cognito_region}.amazonaws.com/{self.cognito_user_pool_id}" + + @property + def cognito_jwks_url(self) -> str: + """Get the Cognito JWKS URL.""" + return f"{self.cognito_issuer}/.well-known/jwks.json" + + + def get_settings() -> PluginSettings: + """Get plugin settings from environment variables.""" + return PluginSettings() + exceptions.py: | + """Custom exceptions for TCloud Cognito Auth Plugin.""" + + + class TCloudAuthError(Exception): + """Base exception for TCloud authentication errors.""" + + def __init__(self, message: str, code: str = "AUTH_ERROR"): + self.message = message + self.code = code + super().__init__(self.message) + + + class TokenValidationError(TCloudAuthError): + """Raised when JWT token validation fails.""" + + def __init__(self, message: str, code: str = "INVALID_TOKEN"): + super().__init__(message, code) + + + class TokenExpiredError(TokenValidationError): + """Raised when JWT token has expired.""" + + def __init__(self, message: str = "Token has expired"): + super().__init__(message, "TOKEN_EXPIRED") + + + class InvalidSignatureError(TokenValidationError): + """Raised when JWT signature is invalid.""" + + def __init__(self, message: str = "Invalid token signature"): + super().__init__(message, "INVALID_SIGNATURE") + + + class InvalidIssuerError(TokenValidationError): + """Raised when JWT issuer is invalid.""" + + def __init__(self, message: str = "Invalid token issuer"): + super().__init__(message, "INVALID_ISSUER") + + + class InvalidAudienceError(TokenValidationError): + """Raised when JWT audience is invalid.""" + + def __init__(self, message: str = "Invalid token audience"): + super().__init__(message, "INVALID_AUDIENCE") + + + class JWKSFetchError(TCloudAuthError): + """Raised when JWKS cannot be fetched.""" + + def __init__(self, message: str = "Failed to fetch JWKS"): + super().__init__(message, "JWKS_FETCH_ERROR") + + + class KeyNotFoundError(TCloudAuthError): + """Raised when signing key is not found in JWKS.""" + + def __init__(self, message: str = "Signing key not found in JWKS"): + super().__init__(message, "KEY_NOT_FOUND") + + + class TCloudAPIError(TCloudAuthError): + """Raised when TCloud API request fails.""" + + def __init__(self, message: str, status_code: int | None = None): + self.status_code = status_code + code = f"TCLOUD_API_ERROR_{status_code}" if status_code else "TCLOUD_API_ERROR" + super().__init__(message, code) + + + class CacheError(TCloudAuthError): + """Raised when cache operations fail.""" + + def __init__(self, message: str = "Cache operation failed"): + super().__init__(message, "CACHE_ERROR") + models.py: | + """Data models for TCloud Cognito Auth Plugin.""" + + from datetime import datetime + from typing import Any + + from pydantic import BaseModel, Field + + + class CognitoClaims(BaseModel): + """Parsed claims from a Cognito JWT token.""" + + sub: str = Field(..., description="Subject (unique user ID)") + iss: str = Field(..., description="Issuer URL") + token_use: str = Field(..., description="Token use (access or id)") + exp: int = Field(..., description="Expiration timestamp") + iat: int = Field(..., description="Issued at timestamp") + client_id: str | None = Field(None, description="Client ID (for access tokens)") + username: str | None = Field(None, description="Username") + email: str | None = Field(None, description="User email (for id tokens)") + name: str | None = Field(None, description="User full name") + + @property + def user_email(self) -> str: + """Extract user email from claims.""" + if self.email: + return self.email + # For access tokens, extract email from username (format: provider_email) + if self.username and "_" in self.username: + return self.username.split("_", 1)[1] + return self.username or self.sub + + + class UserPermissions(BaseModel): + """User permissions fetched from TCloud API.""" + + email: str = Field(..., description="User email") + customers: list[str] = Field( + default_factory=list, description="List of customer/cloud IDs" + ) + roles: list[str] = Field(default_factory=list, description="User roles") + permissions: list[str] = Field( + default_factory=list, description="Specific permissions" + ) + fetched_at: datetime = Field( + default_factory=datetime.utcnow, description="When permissions were fetched" + ) + + def to_cache_dict(self) -> dict[str, Any]: + """Convert to dictionary for caching.""" + return { + "email": self.email, + "customers": self.customers, + "roles": self.roles, + "permissions": self.permissions, + "fetched_at": self.fetched_at.isoformat(), + } + + @classmethod + def from_cache_dict(cls, data: dict[str, Any]) -> "UserPermissions": + """Create from cached dictionary.""" + if isinstance(data.get("fetched_at"), str): + data["fetched_at"] = datetime.fromisoformat(data["fetched_at"]) + return cls(**data) + + + class AuthenticatedUser(BaseModel): + """Represents an authenticated user with permissions.""" + + email: str = Field(..., description="User email") + full_name: str | None = Field(None, description="User full name") + cognito_sub: str = Field(..., description="Cognito subject ID") + is_admin: bool = Field(default=False, description="Whether user is admin") + is_active: bool = Field(default=True, description="Whether user is active") + customers: list[str] = Field( + default_factory=list, description="Allowed customer IDs" + ) + roles: list[str] = Field(default_factory=list, description="User roles") + permissions: list[str] = Field(default_factory=list, description="User permissions") + auth_method: str = Field(default="cognito", description="Authentication method") + + def to_gateway_user(self) -> dict[str, Any]: + """Convert to Context Forge user format.""" + return { + "email": self.email, + "full_name": self.full_name or self.email, + "is_admin": self.is_admin, + "is_active": self.is_active, + } + + def to_metadata(self) -> dict[str, Any]: + """Convert to metadata for header propagation.""" + return { + "auth_method": self.auth_method, + "cognito_sub": self.cognito_sub, + "customers": self.customers, + "roles": self.roles, + "permissions": self.permissions, + } + + + class PropagatedHeaders(BaseModel): + """Headers to propagate to downstream agents.""" + + x_user_email: str = Field(..., alias="X-User-Email") + x_user_customers: str = Field(..., alias="X-User-Customers") + x_request_id: str | None = Field(None, alias="X-Request-ID") + + model_config = {"populate_by_name": True} + + @classmethod + def from_authenticated_user( + cls, user: AuthenticatedUser, request_id: str | None = None + ) -> "PropagatedHeaders": + """Create headers from authenticated user.""" + import json + + return cls( + **{ + "X-User-Email": user.email, + "X-User-Customers": json.dumps(user.customers), + "X-Request-ID": request_id, + } + ) plugin-manifest.yaml: | name: "TCloudCognitoAuthPlugin" - description: "TCloud Cognito Authentication Plugin" + description: "TCloud Cognito Authentication Plugin - JWT validation, TCloud API permissions, and header propagation" + author: "TCloud Platform Team" version: "1.0.0" - hooks: + + # Hooks this plugin implements + available_hooks: - "http_auth_resolve_user" - "agent_pre_invoke" + + # Default configuration + default_configs: + cognito_region: "us-east-2" + permission_cache_ttl: 300 + jwks_cache_ttl: 3600 + enable_header_propagation: true + clock_skew_tolerance: 300 + + # Required environment variables + required_env: + - COGNITO_USER_POOL_ID + - COGNITO_APP_CLIENT_ID + - TCLOUD_API_URL + - TCLOUD_API_KEY + + # Optional environment variables with defaults + optional_env: + COGNITO_REGION: "us-east-2" + REDIS_URL: "redis://localhost:6379/0" + PERMISSION_CACHE_TTL: "300" + JWKS_CACHE_TTL: "3600" + + # Dependencies + dependencies: + - python-jose[cryptography]>=3.3.0 + - httpx>=0.27.0 + - redis>=5.0.0 + - pydantic>=2.0.0 + - pydantic-settings>=2.0.0 + tcloud_api.py: | + """TCloud API client for user permissions.""" + + import logging + from typing import Any + + import httpx + + from .config import PluginSettings + from .exceptions import TCloudAPIError + from .models import UserPermissions + + logger = logging.getLogger(__name__) + + + class TCloudAPIClient: + """Client for TCloud API to fetch user permissions.""" + + def __init__(self, settings: PluginSettings): + """Initialize the TCloud API client. + + Args: + settings: Plugin configuration settings. + """ + self.settings = settings + self._http_client: httpx.AsyncClient | None = None + + async def initialize(self) -> None: + """Initialize the HTTP client.""" + self._http_client = httpx.AsyncClient( + base_url=self.settings.tcloud_api_url, + timeout=30.0, + headers={ + "x-api-key": self.settings.tcloud_api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + async def shutdown(self) -> None: + """Clean up resources.""" + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + async def get_user_permissions( + self, email: str, bearer_token: str | None = None + ) -> UserPermissions: + """Fetch user permissions from TCloud API. + + Args: + email: User email address. + bearer_token: Optional Bearer token to forward to API. + + Returns: + UserPermissions object with user's permissions. + + Raises: + TCloudAPIError: If API request fails. + """ + if not self._http_client: + await self.initialize() + + headers = {} + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + + try: + # Try /customer endpoint first (main source of permissions) + response = await self._http_client.get("/customer", headers=headers) + + if response.status_code == 200: + data = response.json() + customers = self._extract_customers(data) + return UserPermissions( + email=email, + customers=customers, + roles=self._extract_roles(data), + permissions=self._extract_permissions(data), + ) + elif response.status_code == 401: + raise TCloudAPIError( + "Unauthorized access to TCloud API", status_code=401 + ) + elif response.status_code == 403: + # User authenticated but has no permissions + logger.warning(f"User {email} has no customer permissions") + return UserPermissions(email=email, customers=[], roles=[], permissions=[]) + else: + raise TCloudAPIError( + f"TCloud API error: {response.status_code} - {response.text}", + status_code=response.status_code, + ) + + except httpx.TimeoutException as e: + raise TCloudAPIError(f"TCloud API timeout: {e}") + except httpx.HTTPError as e: + raise TCloudAPIError(f"TCloud API request failed: {e}") + + async def get_user_profile( + self, email: str, bearer_token: str | None = None + ) -> dict[str, Any]: + """Fetch user profile from TCloud API. + + Args: + email: User email address. + bearer_token: Optional Bearer token to forward to API. + + Returns: + User profile data. + + Raises: + TCloudAPIError: If API request fails. + """ + if not self._http_client: + await self.initialize() + + headers = {} + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + + try: + response = await self._http_client.get("/user/profile", headers=headers) + + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + return {"email": email, "name": email} + else: + logger.warning( + f"Failed to fetch profile for {email}: {response.status_code}" + ) + return {"email": email, "name": email} + + except httpx.HTTPError as e: + logger.warning(f"Failed to fetch user profile: {e}") + return {"email": email, "name": email} + + def _extract_customers(self, data: Any) -> list[str]: + """Extract customer/cloud IDs from API response. + + Args: + data: API response data. + + Returns: + List of customer/cloud IDs. + """ + customers = [] + + if isinstance(data, list): + # Response is a list of customer objects + for item in data: + if isinstance(item, dict): + cloud_id = item.get("cloud_id") or item.get("cloudId") or item.get("id") + if cloud_id: + customers.append(str(cloud_id)) + elif isinstance(data, dict): + # Response might have a customers/data array + items = data.get("customers") or data.get("data") or [] + for item in items: + if isinstance(item, dict): + cloud_id = item.get("cloud_id") or item.get("cloudId") or item.get("id") + if cloud_id: + customers.append(str(cloud_id)) + + return customers + + def _extract_roles(self, data: Any) -> list[str]: + """Extract user roles from API response. + + Args: + data: API response data. + + Returns: + List of role names. + """ + roles = set() + + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + role = item.get("role") or item.get("permission_level") + if role: + roles.add(str(role)) + elif isinstance(data, dict): + user_roles = data.get("roles") or [] + roles.update(str(r) for r in user_roles if r) + + return list(roles) + + def _extract_permissions(self, data: Any) -> list[str]: + """Extract specific permissions from API response. + + Args: + data: API response data. + + Returns: + List of permission strings. + """ + permissions = set() + + # Default read permissions if user has any customers + if isinstance(data, list) and len(data) > 0: + permissions.add("read:metrics") + permissions.add("read:logs") + + if isinstance(data, dict): + user_perms = data.get("permissions") or [] + permissions.update(str(p) for p in user_perms if p) + + return list(permissions) + tcloud_cognito_auth.py: | + """TCloud Cognito Authentication Plugin for MCP Context Forge. + + This plugin implements authentication via AWS Cognito JWT validation + and fetches user permissions from the TCloud API. + """ + + import json + import logging + from typing import Any + + from .cache import PermissionCache + from .cognito import CognitoJWTValidator + from .config import PluginSettings, get_settings + from .exceptions import ( + TCloudAPIError, + TCloudAuthError, + TokenExpiredError, + TokenValidationError, + ) + from .models import AuthenticatedUser, PropagatedHeaders + from .tcloud_api import TCloudAPIClient + + logger = logging.getLogger(__name__) + + + class TCloudCognitoAuthPlugin: + """TCloud Cognito Authentication Plugin. + + Implements http_auth_resolve_user and agent_pre_invoke hooks for + MCP Context Forge to provide JWT-based authentication with + permission caching. + """ + + def __init__(self, config: dict[str, Any] | None = None): + """Initialize the plugin. + + Args: + config: Optional configuration dictionary from Context Forge. + """ + self._config = config or {} + self._settings: PluginSettings | None = None + self._cognito_validator: CognitoJWTValidator | None = None + self._tcloud_client: TCloudAPIClient | None = None + self._permission_cache: PermissionCache | None = None + self._initialized = False + + @property + def settings(self) -> PluginSettings: + """Get plugin settings, loading from environment if needed.""" + if not self._settings: + self._settings = get_settings() + return self._settings + + async def initialize(self) -> None: + """Initialize plugin resources. + + Called by Context Forge when the plugin is loaded. + """ + if self._initialized: + return + + logger.info("Initializing TCloud Cognito Auth Plugin") + + # Initialize components + self._cognito_validator = CognitoJWTValidator(self.settings) + await self._cognito_validator.initialize() + + self._tcloud_client = TCloudAPIClient(self.settings) + await self._tcloud_client.initialize() + + self._permission_cache = PermissionCache(self.settings) + await self._permission_cache.initialize() + + self._initialized = True + logger.info("TCloud Cognito Auth Plugin initialized successfully") + + async def shutdown(self) -> None: + """Clean up plugin resources. + + Called by Context Forge when the plugin is unloaded. + """ + logger.info("Shutting down TCloud Cognito Auth Plugin") + + if self._cognito_validator: + await self._cognito_validator.shutdown() + if self._tcloud_client: + await self._tcloud_client.shutdown() + if self._permission_cache: + await self._permission_cache.shutdown() + + self._initialized = False + + async def http_auth_resolve_user( + self, + payload: dict[str, Any], + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Resolve user from HTTP authentication credentials. + + This hook is called by Context Forge to authenticate incoming requests. + It validates the Cognito JWT and fetches user permissions. + + Args: + payload: Contains 'credentials' dict with auth info. + - credentials.credentials: The Bearer token + - credentials.scheme: The auth scheme (bearer) + context: Optional context from Context Forge. + + Returns: + Dict with: + - modified_payload: User info for Context Forge + - metadata: Additional info including permissions + - continue_processing: Whether to continue auth chain + """ + if not self._initialized: + await self.initialize() + + # Extract credentials from payload + credentials = payload.get("credentials", {}) + token = credentials.get("credentials") + scheme = credentials.get("scheme", "").lower() + + # Skip if no bearer token + if not token or scheme != "bearer": + logger.debug("No bearer token found, continuing auth chain") + return {"continue_processing": True} + + try: + # Validate JWT with Cognito + claims = await self._cognito_validator.validate_token(token) + email = claims.user_email + + logger.info(f"JWT validated for user: {email}") + + # Fetch permissions (with cache) + async def fetch_permissions(): + return await self._tcloud_client.get_user_permissions( + email, bearer_token=token + ) + + permissions = await self._permission_cache.get_or_fetch( + email, fetch_permissions + ) + + # Build authenticated user + user = AuthenticatedUser( + email=email, + full_name=claims.name, + cognito_sub=claims.sub, + is_admin=False, + is_active=True, + customers=permissions.customers, + roles=permissions.roles, + permissions=permissions.permissions, + ) + + logger.info( + f"Authenticated user {email} with {len(permissions.customers)} customers" + ) + + return { + "modified_payload": user.to_gateway_user(), + "metadata": user.to_metadata(), + "continue_processing": True, + } + + except TokenExpiredError as e: + logger.warning(f"Token expired: {e}") + return { + "error": { + "message": "Token expired", + "code": "TOKEN_EXPIRED", + }, + "continue_processing": False, + } + + except TokenValidationError as e: + logger.warning(f"Token validation failed: {e}") + return { + "error": { + "message": str(e), + "code": e.code, + }, + "continue_processing": False, + } + + except TCloudAPIError as e: + logger.error(f"TCloud API error: {e}") + # Continue with basic auth if API fails + return {"continue_processing": True} + + except Exception as e: + logger.error(f"Unexpected auth error: {e}", exc_info=True) + return {"continue_processing": True} + + async def agent_pre_invoke( + self, + payload: dict[str, Any], + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Inject user context headers before agent invocation. + + This hook is called before each agent request to inject + user identity headers that agents can use for authorization. + + Args: + payload: Contains request info including headers. + context: Context with authenticated user info. + + Returns: + Dict with: + - modified_payload: Updated payload with injected headers + - continue_processing: Always True to continue + """ + if not self.settings.enable_header_propagation: + return {"continue_processing": True} + + # Get user info from context + user_metadata = (context or {}).get("metadata", {}) + if not user_metadata or user_metadata.get("auth_method") != "cognito": + return {"continue_processing": True} + + user_email = (context or {}).get("user", {}).get("email") + if not user_email: + return {"continue_processing": True} + + # Build headers to inject + customers = user_metadata.get("customers", []) + headers_to_inject = { + "X-User-Email": user_email, + "X-User-Customers": json.dumps(customers), + } + + # Get existing headers from payload + existing_headers = payload.get("headers", {}) + merged_headers = {**existing_headers, **headers_to_inject} + + # Get request ID if available + request_id = (context or {}).get("request_id") + if request_id: + merged_headers["X-Request-ID"] = request_id + + logger.debug( + f"Injecting headers for {user_email}: " + f"X-User-Customers={len(customers)} customers" + ) + + return { + "modified_payload": {**payload, "headers": merged_headers}, + "continue_processing": True, + } + + async def tool_pre_invoke( + self, + payload: dict[str, Any], + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Inject user context before tool invocation. + + Similar to agent_pre_invoke but for tool calls. + + Args: + payload: Tool invocation payload. + context: Context with authenticated user info. + + Returns: + Dict with modified payload including user context. + """ + # Reuse the same logic as agent_pre_invoke + return await self.agent_pre_invoke(payload, context) + + + # Plugin factory function for Context Forge + def create_plugin(config: dict[str, Any] | None = None) -> TCloudCognitoAuthPlugin: + """Create a new plugin instance. + + Args: + config: Configuration from Context Forge. + + Returns: + Configured plugin instance. + """ + return TCloudCognitoAuthPlugin(config) +kind: ConfigMap +metadata: + name: tcloud-cognito-auth-plugin From 161fa1633323d480b3791329c4dbd20b15afdde7 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Fri, 23 Jan 2026 13:14:12 -0300 Subject: [PATCH 32/33] =?UTF-8?q?feat(CLAUDE.md):=20update=20authenticatio?= =?UTF-8?q?n=20plugin=20to=20include=20Cognito=20JWT=20validation=20and=20?= =?UTF-8?q?TCloud=20API=20permissions=20(=E2=9C=85=20Deployed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9b233e..c70a9cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,9 @@ Clients โ†’ Orchestrator Agent โ†’ MCP Context Forge (Gateway) โ†’ Specialist Ag **Key Components:** - **MCP Context Forge**: Central gateway that federates multiple MCP servers (IBM upstream chart) - **Specialist Agents**: Domain-specific agents (CPU/RAM, Database, Network, etc.) -- **Authentication Plugin**: `plugins/tcloud_cognito_auth/` - Cognito JWT validation + TCloud API permissions +- **Authentication Plugin**: `plugins/tcloud_cognito_auth/` - Cognito JWT validation + TCloud API permissions (โœ… Deployed) + +**Docker Image (with plugin):** `ghcr.io/tcloud-dev/mcp-context-forge:with-auth` ## Project Structure @@ -69,6 +71,17 @@ Clients โ†’ Orchestrator Agent โ†’ MCP Context Forge (Gateway) โ†’ Specialist Ag | Dev | mcp-dev | https://mcp-gateway.tbf8b9d.k8s.sp06.te.tks.sh | | Prod | mcp | https://mcp-gateway.tcloud.internal (planned) | +## Important Configuration Notes + +**Dev Ingress:** +- Do NOT set `ingressClassName` in values-dev.yaml (external controller picks up ingresses without class) +- TLS should be `false` - external ingress handles HTTPS automatically + +**Common Issues:** +- Migration job stuck: `kubectl -n mcp-dev delete job mcp-stack-migration` then deploy with `--no-hooks` +- Redis 8.4 crash: Remove inline comments from redis configmap (e.g., `save 900 1 # comment` โ†’ separate lines) +- PVC multi-attach: Scale down old replicaset before new pods can attach + ## Code Conventions **MCP Agent Tool Response Format** (all diagnostic tools must use): From f6d0be5ebfbe4eacaef1a3a03a1d2b7f7b54d227 Mon Sep 17 00:00:00 2001 From: Wagner Silva Date: Fri, 23 Jan 2026 13:37:09 -0300 Subject: [PATCH 33/33] fix(tcloud_cognito_auth): update credential extraction to handle HttpAuthResolveUserPayload object --- .../tcloud_cognito_auth/tcloud_cognito_auth.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py b/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py index d088c3a..72ba01c 100644 --- a/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py +++ b/plugins/tcloud_cognito_auth/tcloud_cognito_auth.py @@ -126,10 +126,17 @@ async def http_auth_resolve_user( if not self._plugin_initialized: await self.initialize() - # Extract credentials from payload - credentials = payload.get("credentials", {}) - token = credentials.get("credentials") - scheme = credentials.get("scheme", "").lower() + # Extract credentials from payload (payload can be dict or HttpAuthResolveUserPayload object) + if hasattr(payload, "credentials"): + # Payload is an object with attributes + creds_obj = payload.credentials + token = getattr(creds_obj, "credentials", None) + scheme = getattr(creds_obj, "scheme", "").lower() + else: + # Payload is a dict + credentials = payload.get("credentials", {}) + token = credentials.get("credentials") + scheme = credentials.get("scheme", "").lower() # Skip if no bearer token if not token or scheme != "bearer":