diff --git a/.env b/.env index 8769134..6baadea 100644 --- a/.env +++ b/.env @@ -2,7 +2,7 @@ MONGODB_URI=mongodb://localhost:27017 MONGODB_DBNAME=agenthub_db SECRET_KEY=your-super-secret-jwt-key-change-in-production-agenthub-2024 ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRE_MINUTES=60 +ACCESS_TOKEN_EXPIRE_MINUTES=240 # Agent Collector API Configuration AGENT_COLLECTOR_API_KEY=agent-collector-static-key-2024-secure diff --git a/.gitignore b/.gitignore index 620ca43..43e1485 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,99 @@ +# Python venv/ - +env/ __pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Environment variables +.env +.env.local +.env.*.local + +# IDEs and editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Node.js (if using for frontend build tools) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Linux +*~ + +# Temporary files +*.tmp +*.temp +.cache/ + +# FastAPI/Uvicorn specific +.uvicorn* + +# MongoDB dumps +*.dump +*.bson diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc index 833c922..122b8a9 100644 Binary files a/__pycache__/config.cpython-313.pyc and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/crud.cpython-311.pyc b/__pycache__/crud.cpython-311.pyc index 5b8c47c..0c6d16b 100644 Binary files a/__pycache__/crud.cpython-311.pyc and b/__pycache__/crud.cpython-311.pyc differ diff --git a/__pycache__/crud.cpython-313.pyc b/__pycache__/crud.cpython-313.pyc index b353042..a4a8ee9 100644 Binary files a/__pycache__/crud.cpython-313.pyc and b/__pycache__/crud.cpython-313.pyc differ diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc index 7adf9e7..5dc2aea 100644 Binary files a/__pycache__/main.cpython-311.pyc and b/__pycache__/main.cpython-311.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc index c1fd90f..96418fa 100644 Binary files a/__pycache__/main.cpython-313.pyc and b/__pycache__/main.cpython-313.pyc differ diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index 4a34506..48c1e71 100644 Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ diff --git a/__pycache__/msal_auth.cpython-313.pyc b/__pycache__/msal_auth.cpython-313.pyc index d009c4e..8951b91 100644 Binary files a/__pycache__/msal_auth.cpython-313.pyc and b/__pycache__/msal_auth.cpython-313.pyc differ diff --git a/agent_collector_api_documentation.md b/agent_collector_api_documentation.md deleted file mode 100644 index 063ce4d..0000000 --- a/agent_collector_api_documentation.md +++ /dev/null @@ -1,310 +0,0 @@ -# Agent Collector API Documentation - -## Overview - -Agent Collector is a Flask-based REST API for collecting and storing agent metadata across an organization. The application validates agent data against a predefined JSON schema and stores it in MongoDB for persistence. - -## Base Configuration - -- **Default Host**: `0.0.0.0` -- **Default Port**: `8475` -- **Server**: Hypercorn ASGI server -- **Database**: MongoDB -- **Content Type**: `application/json` - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `MONGO_HOST` | `localhost` | MongoDB host address | -| `MONGO_PORT` | `27017` | MongoDB port | -| `MONGO_USERNAME` | `admin` | MongoDB username | -| `MONGO_PASSWORD` | `admin` | MongoDB password | -| `MONGO_DB_NAME` | `agent_collector` | MongoDB database name | -| `MONGO_COLLECTION` | `agents` | MongoDB collection name | -| `DEBUG` | `False` | Flask debug mode | -| `HOST` | `0.0.0.0` | Host to bind to | -| `PORT` | `8475` | Port to bind to | -| `HYPERCORN_BIND` | `0.0.0.0:8475` | Hypercorn bind address | -| `HYPERCORN_WORKERS` | `1` | Number of worker processes | -| `HYPERCORN_LOG_LEVEL` | `info` | Logging level | - -## API Endpoints - -### Health Check - -**Endpoint**: `GET /` - -**Description**: Health check endpoint that verifies the application and MongoDB connection status. - -**Request**: No parameters required - -**Response**: -```json -{ - "status": "healthy", - "message": "Agent collector API is running", - "timestamp": "2024-01-01T12:00:00.000000", - "database": { - "status": "connected", - "healthy": true - } -} -``` - -**Status Codes**: -- `200 OK`: Service is healthy - -### Submit Agent Data - -**Endpoint**: `POST /agents` - -**Description**: Collects and validates agent metadata, then stores it in MongoDB. - -**Request Headers**: -- `Content-Type: application/json` - -**Request Body Schema**: - -**Required Fields**: -- `name` (string): Name of the agent (minimum length: 1) -- `description` (string): Detailed description of the agent (minimum length: 1) -- `purpose` (string): Primary purpose or function of the agent (minimum length: 1) - -**Optional Fields**: -- `location` (string): Physical or virtual location where the agent is deployed -- `userbase` (array of strings): List of users or user groups who use this agent -- `version` (string): Current version of the agent -- `creation_date` (string, ISO 8601 datetime): Date and time when the agent was created -- `last_updated` (string, ISO 8601 datetime): Date and time when the agent was last updated -- `capabilities` (array of strings): List of agent capabilities -- `status` (string): Current operational status - must be one of: `active`, `inactive`, `deprecated`, `development` -- `department` (string): Department or team responsible for the agent -- `contact_person` (string): Person to contact for issues or inquiries about the agent -- `tags` (array of strings): Tags for categorizing the agent -- `metadata` (object): Additional arbitrary metadata about the agent - -**Example Request**: -```json -{ - "name": "TestAgent", - "description": "Test description", - "purpose": "Testing purposes", - "location": "Development Environment", - "userbase": ["dev-team", "qa-team"], - "version": "1.0.0", - "capabilities": ["data-processing", "automated-testing"], - "status": "development", - "department": "Engineering", - "contact_person": "john.doe@company.com", - "tags": ["automation", "testing"], - "metadata": { - "framework": "custom", - "language": "python" - } -} -``` - -**Success Response** (`201 Created`): -```json -{ - "status": "success", - "message": "Agent data collected successfully", - "agent_id": "507f1f77bcf86cd799439011" -} -``` - -**Error Responses**: - -**Unsupported Media Type** (`415`): -```json -{ - "error": "Unsupported Media Type", - "message": "Request must be JSON" -} -``` - -**Validation Error** (`400 Bad Request`): -```json -{ - "error": "Invalid Data", - "message": "'name' is a required property" -} -``` - -**Database Unavailable** (`503 Service Unavailable`): -```json -{ - "error": "Database Unavailable", - "message": "MongoDB connection is not available. Please check the database setup.", - "agent_data": { - // Original submitted data returned for client retry - } -} -``` - -**Database Error** (`500 Internal Server Error`): -```json -{ - "error": "Database Error", - "message": "Failed to store agent data. MongoDB may be unavailable or there was an error processing the request.", - "agent_data": { - // Original submitted data returned for client retry - } -} -``` - -## Data Processing - -### Automatic Timestamp Addition - -The API automatically adds timestamps to submitted data: -- If `creation_date` is not provided, it's set to the current UTC time -- If `last_updated` is not provided, it's set to the current UTC time -- Timestamps are in ISO 8601 format - -### Schema Validation - -All submitted data is validated against the JSON schema before storage. The validation: -- Ensures required fields are present -- Validates data types for all fields -- Enforces string length constraints -- Validates enum values for status field -- Rejects additional properties not defined in the schema - -## Database Operations - -### Connection Management - -- Automatic connection retry with exponential backoff -- Connection health checks before operations -- Graceful error handling for connection failures -- Connection pooling via PyMongo MongoClient - -### Data Storage - -- Each agent record is stored as a MongoDB document -- Unique ObjectId is generated for each record -- Full document is stored with all provided metadata - -## Error Handling - -The API implements comprehensive error handling: - -1. **Input Validation**: JSON schema validation with detailed error messages -2. **Database Connectivity**: Connection retries and graceful degradation -3. **Data Preservation**: Failed requests return original data for client retry -4. **Logging**: Detailed error logging for troubleshooting - -## Security Considerations - -- No authentication mechanism implemented (authentication should be handled at proxy/gateway level) -- Input validation prevents injection attacks -- Database credentials configurable via environment variables -- Connection timeouts prevent resource exhaustion - -## Deployment Notes - -### Production Deployment - -Run with Hypercorn for production: -```bash -python main.py -``` - -### Development Mode - -For development with Flask's built-in server: -```bash -python wsgi.py -``` - -### Docker Deployment - -The application is designed to work in containerized environments with: -- Environment variable configuration -- MongoDB connection timeout handling -- Graceful shutdown handling - -## Client Integration - -### Example Client Code (Python) - -```python -import requests -import json - -# Health check -response = requests.get('http://localhost:8475/') -print(f"Health: {response.json()}") - -# Submit agent data -agent_data = { - "name": "MyAgent", - "description": "Agent for processing customer data", - "purpose": "Customer service automation", - "status": "active", - "department": "Customer Success" -} - -response = requests.post( - 'http://localhost:8475/agents', - headers={'Content-Type': 'application/json'}, - data=json.dumps(agent_data) -) - -if response.status_code == 201: - result = response.json() - print(f"Agent stored with ID: {result['agent_id']}") -else: - print(f"Error: {response.json()}") -``` - -### Example Client Code (curl) - -```bash -# Health check -curl http://localhost:8475/ - -# Submit agent data -curl -X POST http://localhost:8475/agents \ - -H "Content-Type: application/json" \ - -d '{ - "name": "TestAgent", - "description": "Test description", - "purpose": "Testing purposes" - }' -``` - -## MongoDB Schema - -The MongoDB collection stores documents with the following structure: - -```javascript -{ - "_id": ObjectId("507f1f77bcf86cd799439011"), - "name": "AgentName", - "description": "Agent description", - "purpose": "Agent purpose", - "location": "Optional location", - "userbase": ["user1", "user2"], - "version": "1.0.0", - "creation_date": "2024-01-01T12:00:00.000000", - "last_updated": "2024-01-01T12:00:00.000000", - "capabilities": ["capability1", "capability2"], - "status": "active", - "department": "Engineering", - "contact_person": "contact@company.com", - "tags": ["tag1", "tag2"], - "metadata": { - "custom_field": "custom_value" - } -} -``` - -## Rate Limiting and Scaling - -- No built-in rate limiting (should be implemented at proxy/gateway level) -- Hypercorn workers can be scaled via `HYPERCORN_WORKERS` environment variable -- MongoDB connection pooling handles concurrent requests -- Application is stateless and horizontally scalable \ No newline at end of file diff --git a/config.py b/config.py index 11ea291..96f8a97 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,7 @@ load_dotenv() # Environment Configuration DISABLE_MSAL = os.getenv("DISABLE_MSAL", "false").lower() == "true" +HIDE_LOCAL_LOGIN = os.getenv("HIDE_LOCAL_LOGIN", "false").lower() == "true" BASE_PATH = os.getenv("BASE_PATH", "").rstrip("/") # Remove trailing slash def get_base_path() -> str: @@ -22,6 +23,10 @@ def is_msal_enabled() -> bool: """Check if MSAL authentication is enabled""" return not DISABLE_MSAL +def show_local_login() -> bool: + """Check if local login should be shown (hidden in production)""" + return not HIDE_LOCAL_LOGIN + def get_msal_config(): """Get MSAL configuration if enabled""" if not is_msal_enabled(): @@ -29,6 +34,7 @@ def get_msal_config(): return { "client_id": os.getenv("AZURE_CLIENT_ID"), + "client_secret": os.getenv("AZURE_CLIENT_SECRET"), "authority": os.getenv("AZURE_AUTHORITY"), "redirect_uri": os.getenv("AZURE_REDIRECT_URI"), } \ No newline at end of file diff --git a/crud.py b/crud.py index ec0f19d..04e0b2b 100644 --- a/crud.py +++ b/crud.py @@ -137,6 +137,16 @@ async def create_agent(agent_data: dict, user_id: str): "created_at": now, "updated_at": now, } + + # Initialize quality audit fields if not provided + if "quality_audit_status" not in agent_doc: + agent_doc["quality_audit_status"] = False + if "quality_audit_updated_by" not in agent_doc: + agent_doc["quality_audit_updated_by"] = None + if "quality_audit_updated_at" not in agent_doc: + agent_doc["quality_audit_updated_at"] = None + if "quality_audit_updated_by_name" not in agent_doc: + agent_doc["quality_audit_updated_by_name"] = None result = await agents_collection.insert_one(agent_doc) agent_doc["_id"] = result.inserted_id return agent_doc @@ -197,12 +207,23 @@ async def get_agent_stats(): stats = await agents_collection.aggregate(pipeline).to_list(length=None) return {stat["_id"]: stat["count"] for stat in stats} -async def update_agent(agent_id: str, update_data: dict, user_id: str = None): +async def update_agent(agent_id: str, update_data: dict, user_id: str = None, admin_user_info: dict = None): try: filter_query = {"_id": ObjectId(agent_id)} if user_id: filter_query["created_by"] = user_id + # Handle Quality Audit updates specially + if "quality_audit_status" in update_data and admin_user_info: + # Get current agent to check if quality audit status is changing + current_agent = await get_agent_by_id(agent_id) + + if current_agent and current_agent.get("quality_audit_status") != update_data["quality_audit_status"]: + # Quality audit status is changing, update audit fields with separate timestamp + update_data["quality_audit_updated_by"] = admin_user_info.get("user_id") + update_data["quality_audit_updated_at"] = datetime.utcnow().isoformat() + update_data["quality_audit_updated_by_name"] = admin_user_info.get("user_name", admin_user_info.get("email")) + update_data["updated_at"] = datetime.utcnow() result = await agents_collection.update_one( filter_query, @@ -214,6 +235,36 @@ async def update_agent(agent_id: str, update_data: dict, user_id: str = None): except: return None +async def update_agent_quality_audit(agent_id: str, quality_audit_status: bool, admin_user_info: dict): + """Update only the quality audit status of an agent (admin only)""" + try: + # Get current agent to ensure it exists + current_agent = await get_agent_by_id(agent_id) + if not current_agent: + return None + + # Only update if status is actually changing + if current_agent.get("quality_audit_status") == quality_audit_status: + return current_agent + + update_data = { + "quality_audit_status": quality_audit_status, + "quality_audit_updated_by": admin_user_info.get("user_id"), + "quality_audit_updated_at": datetime.utcnow().isoformat(), + "quality_audit_updated_by_name": admin_user_info.get("user_name", admin_user_info.get("email")) + } + + result = await agents_collection.update_one( + {"_id": ObjectId(agent_id)}, + {"$set": update_data} + ) + + if result.modified_count: + return await get_agent_by_id(agent_id) + return None + except: + return None + async def delete_agent(agent_id: str, user_id: str = None): try: print(f"🗑️ CRUD: Deleting agent {agent_id} with user_id filter: {user_id}") diff --git a/docs/Agent Tracker API documentation 2025-08-22.pdf b/docs/Agent Tracker API documentation 2025-08-22.pdf new file mode 100644 index 0000000..2ad75fb Binary files /dev/null and b/docs/Agent Tracker API documentation 2025-08-22.pdf differ diff --git a/docs/agent_tracker_API_docs.md b/docs/agent_tracker_API_docs.md new file mode 100644 index 0000000..c817ca8 --- /dev/null +++ b/docs/agent_tracker_API_docs.md @@ -0,0 +1,306 @@ +# Agent Registration API Documentation + +## Overview + +The Agent Registration API allows you to register AI agents and track their usage in the AgentHub system. The API automatically handles duplicate registrations by tracking usage when an agent with the same name already exists. + +**Base URL:** `https://ai-sandbox.oliver.solutions/agent_collector/agents` + +## Authentication + +All requests require authentication using a static API key. + +**API Key:** `agent-collector-static-key-2024-secure` + +**Header:** `X-API-Key` + +## Agent Registration Endpoint + +### POST /agents + +Register a new agent or track usage of an existing agent. + +**URL:** `https://ai-sandbox.oliver.solutions/agent_collector/agents` + +**Method:** `POST` + +**Required Headers:** +- `Content-Type: application/json` +- `X-API-Key: agent-collector-static-key-2024-secure` + +## Request Schema + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | The unique name of the agent (minimum 1 character) | +| `description` | string | A detailed description of the agent's functionality (minimum 1 character) | +| `purpose` | string | The primary purpose or goal of the agent (minimum 1 character) | +| `tool` | string | The tool or platform where the agent operates (minimum 1 character) | + +### Optional Fields + +| Field | Type | Description | Default | +|-------|------|-------------|---------| +| `location` | string | Where the agent is deployed or operates | null | +| `userbase` | array[string] | List of user groups or departments that use this agent | null | +| `version` | string | Version number or identifier of the agent | null | +| `creation_date` | string | ISO 8601 datetime when the agent was first created | null | +| `last_updated` | string | ISO 8601 datetime when the agent was last modified | null | +| `capabilities` | array[string] | List of specific capabilities or features the agent provides | null | +| `status` | string | Current operational status of the agent | "development" | +| `department` | string | Department or team that owns/manages the agent | null | +| `contact_person` | string | Primary contact for questions about this agent | null | +| `tags` | array[string] | Keywords or labels for categorizing the agent | null | +| `metadata` | object | Additional key-value pairs of agent information | null | + +### Status Values + +The `status` field accepts the following values (case-insensitive): +- `"active"` - Agent is live and operational +- `"inactive"` - Agent is temporarily disabled +- `"deprecated"` - Agent is being phased out +- `"development"` - Agent is in development/testing phase (default) + +## Usage Examples + +### Basic Agent Registration + +**cURL Example:** +```bash +curl -X POST https://ai-sandbox.oliver.solutions/agent_collector/agents \ + -H "Content-Type: application/json" \ + -H "X-API-Key: agent-collector-static-key-2024-secure" \ + -d '{ + "name": "CustomerSupportBot", + "description": "AI assistant that handles customer inquiries and support tickets", + "purpose": "Provide 24/7 automated customer support and reduce response times", + "tool": "chat-sandbox" + }' +``` + +**Python Example:** +```python +import requests +import json + +url = "https://ai-sandbox.oliver.solutions/agent_collector/agents" +headers = { + "Content-Type": "application/json", + "X-API-Key": "agent-collector-static-key-2024-secure" +} + +data = { + "name": "CustomerSupportBot", + "description": "AI assistant that handles customer inquiries and support tickets", + "purpose": "Provide 24/7 automated customer support and reduce response times", + "tool": "chat-sandbox" +} + +response = requests.post(url, headers=headers, data=json.dumps(data)) +print(response.json()) +``` + +### Complex Agent Registration + +**cURL Example:** +```bash +curl -X POST https://ai-sandbox.oliver.solutions/agent_collector/agents \ + -H "Content-Type: application/json" \ + -H "X-API-Key: agent-collector-static-key-2024-secure" \ + -d '{ + "name": "DataAnalysisAgent", + "description": "Advanced AI agent that performs automated data analysis and generates business insights", + "purpose": "Transform raw business data into actionable insights and recommendations", + "tool": "Custom Python Framework", + "location": "AWS us-east-1", + "userbase": ["Data Science Team", "Business Intelligence", "Executive Team"], + "version": "2.1.3", + "creation_date": "2024-01-15T10:30:00Z", + "last_updated": "2024-08-22T14:45:30Z", + "capabilities": ["Data Visualization", "Trend Analysis", "Predictive Modeling", "Report Generation"], + "status": "active", + "department": "Data Science", + "contact_person": "Dr. Sarah Johnson", + "tags": ["analytics", "business-intelligence", "automation", "reporting"], + "metadata": { + "framework": "TensorFlow", + "language": "Python", + "memory_usage": "2GB", + "processing_time": "5-10 minutes" + } + }' +``` + +**Python Example:** +```python +import requests +import json +from datetime import datetime + +url = "https://ai-sandbox.oliver.solutions/agent_collector/agents" +headers = { + "Content-Type": "application/json", + "X-API-Key": "agent-collector-static-key-2024-secure" +} + +data = { + "name": "DataAnalysisAgent", + "description": "Advanced AI agent that performs automated data analysis and generates business insights", + "purpose": "Transform raw business data into actionable insights and recommendations", + "tool": "Custom Python Framework", + "location": "AWS us-east-1", + "userbase": ["Data Science Team", "Business Intelligence", "Executive Team"], + "version": "2.1.3", + "creation_date": "2024-01-15T10:30:00Z", + "last_updated": datetime.utcnow().isoformat() + "Z", + "capabilities": ["Data Visualization", "Trend Analysis", "Predictive Modeling", "Report Generation"], + "status": "active", + "department": "Data Science", + "contact_person": "Dr. Sarah Johnson", + "tags": ["analytics", "business-intelligence", "automation", "reporting"], + "metadata": { + "framework": "TensorFlow", + "language": "Python", + "memory_usage": "2GB", + "processing_time": "5-10 minutes" + } +} + +response = requests.post(url, headers=headers, data=json.dumps(data)) +print(response.json()) +``` + +## Response Formats + +### Successful New Agent Registration + +When a new agent is successfully registered: + +```json +{ + "status": "success", + "message": "Agent data collected successfully", + "agent_id": "64f7b8a9e1234567890abcde" +} +``` + +### Usage Tracking Response + +When an agent with the same name already exists, the system logs usage instead of creating a duplicate: + +```json +{ + "status": "usage_logged", + "message": "Agent already exists, usage tracked", + "agent_name": "CustomerSupportBot" +} +``` + +### Error Responses + +#### Invalid API Key (401) +```json +{ + "detail": "Invalid API key" +} +``` + +#### Unsupported Media Type (415) +```json +{ + "error": "Unsupported Media Type", + "message": "Request must be JSON" +} +``` + +#### Database Error (500) +```json +{ + "error": "Database Error", + "message": "Failed to store agent data. MongoDB may be unavailable or there was an error processing the request.", + "agent_data": { + "name": "YourAgentName", + "description": "Your agent description", + "purpose": "Your agent purpose", + "tool": "YourAgentTool" + } +} +``` + +#### Database Unavailable (503) +```json +{ + "error": "Database Unavailable", + "message": "MongoDB connection is not available. Please check the database setup.", + "agent_data": { + "name": "YourAgentName", + "description": "Your agent description", + "purpose": "Your agent purpose", + "tool": "YourAgentTool" + } +} +``` + +## Usage Tracking Behavior + +The API implements intelligent duplicate detection and usage tracking: + +### New Agent Registration +- When you register an agent with a **new name**, the system creates a new agent record +- Returns a success response with the new agent's unique ID + +### Usage Tracking +- When you register an agent with an **existing name**, the system: + - Logs the usage attempt with a timestamp + - Compares the submitted data with the existing agent data + - Updates the existing agent record if any fields have changed + - Returns a usage tracking response + +### Data Updates +If usage tracking detects that the submitted agent data differs from the existing record (excluding the name field), the system will automatically update the existing agent with the new information. This ensures agent records stay current while maintaining usage statistics. + +## Date Format + +All date fields (`creation_date`, `last_updated`) should be provided in **ISO 8601 format**: +- `"2024-08-22T14:45:30Z"` (with timezone) +- `"2024-08-22T14:45:30"` (without timezone) + +## Health Check + +You can check if the API is operational by sending a GET request with JSON Accept header: + +```bash +curl -H "Accept: application/json" https://ai-sandbox.oliver.solutions/agent_collector/agents +``` + +Response: +```json +{ + "status": "healthy", + "message": "Agent collector API is running", + "timestamp": "2024-08-22T14:45:30.123456", + "database": { + "healthy": true, + "connection_status": "connected" + } +} +``` + +## Best Practices + +1. **Use descriptive names**: Agent names should be unique and descriptive to avoid confusion +2. **Include version information**: Use the `version` field to track agent iterations +3. **Set appropriate status**: Keep the `status` field updated to reflect the agent's current state +4. **Provide contact information**: Include a `contact_person` for operational support +5. **Use tags effectively**: Tags help with categorization and searching +6. **Handle errors gracefully**: Always check response status codes and handle errors appropriately + +## Rate Limiting + +Currently, there are no explicit rate limits on the API, but please be considerate of the shared infrastructure and avoid excessive concurrent requests. + +## Support + +For technical support or questions about the Agent Registration API, please contact the AgentHub development team. \ No newline at end of file diff --git a/main.py b/main.py index 23663d1..6246658 100644 --- a/main.py +++ b/main.py @@ -71,6 +71,7 @@ def get_template_context(request: Request, current_user=None, **kwargs): "current_user": current_user, "base_path": config.get_base_path(), "msal_enabled": msal_auth.is_msal_available(), + "show_local_login": config.show_local_login(), **kwargs } @@ -117,6 +118,32 @@ async def require_admin(current_user: dict = Depends(get_current_user_from_cooki raise HTTPException(status_code=403, detail="Admin access required") return current_user +def create_agent_response(agent: dict) -> models.AiAgentResponse: + """Helper function to create AiAgentResponse with all fields including Quality Audit""" + return models.AiAgentResponse( + agent_id=str(agent["_id"]), + agent_name=agent["agent_name"], + agent_tool=agent.get("agent_tool"), + agent_description=agent.get("agent_description"), + agent_purpose=agent.get("agent_purpose"), + agent_version=agent.get("agent_version"), + agent_status=agent.get("agent_status"), + agent_location=agent.get("agent_location"), + agent_department=agent.get("agent_department"), + agent_contact_person=agent.get("agent_contact_person"), + agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None, + agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None, + agent_tags=agent.get("agent_tags"), + agent_metadata=agent.get("agent_metadata"), + agent_userbase=agent.get("agent_userbase"), + agent_capabilities=agent.get("agent_capabilities"), + quality_audit_status=agent.get("quality_audit_status", False), + quality_audit_updated_by=agent.get("quality_audit_updated_by"), + quality_audit_updated_at=agent.get("quality_audit_updated_at"), + quality_audit_updated_by_name=agent.get("quality_audit_updated_by_name"), + created_by=agent["created_by"] + ) + async def verify_agent_collector_api_key(x_api_key: str = Header(alias="X-API-Key")): """Verify static API key for agent collector endpoints""" expected_key = os.getenv("AGENT_COLLECTOR_API_KEY") @@ -141,6 +168,7 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate) return { "agent_name": collector_data.name, + "agent_tool": collector_data.tool, "agent_description": collector_data.description, "agent_purpose": collector_data.purpose, "agent_location": collector_data.location, @@ -230,7 +258,18 @@ async def register_form( @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): current_user = await get_current_user_optional(request) - return templates.TemplateResponse("login.html", get_template_context(request, current_user)) + + # Add MSAL configuration if enabled + context = get_template_context(request, current_user) + if msal_auth.is_msal_available(): + msal_config = config.get_msal_config() + context.update({ + "client_id": msal_config["client_id"], + "authority": msal_config["authority"], + "redirect_uri": msal_config["redirect_uri"] + }) + + return templates.TemplateResponse("login.html", context) @app.post("/login", response_class=HTMLResponse) async def login_form( @@ -279,8 +318,14 @@ async def login_form( # Regular user goes to all agents page response = RedirectResponse(url=get_app_url("agent-management"), status_code=303) - # Set token in cookie (for demo purposes) - response.set_cookie(key="access_token", value=token) + # Set token in cookie with proper attributes + response.set_cookie( + key="access_token", + value=token, + httponly=True, + samesite="lax", + secure=False # Set to True in production with HTTPS + ) return response except Exception as e: @@ -289,79 +334,84 @@ async def login_form( {"request": request, "error": f"Login failed: {str(e)}"} ) -# Azure AD/MSAL Authentication Routes -async def azure_login(request: Request): - """Initiate Azure AD login with PKCE""" - try: - msal_instance = msal_auth.get_msal_instance() - auth_data = msal_instance.get_auth_url() - - # Store PKCE parameters in session - request.session["msal_state"] = auth_data["state"] - request.session["msal_code_verifier"] = auth_data["code_verifier"] - - # Redirect to Azure AD - return RedirectResponse(url=auth_data["auth_url"], status_code=302) - - except Exception as e: - return templates.TemplateResponse( - "login.html", - {"request": request, "error": f"Azure login failed: {str(e)}"} - ) +# Azure AD/MSAL Authentication - Using popup-based authentication per specification -async def azure_callback(request: Request): - """Handle Azure AD callback and complete authentication""" + +@app.post("/api/auth/azure/token") +async def azure_token_exchange(request: Request): + """Exchange Azure AD token for local JWT token with proper validation""" try: - # Get authorization code and state from callback - auth_code = request.query_params.get("code") - state = request.query_params.get("state") - error = request.query_params.get("error") + data = await request.json() + access_token = data.get("access_token") + id_token = data.get("id_token") - if error: - error_description = request.query_params.get("error_description", "Unknown error") - return templates.TemplateResponse( - "login.html", - {"request": request, "error": f"Azure AD error: {error_description}"} + if not id_token: + raise HTTPException(status_code=400, detail="Missing ID token") + + # Validate JWT token against Azure AD public keys (as per specification) + import jwt + import requests + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + import base64 + + msal_config = config.get_msal_config() + + # Get Azure AD public keys for token verification + jwks_uri = f"{msal_config['authority']}/discovery/v2.0/keys" + jwks_response = requests.get(jwks_uri) + jwks = jwks_response.json() + + # Decode token header to get key ID + unverified_header = jwt.get_unverified_header(id_token) + kid = unverified_header.get("kid") + + # Find the matching public key + public_key = None + for key in jwks["keys"]: + if key["kid"] == kid: + # Convert JWK to PEM format for validation + n = base64.urlsafe_b64decode(key["n"] + "==") + e = base64.urlsafe_b64decode(key["e"] + "==") + + # Convert to RSA public key + numbers = rsa.RSAPublicNumbers( + int.from_bytes(e, byteorder="big"), + int.from_bytes(n, byteorder="big") + ) + public_key = numbers.public_key() + break + + if not public_key: + raise HTTPException(status_code=400, detail="Unable to verify token signature") + + # Convert to PEM format for JWT library + pem_key = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + # Validate JWT token signature and claims + try: + id_token_claims = jwt.decode( + id_token, + pem_key, + algorithms=["RS256"], + audience=msal_config["client_id"], + issuer=f"{msal_config['authority']}/v2.0" ) + except jwt.InvalidTokenError as e: + raise HTTPException(status_code=400, detail=f"Token validation failed: {str(e)}") - if not auth_code: - return templates.TemplateResponse( - "login.html", - {"request": request, "error": "No authorization code received from Azure AD"} - ) - - # Validate state parameter (CSRF protection) - session_state = request.session.get("msal_state") - code_verifier = request.session.get("msal_code_verifier") - - if not session_state or not code_verifier: - return templates.TemplateResponse( - "login.html", - {"request": request, "error": "Session expired. Please try logging in again."} - ) - - msal_instance = msal_auth.get_msal_instance() - if not msal_instance.validate_state(state, session_state): - return templates.TemplateResponse( - "login.html", - {"request": request, "error": "Invalid state parameter. Possible CSRF attack."} - ) - - # Exchange authorization code for tokens - token_result = msal_instance.acquire_token_by_auth_code(auth_code, code_verifier) - if not token_result: - return templates.TemplateResponse( - "login.html", - {"request": request, "error": "Failed to acquire token from Azure AD"} - ) - - # Extract user profile - user_profile = msal_instance.get_user_profile(token_result) - if not user_profile: - return templates.TemplateResponse( - "login.html", - {"request": request, "error": "Failed to get user profile from Azure AD"} - ) + # Create user profile from validated ID token claims + user_profile = { + "azure_ad_id": id_token_claims.get("oid"), + "email": id_token_claims.get("email") or id_token_claims.get("preferred_username"), + "full_name": id_token_claims.get("name"), + "first_name": id_token_claims.get("given_name"), + "last_name": id_token_claims.get("family_name"), + "tenant_id": id_token_claims.get("tid") + } # Create or update user in local database user = await crud.create_or_update_azure_user(user_profile) @@ -369,29 +419,30 @@ async def azure_callback(request: Request): # Create JWT token for local session management jwt_token = auth.create_access_token({"sub": str(user["_id"])}) - # Clear MSAL session data - request.session.pop("msal_state", None) - request.session.pop("msal_code_verifier", None) + # Return user info and set secure cookie (following specification) + response = JSONResponse({ + "success": True, + "is_admin": user.get("is_admin", False), + "email": user.get("email"), + "full_name": user.get("full_name") + }) - # Redirect based on user role - if user.get("is_admin"): - response = RedirectResponse(url=get_app_url("admin"), status_code=303) - else: - response = RedirectResponse(url=get_app_url("agent-management"), status_code=303) + # Set JWT token in httpOnly cookie with security flags (as per specification) + response.set_cookie( + key="access_token", + value=jwt_token, + httponly=True, + samesite="lax", + secure=False, # Set to True in production with HTTPS + max_age=24 * 60 * 60 # 24 hours as per specification + ) - # Set JWT token in cookie - response.set_cookie(key="access_token", value=jwt_token) return response + except HTTPException: + raise except Exception as e: - # Clear session on error - request.session.pop("msal_state", None) - request.session.pop("msal_code_verifier", None) - - return templates.TemplateResponse( - "login.html", - {"request": request, "error": f"Authentication failed: {str(e)}"} - ) + raise HTTPException(status_code=400, detail=f"Token exchange failed: {str(e)}") @app.get("/agent-register", response_class=HTMLResponse) async def agent_register_page(request: Request): @@ -404,6 +455,7 @@ async def agent_register_page(request: Request): async def agent_register_form( request: Request, agent_name: str = Form(...), + agent_tool: str = Form(...), agent_description: str = Form(None), agent_purpose: str = Form(None), agent_version: str = Form(None), @@ -413,7 +465,8 @@ async def agent_register_form( agent_contact_person: str = Form(None), agent_tags: str = Form(None), agent_userbase: str = Form(None), - agent_capabilities: str = Form(None) + agent_capabilities: str = Form(None), + quality_audit_status: bool = Form(False) ): try: # Get user from cookie - require authentication @@ -426,6 +479,7 @@ async def agent_register_form( # Prepare agent data agent_data = { "agent_name": agent_name, + "agent_tool": agent_tool, "agent_description": agent_description, "agent_purpose": agent_purpose, "agent_version": agent_version, @@ -443,6 +497,18 @@ async def agent_register_form( if agent_capabilities: agent_data["agent_capabilities"] = [cap.strip() for cap in agent_capabilities.split(',') if cap.strip()] + # Handle Quality Audit - only admins can set it to True + if quality_audit_status and current_user.get("is_admin"): + # Admin is setting quality audit to true + from datetime import datetime + agent_data["quality_audit_status"] = True + agent_data["quality_audit_updated_by"] = user_id + agent_data["quality_audit_updated_at"] = datetime.utcnow().isoformat() + agent_data["quality_audit_updated_by_name"] = current_user.get("full_name", current_user.get("email")) + else: + # Non-admin or quality audit not checked + agent_data["quality_audit_status"] = False + # Remove None values agent_data = {k: v for k, v in agent_data.items() if v is not None} @@ -635,77 +701,23 @@ async def get_all_agents_for_users(current_user: dict = Depends(get_current_user """Get all agents for regular users (read-only access)""" agents = await crud.get_all_agents() return [ - models.AiAgentResponse( - agent_id=str(agent["_id"]), - agent_name=agent["agent_name"], - agent_description=agent.get("agent_description"), - agent_purpose=agent.get("agent_purpose"), - agent_version=agent.get("agent_version"), - agent_status=agent.get("agent_status"), - agent_location=agent.get("agent_location"), - agent_department=agent.get("agent_department"), - agent_contact_person=agent.get("agent_contact_person"), - agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None, - agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None, - agent_tags=agent.get("agent_tags"), - agent_metadata=agent.get("agent_metadata"), - agent_userbase=agent.get("agent_userbase"), - agent_capabilities=agent.get("agent_capabilities"), - created_by=agent["created_by"] - ) for agent in agents + create_agent_response(agent) for agent in agents ] @app.post("/api/agents", response_model=models.AiAgentResponse) async def create_agent(agent: models.AiAgentCreate, current_user: dict = Depends(get_current_user_from_cookie)): agent_data = agent.model_dump() created_agent = await crud.create_agent(agent_data, str(current_user["_id"])) - return models.AiAgentResponse( - agent_id=str(created_agent["_id"]), - agent_name=created_agent["agent_name"], - agent_description=created_agent.get("agent_description"), - agent_purpose=created_agent.get("agent_purpose"), - agent_version=created_agent.get("agent_version"), - agent_status=created_agent.get("agent_status"), - agent_location=created_agent.get("agent_location"), - agent_department=created_agent.get("agent_department"), - agent_contact_person=created_agent.get("agent_contact_person"), - agent_created_at=created_agent["created_at"].isoformat(), - agent_updated_at=created_agent["updated_at"].isoformat(), - agent_tags=created_agent.get("agent_tags"), - agent_metadata=created_agent.get("agent_metadata"), - agent_userbase=created_agent.get("agent_userbase"), - agent_capabilities=created_agent.get("agent_capabilities"), - created_by=created_agent["created_by"] - ) + return create_agent_response(created_agent) @app.get("/api/agents", response_model=List[models.AiAgentResponse]) async def get_user_agents(current_user: dict = Depends(get_current_user_from_cookie)): agents = await crud.get_agents_by_user(str(current_user["_id"])) return [ - models.AiAgentResponse( - agent_id=str(agent["_id"]), - agent_name=agent["agent_name"], - agent_description=agent.get("agent_description"), - agent_purpose=agent.get("agent_purpose"), - agent_version=agent.get("agent_version"), - agent_status=agent.get("agent_status"), - agent_location=agent.get("agent_location"), - agent_department=agent.get("agent_department"), - agent_contact_person=agent.get("agent_contact_person"), - agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None, - agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None, - agent_tags=agent.get("agent_tags"), - agent_metadata=agent.get("agent_metadata"), - agent_userbase=agent.get("agent_userbase"), - agent_capabilities=agent.get("agent_capabilities"), - created_by=agent["created_by"] - ) for agent in agents + create_agent_response(agent) for agent in agents ] -# Conditionally register MSAL routes if available -if msal_auth.is_msal_available(): - app.get("/auth/azure/login")(azure_login) - app.get("/auth/azure/callback")(azure_callback) +# MSAL routes no longer needed - using popup authentication @app.get("/api/agents/{agent_id}", response_model=models.AiAgentResponse) async def get_agent(agent_id: str, current_user: dict = Depends(get_current_user_from_cookie)): @@ -716,23 +728,7 @@ async def get_agent(agent_id: str, current_user: dict = Depends(get_current_user if agent["created_by"] != str(current_user["_id"]) and not current_user.get("is_admin"): raise HTTPException(status_code=403, detail="Not authorized to view this agent") - return models.AiAgentResponse( - agent_id=str(agent["_id"]), - agent_name=agent["agent_name"], - agent_description=agent.get("agent_description"), - agent_purpose=agent.get("agent_purpose"), - agent_version=agent.get("agent_version"), - agent_status=agent.get("agent_status"), - agent_location=agent.get("agent_location"), - agent_department=agent.get("agent_department"), - agent_contact_person=agent.get("agent_contact_person"), - agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None, - agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None, - agent_tags=agent.get("agent_tags"), - agent_metadata=agent.get("agent_metadata"), - agent_userbase=agent.get("agent_userbase"), - created_by=agent["created_by"] - ) + return create_agent_response(agent) @app.put("/api/agents/{agent_id}", response_model=models.AiAgentResponse) async def update_agent(agent_id: str, agent: models.AiAgentCreate, current_user: dict = Depends(get_current_user_from_cookie)): @@ -748,28 +744,24 @@ async def update_agent(agent_id: str, agent: models.AiAgentCreate, current_user: # For regular users, pass user_id to enforce ownership at DB level # For admins, pass None to allow editing any agent user_id_filter = str(current_user["_id"]) if not current_user.get("is_admin") else None - updated_agent = await crud.update_agent(agent_id, agent.model_dump(), user_id_filter) + + # Prepare admin info for Quality Audit updates + admin_user_info = None + agent_data = agent.model_dump() + + if current_user.get("is_admin"): + admin_user_info = { + "user_id": str(current_user["_id"]), + "user_name": current_user.get("full_name", current_user.get("email")), + "email": current_user.get("email") + } + + updated_agent = await crud.update_agent(agent_id, agent_data, user_id_filter, admin_user_info) if not updated_agent: raise HTTPException(status_code=404, detail="Agent not found or not authorized") - return models.AiAgentResponse( - agent_id=str(updated_agent["_id"]), - agent_name=updated_agent["agent_name"], - agent_description=updated_agent.get("agent_description"), - agent_purpose=updated_agent.get("agent_purpose"), - agent_version=updated_agent.get("agent_version"), - agent_status=updated_agent.get("agent_status"), - agent_location=updated_agent.get("agent_location"), - agent_department=updated_agent.get("agent_department"), - agent_contact_person=updated_agent.get("agent_contact_person"), - agent_created_at=updated_agent["created_at"].isoformat() if updated_agent.get("created_at") else None, - agent_updated_at=updated_agent["updated_at"].isoformat() if updated_agent.get("updated_at") else None, - agent_tags=updated_agent.get("agent_tags"), - agent_metadata=updated_agent.get("agent_metadata"), - agent_userbase=updated_agent.get("agent_userbase"), - created_by=updated_agent["created_by"] - ) + return create_agent_response(updated_agent) @app.delete("/api/agents/{agent_id}") async def delete_agent(agent_id: str, current_user: dict = Depends(get_current_user_from_cookie)): @@ -837,24 +829,7 @@ async def get_all_agents_admin(current_user: dict = Depends(require_admin)): agents = await crud.get_all_agents() return [ - models.AiAgentResponse( - agent_id=str(agent["_id"]), - agent_name=agent["agent_name"], - agent_description=agent.get("agent_description"), - agent_purpose=agent.get("agent_purpose"), - agent_version=agent.get("agent_version"), - agent_status=agent.get("agent_status"), - agent_location=agent.get("agent_location"), - agent_department=agent.get("agent_department"), - agent_contact_person=agent.get("agent_contact_person"), - agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None, - agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None, - agent_tags=agent.get("agent_tags"), - agent_metadata=agent.get("agent_metadata"), - agent_userbase=agent.get("agent_userbase"), - agent_capabilities=agent.get("agent_capabilities"), - created_by=agent["created_by"] - ) for agent in agents + create_agent_response(agent) for agent in agents ] # Agent Collector API Endpoints (for compatibility with agent_collector app) @@ -1007,7 +982,4 @@ async def get_agent_usage_chart( except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get chart data: {str(e)}") -# Conditionally register MSAL routes if available -if msal_auth.is_msal_available(): - app.get("/auth/azure/login")(azure_login) - app.get("/auth/azure/callback")(azure_callback) +# MSAL routes no longer needed - using popup authentication diff --git a/models.py b/models.py index 5c8f56b..2de2c90 100644 --- a/models.py +++ b/models.py @@ -5,6 +5,7 @@ from typing import Optional class AiAgent(BaseModel): agent_id: int agent_name: str + agent_tool: str | None = Field(default=None, title="The tool or platform where the agent operates", max_length=100) agent_description: str | None = Field(default=None, title="The description of the agent", max_length=300) agent_purpose: str | None = Field(default=None, title="The purpose of the agent", max_length=200) agent_version: str | None = Field(default=None, title="The version of the agent", max_length=100) @@ -18,6 +19,10 @@ class AiAgent(BaseModel): agent_metadata: dict[str, str] | None = Field(default=None, title="Metadata associated with the agent") agent_userbase: list[str] | None = Field(default=None, title="Userbase associated with the agent") agent_capabilities: list[str] | None = Field(default=None, title="Capabilities of the agent") + quality_audit_status: bool | None = Field(default=False, title="Quality audit status") + quality_audit_updated_by: str | None = Field(default=None, title="Admin user ID who updated quality audit") + quality_audit_updated_at: str | None = Field(default=None, title="Quality audit last update timestamp") + quality_audit_updated_by_name: str | None = Field(default=None, title="Admin user name who updated quality audit") @@ -50,6 +55,7 @@ class Token(BaseModel): # Agent models for creation and response class AiAgentCreate(BaseModel): agent_name: str + agent_tool: str agent_description: Optional[str] = None agent_purpose: Optional[str] = None agent_version: Optional[str] = None @@ -61,10 +67,15 @@ class AiAgentCreate(BaseModel): agent_metadata: Optional[dict[str, str]] = None agent_userbase: Optional[list[str]] = None agent_capabilities: Optional[list[str]] = None + quality_audit_status: Optional[bool] = False + quality_audit_updated_by: Optional[str] = None + quality_audit_updated_at: Optional[str] = None + quality_audit_updated_by_name: Optional[str] = None class AiAgentResponse(BaseModel): agent_id: str agent_name: str + agent_tool: Optional[str] = None agent_description: Optional[str] = None agent_purpose: Optional[str] = None agent_version: Optional[str] = None @@ -78,6 +89,10 @@ class AiAgentResponse(BaseModel): agent_metadata: Optional[dict[str, str]] = None agent_userbase: Optional[list[str]] = None agent_capabilities: Optional[list[str]] = None + quality_audit_status: Optional[bool] = None + quality_audit_updated_by: Optional[str] = None + quality_audit_updated_at: Optional[str] = None + quality_audit_updated_by_name: Optional[str] = None created_by: str # Agent Collector API Models (for compatibility with agent_collector app) @@ -85,6 +100,7 @@ class AgentCollectorCreate(BaseModel): name: str = Field(min_length=1) description: str = Field(min_length=1) purpose: str = Field(min_length=1) + tool: str = Field(min_length=1) location: Optional[str] = None userbase: Optional[list[str]] = None version: Optional[str] = None diff --git a/msal_auth.py b/msal_auth.py index c2f6ce0..fbb171f 100644 --- a/msal_auth.py +++ b/msal_auth.py @@ -11,8 +11,8 @@ if config.is_msal_enabled(): else: msal = None -# MSAL Scopes -SCOPES = ["User.Read"] +# MSAL Scopes - Following specification +SCOPES = ["openid", "profile", "email"] class MSALAuth: def __init__(self): @@ -36,7 +36,7 @@ class MSALAuth: raise ValueError("Missing Azure AD configuration. Check environment variables.") def _build_msal_app(self, cache=None): - """Create MSAL confidential client application""" + """Create MSAL public client application for SPA""" return msal.PublicClientApplication( self.client_id, authority=self.authority, @@ -52,7 +52,7 @@ class MSALAuth: def get_auth_url(self, session_state: Optional[str] = None) -> Dict[str, Any]: """ - Generate authorization URL with PKCE challenge + Generate authorization URL with PKCE challenge for SPA Returns: dict with auth_url, state, and code_verifier for session storage """ if not self.enabled: @@ -60,19 +60,30 @@ class MSALAuth: app = self._build_msal_app() - # Generate PKCE parameters + # Generate PKCE parameters (required for SPA) code_verifier = secrets.token_urlsafe(96) # Generate state parameter for CSRF protection state = session_state or secrets.token_urlsafe(32) - auth_url = app.get_authorization_request_url( - scopes=SCOPES, - redirect_uri=self.redirect_uri, - state=state, - code_challenge=self._generate_pkce_challenge(code_verifier), - code_challenge_method="S256" - ) + code_challenge = self._generate_pkce_challenge(code_verifier) + + # Manual URL construction to ensure PKCE parameters are included + # MSAL library sometimes doesn't include PKCE params correctly for SPA + import urllib.parse + + auth_params = { + "client_id": self.client_id, + "response_type": "code", + "redirect_uri": self.redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "response_mode": "query" + } + + auth_url = f"{self.authority}/oauth2/v2.0/authorize?" + urllib.parse.urlencode(auth_params) return { "auth_url": auth_url, @@ -85,22 +96,43 @@ class MSALAuth: code_verifier: str, scopes: Optional[list] = None) -> Optional[Dict[str, Any]]: """ - Exchange authorization code for tokens using PKCE + Exchange authorization code for tokens using PKCE for SPA """ if not self.enabled: raise RuntimeError("MSAL is disabled") + if not code_verifier: + print("MSAL Error: code_verifier is required for SPA") + return None + app = self._build_msal_app() if scopes is None: scopes = SCOPES - result = app.acquire_token_by_authorization_code( - auth_code, - scopes=scopes, - redirect_uri=self.redirect_uri, - code_verifier=code_verifier - ) + # Manual token request for PKCE flow - MSAL library may not support code_verifier properly + import requests + + token_url = f"{self.authority}/oauth2/v2.0/token" + token_data = { + "client_id": self.client_id, + "scope": " ".join(scopes), + "code": auth_code, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + "code_verifier": code_verifier + } + + try: + response = requests.post(token_url, data=token_data) + result = response.json() + + if response.status_code != 200: + result["error"] = result.get("error", "token_request_failed") + + except Exception as e: + print(f"MSAL Error: Manual token request failed: {e}") + return None if "error" in result: print(f"MSAL Error: {result.get('error_description', result.get('error'))}") diff --git a/msal_pkce_flow.md b/msal_pkce_flow.md new file mode 100644 index 0000000..e2eb058 --- /dev/null +++ b/msal_pkce_flow.md @@ -0,0 +1,224 @@ +# MSAL PKCE Authentication Flow Specification + +## Overview + +This document specifies the Microsoft Authentication Library (MSAL) implementation with Proof Key for Code Exchange (PKCE) flow used in this web application. The implementation uses popup-based authentication with Azure AD/Entra ID and includes server-side JWT validation with httpOnly cookies for secure session management. + +## Architecture Components + +### Frontend Components +- **MSAL Browser Library**: `msal-browser.min.js` v2.15.0 +- **Authentication Method**: Popup-based login with PKCE +- **Token Storage**: Session storage for MSAL tokens, httpOnly cookies for server sessions +- **Cache Configuration**: Session storage with fallback cookie for auth state + +### Backend Components +- **Server-Side Validation**: Custom JWT validation using Firebase JWT library +- **Session Management**: httpOnly cookies with security headers +- **Token Validation**: Real-time validation against Azure AD public keys +- **Authentication Middleware**: Server-side protection for all routes + +## Azure AD Configuration + +### Application Registration +- **Client ID**: `9079054c-9620-4757-a256-23413042f1ef` +- **Tenant ID**: `e519c2e6-bc6d-4fdf-8d9c-923c2f002385` +- **Authority URL**: `https://login.microsoftonline.com/{tenantId}` +- **Application Type**: Single Page Application (SPA) +- **Authentication Flow**: Authorization Code with PKCE +- **Redirect URI**: Dynamic based on application origin + +### Required Scopes +- `openid` - OpenID Connect authentication +- `profile` - User profile information +- `email` - User email address + +## MSAL Configuration + +### Client Configuration +```javascript +const msalConfig = { + auth: { + clientId: "9079054c-9620-4757-a256-23413042f1ef", + authority: "https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385", + redirectUri: window.location.origin + window.location.pathname.replace(/\/$/, '') + }, + cache: { + cacheLocation: "sessionStorage", + storeAuthStateInCookie: true, + } +}; +``` + +### Login Request Configuration +```javascript +const loginRequest = { + scopes: ["openid", "profile", "email"], + prompt: "select_account" +}; +``` + +## Authentication Flow Steps + +### 1. User Access Control +- **Server-Side Gate**: All pages require authentication via `AuthMiddleware->requireAuth()` +- **Unauthenticated Users**: Redirected to login interface automatically +- **Token Validation**: Every request validates JWT against Azure AD public keys + +### 2. Login Initiation +1. User clicks "Sign In with Microsoft" button +2. `myMSALObj.loginPopup(loginRequest)` triggers PKCE flow +3. MSAL automatically generates PKCE code verifier and challenge +4. User redirected to Azure AD login in popup window + +### 3. Azure AD Authentication +1. User authenticates with Microsoft credentials +2. Azure AD validates user and application permissions +3. Authorization code returned with PKCE validation +4. MSAL exchanges code for tokens using PKCE code verifier + +### 4. Token Exchange and Validation +1. **Client receives tokens**: ID token and access token from MSAL +2. **Server submission**: Tokens sent to `auth.php` endpoint +3. **JWT validation**: Server validates token signature against Azure AD JWKS +4. **Claims validation**: Audience, issuer, expiration, and timing claims checked +5. **Cookie creation**: Valid tokens stored in httpOnly cookie with security flags + +### 5. Session Management +1. **httpOnly cookie**: Prevents JavaScript access to authentication token +2. **Security flags**: Secure, SameSite=Lax, path=/, 24-hour expiration +3. **Server-side validation**: Every request validates cookie token +4. **Automatic renewal**: No automatic token refresh implemented + +### 6. Logout Process +1. **Client logout**: `auth.php` called to clear server-side cookie +2. **MSAL logout**: `myMSALObj.logoutPopup()` clears client-side tokens +3. **Page reload**: Fresh page load to reflect authentication state + +## Security Implementation + +### Token Security +- **httpOnly cookies**: Prevent XSS access to authentication tokens +- **Secure flag**: Cookies only sent over HTTPS in production +- **SameSite protection**: CSRF protection with Lax policy +- **Server-side validation**: No client-side security dependencies + +### JWT Validation Process +1. **JWKS retrieval**: Public keys fetched from Azure AD OpenID configuration +2. **Signature verification**: JWT signature validated using RS256 algorithm +3. **Claims validation**: Comprehensive validation of all standard claims +4. **Real-time validation**: Every request validates token freshness + +### Supported Token Types +- **Primary**: ID tokens (preferred for user authentication) +- **Fallback**: Access tokens (Microsoft Graph audience) +- **Validation**: Both token types supported with appropriate audience validation + +## Server-Side Components + +### AuthMiddleware.php +- **Purpose**: Authentication gate for all protected resources +- **Methods**: + - `requireAuth()`: Enforce authentication for page access + - `isAuthenticated()`: Check current authentication status + - `setAuthToken()`: Store validated token in httpOnly cookie + - `clearAuthToken()`: Remove authentication cookie + +### JWTValidator.php +- **Purpose**: Validate Azure AD JWT tokens server-side +- **Features**: + - Real-time JWKS key retrieval from Azure AD + - Comprehensive claims validation (exp, nbf, aud, iss) + - Support for multiple token audiences and issuers + - Firebase JWT library integration + +### auth.php +- **Purpose**: Authentication endpoint for login/logout operations +- **Endpoints**: + - `POST /auth.php?action=login`: Process authentication tokens + - `POST /auth.php?action=logout`: Clear authentication session + - `GET /auth.php?action=status`: Check authentication status + +## Error Handling + +### Authentication Errors +- **Invalid tokens**: Clear error messages with retry options +- **Expired sessions**: Automatic redirect to login interface +- **Network errors**: Graceful fallback with user notifications +- **Popup blocking**: Error handling for blocked popup windows + +### Server-Side Error Handling +- **Token validation failures**: Specific error messages logged and returned +- **JWKS retrieval errors**: Fallback mechanisms and retry logic +- **Cookie security**: Proper error responses for cookie failures + +## Implementation Requirements + +### Frontend Dependencies +- MSAL Browser library v2.15.0 or later +- Modern browser with popup support +- JavaScript ES6+ support +- Fetch API for token submission + +### Backend Dependencies +- PHP 7.4+ with OpenSSL support +- Firebase JWT library via Composer +- cURL or file_get_contents for HTTPS requests +- Cookie support in server environment + +### Azure AD Requirements +- Application registered as Single Page Application +- Redirect URIs configured for all deployment environments +- Appropriate API permissions granted +- PKCE support enabled (default for SPAs) + +## Configuration Variables + +### Required Environment Configuration +```php +// AuthMiddleware.php +private $tenantId = 'e519c2e6-bc6d-4fdf-8d9c-923c2f002385'; +private $clientId = '9079054c-9620-4757-a256-23413042f1ef'; +``` + +### Cookie Security Configuration +```php +$cookieOptions = [ + 'expires' => time() + (24 * 60 * 60), // 24 hours + 'path' => '/', + 'domain' => '', + 'secure' => isset($_SERVER['HTTPS']), // HTTPS in production + 'httponly' => true, + 'samesite' => 'Lax' +]; +``` + +## Testing and Validation + +### Authentication Testing +1. **Successful login**: Verify popup authentication and cookie creation +2. **Token validation**: Confirm server-side JWT validation +3. **Session persistence**: Test page navigation with authentication +4. **Logout functionality**: Verify complete session termination + +### Security Testing +1. **Cookie security**: Verify httpOnly and secure flags +2. **XSS protection**: Confirm no client-side token exposure +3. **Token expiration**: Test automatic session invalidation +4. **CSRF protection**: Verify SameSite cookie protection + +## Migration Considerations + +### From Basic MSAL Implementation +1. **Add server-side validation**: Implement JWT validation middleware +2. **Replace client-side token storage**: Move to httpOnly cookies +3. **Add authentication gates**: Protect all server endpoints +4. **Update error handling**: Implement proper fallback mechanisms + +### Security Upgrades +1. **Remove client-side security**: Eliminate CSS-based protection +2. **Implement token validation**: Add real-time JWT verification +3. **Secure cookie implementation**: Use httpOnly with security flags +4. **Add comprehensive error handling**: User-friendly error messages + +This specification provides a complete reference for implementing secure MSAL PKCE authentication with server-side validation and httpOnly cookie session management. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9a097e7..9fb5a0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,3 +37,4 @@ urllib3==2.5.0 uvicorn==0.35.0 msal==1.26.0 itsdangerous==2.2.0 +cryptography==41.0.8 diff --git a/static/style.css b/static/style.css index 10fb07e..b5c89b4 100644 --- a/static/style.css +++ b/static/style.css @@ -1,7 +1,7 @@ :root { - --primary-color: #667eea; - --secondary-color: #764ba2; - --accent-color: #f093fb; + --primary-color: #f3ae3e; + --secondary-color: #f3ae3e; + --accent-color: #f3ae3e; --text-dark: #2d3748; --text-light: #718096; --bg-light: #f7fafc; @@ -11,8 +11,8 @@ --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); /* Microsoft Brand Colors */ - --ms-blue: #0078d4; - --ms-blue-dark: #106ebe; + --ms-blue: #f3ae3e; + --ms-blue-dark: #f3ae3e; --ms-gray: #5e5e5e; } @@ -170,7 +170,7 @@ body { .form-control:focus { border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + box-shadow: 0 0 0 3px rgba(243, 174, 62, 0.1); } .form-label { @@ -259,14 +259,14 @@ h2 { border-color: var(--ms-blue-dark); color: white; transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 120, 212, 0.3); + box-shadow: 0 4px 8px rgba(243, 174, 62, 0.3); } .btn-microsoft:focus { background-color: var(--ms-blue-dark); border-color: var(--ms-blue-dark); color: white; - box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.2); + box-shadow: 0 0 0 3px rgba(243, 174, 62, 0.2); } .btn-microsoft:active { diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 0e849ba..920a3ed 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -224,9 +224,111 @@ + + + +{% if msal_enabled %} + + +{% endif %} + {% endblock %} diff --git a/templates/search.html b/templates/search.html index 100068e..32ac28c 100644 --- a/templates/search.html +++ b/templates/search.html @@ -130,7 +130,7 @@