feat: add API key authentication system for ChatGPT Business
Implements comprehensive Bearer token authentication to ensure only authorized ChatGPT workspaces can access the MCP server. Core Features: - API key validation with constant-time comparison - Multi-key support for rotation grace periods - Rate limiting (5 failures per IP per 5 min) - Comprehensive audit logging of all auth attempts - IP-based failed attempt tracking Key Management: - generate_api_key.py: Create secure 64-char keys - rotate_api_key.py: Guided key rotation with backup - check_key_age.py: Automated expiration monitoring Infrastructure: - Traefik labels for HTTPS and rate limiting - Security headers (HSTS, CSP, X-Frame-Options) - Environment-based configuration - Docker secrets support Documentation: - AUTH_SETUP.md: Complete authentication setup guide - CHATGPT_SETUP.md: ChatGPT Business integration guide - KEY_ROTATION.md: Key rotation procedures and automation Security: - Read-only operations enforced - No write access to Gitea possible - All auth attempts logged with correlation IDs - Failed attempts trigger IP rate limits - Keys never logged in full (only hints) Breaking Changes: - AUTH_ENABLED defaults to true - MCP_API_KEYS environment variable now required - Minimum key length: 32 characters (64 recommended) Migration: 1. Generate API key: make generate-key 2. Add to .env: MCP_API_KEYS=<generated-key> 3. Restart: docker-compose restart aegis-mcp 4. Configure ChatGPT with Authorization header Closes requirements for ChatGPT Business exclusive access.
This commit is contained in:
13
.env.example
13
.env.example
@@ -5,6 +5,19 @@ GITEA_TOKEN=your-bot-user-token-here
|
||||
# MCP Server Configuration
|
||||
MCP_HOST=0.0.0.0
|
||||
MCP_PORT=8080
|
||||
MCP_DOMAIN=mcp.yourdomain.com # Domain for Traefik (if using)
|
||||
|
||||
# Authentication Configuration (REQUIRED)
|
||||
# Generate key with: python scripts/generate_api_key.py
|
||||
AUTH_ENABLED=true
|
||||
MCP_API_KEYS=your-generated-api-key-here
|
||||
|
||||
# Multiple keys (comma-separated for grace period during rotation):
|
||||
# MCP_API_KEYS=key1,key2,key3
|
||||
|
||||
# Authentication limits
|
||||
MAX_AUTH_FAILURES=5 # Max failures before rate limiting
|
||||
AUTH_FAILURE_WINDOW=300 # Time window in seconds (5 min)
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -44,6 +44,11 @@ htmlcov/
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.backup-*
|
||||
|
||||
# API keys
|
||||
keys/
|
||||
*.key
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
434
AUTH_SETUP.md
Normal file
434
AUTH_SETUP.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Authentication Setup Guide
|
||||
|
||||
This guide walks you through setting up API key authentication for AegisGitea MCP to ensure only your ChatGPT workspace can access your self-hosted Gitea instance.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
AegisGitea MCP uses **Bearer Token authentication** to secure access:
|
||||
|
||||
- **API Keys**: Cryptographically secure 64-character tokens
|
||||
- **Header-based**: Keys sent via `Authorization: Bearer <key>` header
|
||||
- **Multi-key support**: Multiple keys for rotation grace periods
|
||||
- **Rate limiting**: Failed auth attempts trigger IP-based rate limits
|
||||
- **Audit logging**: All auth attempts logged for security monitoring
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (5 minutes)
|
||||
|
||||
### 1. Generate API Key
|
||||
|
||||
```bash
|
||||
# Using Make
|
||||
make generate-key
|
||||
|
||||
# Or directly
|
||||
python3 scripts/generate_api_key.py
|
||||
```
|
||||
|
||||
This will:
|
||||
- Generate a secure 64-character API key
|
||||
- Show you the key (save it immediately!)
|
||||
- Provide `.env` configuration snippet
|
||||
- Optionally save metadata (not the key itself) for tracking
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
✓ API Key Generated Successfully!
|
||||
|
||||
API KEY:
|
||||
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
|
||||
|
||||
📋 Next Steps:
|
||||
1. Add this key to your .env file
|
||||
2. Restart the MCP server
|
||||
3. Configure ChatGPT Business
|
||||
```
|
||||
|
||||
### 2. Add Key to .env
|
||||
|
||||
```bash
|
||||
# Edit .env
|
||||
nano .env
|
||||
|
||||
# Add/update this line:
|
||||
MCP_API_KEYS=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
|
||||
```
|
||||
|
||||
### 3. Restart MCP Server
|
||||
|
||||
```bash
|
||||
docker-compose restart aegis-mcp
|
||||
|
||||
# Verify authentication is enabled
|
||||
docker-compose logs aegis-mcp | grep "authentication"
|
||||
```
|
||||
|
||||
Expected log output:
|
||||
```
|
||||
API key authentication ENABLED (1 key(s) configured)
|
||||
```
|
||||
|
||||
### 4. Test Authentication
|
||||
|
||||
```bash
|
||||
# Without key - should fail with 401
|
||||
curl https://mcp.yourdomain.com/mcp/tools
|
||||
|
||||
# With valid key - should succeed
|
||||
curl -H "Authorization: Bearer YOUR_KEY_HERE" \
|
||||
https://mcp.yourdomain.com/mcp/tools
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Enable/disable authentication
|
||||
AUTH_ENABLED=true # Set to false ONLY for testing
|
||||
|
||||
# API keys (comma-separated for multiple)
|
||||
MCP_API_KEYS=key1 # Single key
|
||||
MCP_API_KEYS=key1,key2,key3 # Multiple keys (rotation grace period)
|
||||
|
||||
# Rate limiting
|
||||
MAX_AUTH_FAILURES=5 # Max failed attempts before blocking
|
||||
AUTH_FAILURE_WINDOW=300 # Time window in seconds (5 min)
|
||||
```
|
||||
|
||||
### Multiple Keys (Rotation Grace Period)
|
||||
|
||||
During key rotation, you can temporarily allow multiple keys:
|
||||
|
||||
```bash
|
||||
# Old key
|
||||
MCP_API_KEYS=old-key-here
|
||||
|
||||
# Add new key (both work)
|
||||
MCP_API_KEYS=old-key-here,new-key-here
|
||||
|
||||
# After updating ChatGPT, remove old key
|
||||
MCP_API_KEYS=new-key-here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
### 1. Constant-Time Comparison
|
||||
|
||||
Keys are compared using `hmac.compare_digest()` to prevent timing attacks.
|
||||
|
||||
### 2. Rate Limiting
|
||||
|
||||
- **Threshold**: 5 failed attempts per IP
|
||||
- **Window**: 5 minutes (configurable)
|
||||
- **Action**: Reject all requests from that IP until window expires
|
||||
- **Logging**: High-severity security event logged
|
||||
|
||||
### 3. Audit Logging
|
||||
|
||||
Every authentication attempt logs:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-01-29T12:34:56.789Z",
|
||||
"event": "api_authentication",
|
||||
"status": "success",
|
||||
"client_ip": "203.0.113.42",
|
||||
"user_agent": "ChatGPT-User/1.0",
|
||||
"key_hint": "a1b2c3d4...e1f2"
|
||||
}
|
||||
```
|
||||
|
||||
Failed attempts:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-01-29T12:34:56.789Z",
|
||||
"event": "access_denied",
|
||||
"reason": "invalid_api_key",
|
||||
"client_ip": "203.0.113.42"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Key Format Validation
|
||||
|
||||
- Minimum length: 32 characters
|
||||
- Recommended length: 64 characters
|
||||
- Format: Hexadecimal string (0-9, a-f)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "No API keys configured" error
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
docker-compose logs: "No API keys configured in environment"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
1. Check `.env` file exists and contains `MCP_API_KEYS`
|
||||
2. Ensure no typos (`MCP_API_KEYS` not `MCP_API_KEY`)
|
||||
3. Verify key is at least 32 characters
|
||||
4. Restart container after updating `.env`
|
||||
|
||||
### Issue: "Invalid API key" on valid key
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
1. **Whitespace in .env**: Remove spaces around `=`
|
||||
```bash
|
||||
# Wrong
|
||||
MCP_API_KEYS = key-here
|
||||
|
||||
# Correct
|
||||
MCP_API_KEYS=key-here
|
||||
```
|
||||
|
||||
2. **Key truncated**: Ensure entire 64-char key is copied
|
||||
```bash
|
||||
# Check key length
|
||||
echo -n "your-key-here" | wc -c
|
||||
# Should output: 64
|
||||
```
|
||||
|
||||
3. **Container not restarted**: Always restart after changing `.env`
|
||||
```bash
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
### Issue: Rate limit blocking legitimate requests
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
"Too many failed authentication attempts"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
1. Check audit logs for failed attempts:
|
||||
```bash
|
||||
docker-compose exec aegis-mcp cat /var/log/aegis-mcp/audit.log | grep "invalid_api_key"
|
||||
```
|
||||
|
||||
2. Wait 5 minutes for rate limit to reset
|
||||
|
||||
3. If accidentally blocked yourself:
|
||||
```bash
|
||||
# Restart container (clears in-memory rate limits)
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
4. Adjust rate limit settings in `.env` if needed:
|
||||
```bash
|
||||
MAX_AUTH_FAILURES=10
|
||||
AUTH_FAILURE_WINDOW=600
|
||||
```
|
||||
|
||||
### Issue: ChatGPT can't connect after adding auth
|
||||
|
||||
See [CHATGPT_SETUP.md](CHATGPT_SETUP.md) for detailed ChatGPT configuration.
|
||||
|
||||
**Quick check:**
|
||||
1. Verify key in ChatGPT settings matches `.env`
|
||||
2. Check Authorization header format: `Bearer <key>` (with space)
|
||||
3. Test manually with curl first
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Authentication
|
||||
|
||||
### View All Auth Attempts
|
||||
|
||||
```bash
|
||||
# All auth events
|
||||
docker-compose exec aegis-mcp grep "api_authentication" /var/log/aegis-mcp/audit.log
|
||||
|
||||
# Failed attempts only
|
||||
docker-compose exec aegis-mcp grep "access_denied" /var/log/aegis-mcp/audit.log
|
||||
|
||||
# Rate limit triggers
|
||||
docker-compose exec aegis-mcp grep "auth_rate_limit_exceeded" /var/log/aegis-mcp/audit.log
|
||||
```
|
||||
|
||||
### Real-Time Monitoring
|
||||
|
||||
```bash
|
||||
# Follow auth events
|
||||
docker-compose exec aegis-mcp tail -f /var/log/aegis-mcp/audit.log | grep "auth"
|
||||
```
|
||||
|
||||
### Weekly Summary Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Save as scripts/auth_summary.sh
|
||||
|
||||
CONTAINER="aegis-gitea-mcp"
|
||||
LOG_PATH="/var/log/aegis-mcp/audit.log"
|
||||
|
||||
echo "=== Weekly Auth Summary ==="
|
||||
echo ""
|
||||
echo "Total auth attempts:"
|
||||
docker exec $CONTAINER grep "api_authentication" $LOG_PATH | wc -l
|
||||
|
||||
echo ""
|
||||
echo "Successful:"
|
||||
docker exec $CONTAINER grep "api_authentication.*success" $LOG_PATH | wc -l
|
||||
|
||||
echo ""
|
||||
echo "Failed:"
|
||||
docker exec $CONTAINER grep "access_denied.*invalid_api_key" $LOG_PATH | wc -l
|
||||
|
||||
echo ""
|
||||
echo "Rate limited IPs:"
|
||||
docker exec $CONTAINER grep "auth_rate_limit_exceeded" $LOG_PATH | wc -l
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Key Storage
|
||||
|
||||
- ✅ Store in `.env` file (never commit to git)
|
||||
- ✅ Use password manager for backup
|
||||
- ✅ Save key metadata (not key itself) for tracking
|
||||
- ❌ Never hardcode in application code
|
||||
- ❌ Never share via unencrypted channels
|
||||
|
||||
### 2. Key Rotation
|
||||
|
||||
- **Schedule**: Every 90 days (automated check available)
|
||||
- **Method**: Use `make rotate-key` for guided rotation
|
||||
- **Grace period**: Temporarily allow both old and new keys
|
||||
- **Verification**: Test new key before removing old key
|
||||
|
||||
See [KEY_ROTATION.md](KEY_ROTATION.md) for detailed rotation procedures.
|
||||
|
||||
### 3. Access Control
|
||||
|
||||
- **Single workspace**: One key for your ChatGPT Business account
|
||||
- **Multiple users**: One key per user (track with metadata)
|
||||
- **Revocation**: Remove key from `.env` and restart
|
||||
|
||||
### 4. Monitoring
|
||||
|
||||
- **Weekly**: Review auth logs for anomalies
|
||||
- **Monthly**: Check key age with `make check-key-age`
|
||||
- **Quarterly**: Rotate keys
|
||||
- **Alerts**: Set up notifications for rate limit events
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Disable Authentication (Testing Only)
|
||||
|
||||
**⚠️ WARNING: Only for isolated test environments**
|
||||
|
||||
```bash
|
||||
# .env
|
||||
AUTH_ENABLED=false
|
||||
```
|
||||
|
||||
Server will log critical warning:
|
||||
```
|
||||
API key authentication DISABLED - server is open to all requests!
|
||||
```
|
||||
|
||||
### Custom Rate Limiting
|
||||
|
||||
```bash
|
||||
# More lenient (for high-traffic)
|
||||
MAX_AUTH_FAILURES=20
|
||||
AUTH_FAILURE_WINDOW=600 # 10 minutes
|
||||
|
||||
# More strict (for high-security)
|
||||
MAX_AUTH_FAILURES=3
|
||||
AUTH_FAILURE_WINDOW=1800 # 30 minutes
|
||||
```
|
||||
|
||||
### Key Hashing (Future Enhancement)
|
||||
|
||||
Currently keys are stored in plaintext in `.env`. Future versions will support hashed keys in database.
|
||||
|
||||
**Current security model:**
|
||||
- ✅ Keys never logged in full (only first 8 + last 4 chars)
|
||||
- ✅ `.env` file protected by filesystem permissions
|
||||
- ✅ Container runs as non-root user
|
||||
- ⚠️ Ensure `.env` has restrictive permissions: `chmod 600 .env`
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
Before going to production:
|
||||
|
||||
- [ ] API key generated with `make generate-key` (not manually typed)
|
||||
- [ ] Key is exactly 64 characters
|
||||
- [ ] `.env` file has restrictive permissions (`chmod 600 .env`)
|
||||
- [ ] `.env` is in `.gitignore` (verify: `git status` shows no .env)
|
||||
- [ ] Container restarted after adding key
|
||||
- [ ] Authentication enabled (check logs for "ENABLED" message)
|
||||
- [ ] Test curl command succeeds with key, fails without
|
||||
- [ ] ChatGPT configured with Authorization header
|
||||
- [ ] Rate limiting tested (try 6 failed attempts)
|
||||
- [ ] Audit logs capturing auth events
|
||||
- [ ] Key rotation reminder set (90 days)
|
||||
- [ ] Backup key stored securely (password manager)
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I use the same key for multiple ChatGPT workspaces?**
|
||||
|
||||
A: Yes, but not recommended. Generate separate keys for auditability:
|
||||
```bash
|
||||
MCP_API_KEYS=workspace1-key,workspace2-key
|
||||
```
|
||||
|
||||
**Q: How do I revoke access immediately?**
|
||||
|
||||
A: Remove key from `.env` and restart:
|
||||
```bash
|
||||
# Edit .env (remove old key)
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
Access is revoked instantly after restart.
|
||||
|
||||
**Q: Does rate limiting affect valid keys?**
|
||||
|
||||
A: No. Rate limiting only applies to IPs that fail authentication. Valid keys are never rate limited.
|
||||
|
||||
**Q: Can I see which key was used for each request?**
|
||||
|
||||
A: Yes. Audit logs include `key_hint` (first 8 + last 4 characters):
|
||||
```json
|
||||
{"key_hint": "a1b2c3d4...e1f2"}
|
||||
```
|
||||
|
||||
**Q: What happens if I lose my API key?**
|
||||
|
||||
A: Generate a new one with `make generate-key`, update `.env`, restart server, update ChatGPT config. The old key stops working immediately after restart.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ChatGPT Setup Guide](CHATGPT_SETUP.md) - Configure ChatGPT Business
|
||||
- [Key Rotation Guide](KEY_ROTATION.md) - Automated rotation procedures
|
||||
- [Security Policy](SECURITY.md) - Overall security best practices
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Open an issue in the Gitea repository.
|
||||
435
CHATGPT_SETUP.md
Normal file
435
CHATGPT_SETUP.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# ChatGPT Business Setup Guide
|
||||
|
||||
Complete guide for connecting ChatGPT Business to your secured AegisGitea MCP server.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ✅ AegisGitea MCP server deployed and running
|
||||
- ✅ API key generated (see [AUTH_SETUP.md](AUTH_SETUP.md))
|
||||
- ✅ Traefik configured with HTTPS (or reverse proxy with TLS)
|
||||
- ✅ ChatGPT Business or Developer subscription
|
||||
|
||||
---
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### Step 1: Verify MCP Server is Running
|
||||
|
||||
```bash
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# Should show "Up" status
|
||||
aegis-gitea-mcp Up 0.0.0.0:8080->8080/tcp
|
||||
```
|
||||
|
||||
### Step 2: Test Authentication
|
||||
|
||||
```bash
|
||||
# Replace YOUR_API_KEY with your actual key
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://mcp.yourdomain.com/mcp/tools
|
||||
|
||||
# Expected: JSON response with available tools
|
||||
# If error: Check AUTH_SETUP.md troubleshooting
|
||||
```
|
||||
|
||||
### Step 3: Configure ChatGPT Business
|
||||
|
||||
1. **Open ChatGPT Settings**
|
||||
- Click your profile icon (bottom left)
|
||||
- Select "Settings"
|
||||
- Navigate to "Beta Features" or "Integrations"
|
||||
|
||||
2. **Add MCP Server**
|
||||
- Look for "Model Context Protocol" or "MCP Servers"
|
||||
- Click "Add Server" or "+"
|
||||
|
||||
3. **Enter Server Details**
|
||||
```
|
||||
Name: AegisGitea MCP
|
||||
URL: https://mcp.yourdomain.com
|
||||
Type: HTTP/SSE (Server-Sent Events)
|
||||
```
|
||||
|
||||
4. **Add Custom Header**
|
||||
```
|
||||
Header Name: Authorization
|
||||
Header Value: Bearer YOUR_API_KEY_HERE
|
||||
```
|
||||
|
||||
**Important:**
|
||||
- Include the word "Bearer" followed by a space
|
||||
- Then paste your full 64-character API key
|
||||
- No quotes around the key
|
||||
|
||||
5. **Save Configuration**
|
||||
|
||||
### Step 4: Test Connection
|
||||
|
||||
Start a new ChatGPT conversation and try:
|
||||
|
||||
```
|
||||
List my Gitea repositories
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```
|
||||
I found X repositories in your Gitea instance:
|
||||
|
||||
1. org/repo-name - Description here
|
||||
2. org/another-repo - Another description
|
||||
...
|
||||
```
|
||||
|
||||
**If it fails**, see Troubleshooting section below.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After setup, verify:
|
||||
|
||||
- [ ] ChatGPT shows "Connected" status for AegisGitea MCP
|
||||
- [ ] Test command "List my Gitea repositories" works
|
||||
- [ ] Audit logs show successful authentication:
|
||||
```bash
|
||||
docker-compose logs aegis-mcp | grep "api_authentication.*success"
|
||||
```
|
||||
- [ ] Can read file contents:
|
||||
```
|
||||
Show me the README.md file from org/repo-name
|
||||
```
|
||||
- [ ] Can browse repository structure:
|
||||
```
|
||||
What files are in the src/ directory of org/repo-name?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Commands
|
||||
|
||||
Once connected, try these commands in ChatGPT:
|
||||
|
||||
### Repository Discovery
|
||||
```
|
||||
What repositories do I have access to?
|
||||
List all my Gitea repositories
|
||||
Show me my private repositories
|
||||
```
|
||||
|
||||
### Repository Information
|
||||
```
|
||||
Tell me about the org/my-repo repository
|
||||
What's the default branch of org/my-repo?
|
||||
When was org/my-repo last updated?
|
||||
```
|
||||
|
||||
### File Operations
|
||||
```
|
||||
Show me the file tree of org/my-repo
|
||||
What files are in the src/ directory of org/my-repo?
|
||||
Read the README.md file from org/my-repo
|
||||
Show me the contents of src/main.py in org/my-repo
|
||||
```
|
||||
|
||||
### Code Understanding
|
||||
```
|
||||
Explain what the main function does in org/my-repo/src/main.py
|
||||
Summarize the architecture of org/my-repo
|
||||
What dependencies does org/my-repo use?
|
||||
Find all TODO comments in org/my-repo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Cannot connect to MCP server"
|
||||
|
||||
**Check 1: Server is running**
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose logs aegis-mcp
|
||||
```
|
||||
|
||||
**Check 2: Domain/URL is correct**
|
||||
```bash
|
||||
curl https://mcp.yourdomain.com/health
|
||||
# Should return: {"status": "healthy"}
|
||||
```
|
||||
|
||||
**Check 3: Firewall/Network**
|
||||
- Ensure port 443 is open
|
||||
- Check Traefik is routing correctly
|
||||
- Verify DNS resolves correctly
|
||||
|
||||
### Issue: "Authentication failed"
|
||||
|
||||
**Check 1: Authorization header format**
|
||||
|
||||
Correct:
|
||||
```
|
||||
Authorization: Bearer a1b2c3d4e5f6g7h8...
|
||||
```
|
||||
|
||||
Wrong:
|
||||
```
|
||||
Authorization: a1b2c3d4... (missing "Bearer ")
|
||||
Authorization: "Bearer a1b2c3d4..." (extra quotes)
|
||||
Authorization:Bearer a1b2c3d4... (missing space after colon)
|
||||
```
|
||||
|
||||
**Check 2: Key matches .env**
|
||||
```bash
|
||||
# Show configured keys (first 12 chars only for security)
|
||||
docker-compose exec aegis-mcp printenv MCP_API_KEYS | cut -c1-12
|
||||
```
|
||||
|
||||
Compare with your ChatGPT key (first 12 chars).
|
||||
|
||||
**Check 3: Server logs**
|
||||
```bash
|
||||
docker-compose logs aegis-mcp | tail -50
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `invalid_api_key` → Key doesn't match
|
||||
- `missing_api_key` → Header not sent
|
||||
- `invalid_key_format` → Key too short
|
||||
|
||||
### Issue: "No repositories visible"
|
||||
|
||||
**Verify bot user has access:**
|
||||
|
||||
1. Log into Gitea
|
||||
2. Go to repository Settings > Collaborators
|
||||
3. Confirm bot user is listed with Read permission
|
||||
4. If not, add bot user as collaborator
|
||||
|
||||
**Test manually:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_KEY" \
|
||||
https://mcp.yourdomain.com/mcp/tool/call \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool": "list_repositories", "arguments": {}}'
|
||||
```
|
||||
|
||||
### Issue: "Rate limited"
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
Too many failed authentication attempts
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
1. Wait 5 minutes
|
||||
2. Verify API key is correct
|
||||
3. Check audit logs:
|
||||
```bash
|
||||
docker-compose exec aegis-mcp grep "auth_rate_limit" /var/log/aegis-mcp/audit.log
|
||||
```
|
||||
4. If accidentally locked out:
|
||||
```bash
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
### Issue: ChatGPT says "Tool not available"
|
||||
|
||||
**Verify tools are registered:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_KEY" \
|
||||
https://mcp.yourdomain.com/mcp/tools
|
||||
```
|
||||
|
||||
Should return JSON with tools array.
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. API Key Management
|
||||
|
||||
- ✅ Store key in password manager
|
||||
- ✅ Never share key in chat or messages
|
||||
- ✅ Rotate key every 90 days
|
||||
- ❌ Don't paste key in public ChatGPT conversations
|
||||
- ❌ Don't screenshot ChatGPT settings with key visible
|
||||
|
||||
### 2. Workspace Separation
|
||||
|
||||
If multiple team members:
|
||||
|
||||
```
|
||||
User A: Use key_a1b2c3...
|
||||
User B: Use key_b4c5d6...
|
||||
User C: Use key_c7d8e9...
|
||||
```
|
||||
|
||||
Configure in `.env`:
|
||||
```bash
|
||||
MCP_API_KEYS=key_a1b2c3...,key_b4c5d6...,key_c7d8e9...
|
||||
```
|
||||
|
||||
Each user configures their own ChatGPT with their specific key.
|
||||
|
||||
### 3. Monitoring Usage
|
||||
|
||||
Weekly check:
|
||||
```bash
|
||||
# Who's using the MCP server?
|
||||
docker-compose exec aegis-mcp grep "api_authentication" /var/log/aegis-mcp/audit.log | \
|
||||
grep "success" | \
|
||||
tail -20
|
||||
```
|
||||
|
||||
Look for `key_hint` to identify which key was used.
|
||||
|
||||
### 4. Incident Response
|
||||
|
||||
If key is compromised:
|
||||
|
||||
1. **Immediate:** Remove from `.env`
|
||||
```bash
|
||||
# Edit .env (remove compromised key)
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
2. **Generate new key**
|
||||
```bash
|
||||
make generate-key
|
||||
```
|
||||
|
||||
3. **Update ChatGPT** with new key
|
||||
|
||||
4. **Check audit logs** for unauthorized access:
|
||||
```bash
|
||||
docker-compose exec aegis-mcp grep "key_hint: <compromised_key_hint>" /var/log/aegis-mcp/audit.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom MCP Tool Calls (For Developers)
|
||||
|
||||
You can call tools directly via API:
|
||||
|
||||
```bash
|
||||
# List repositories
|
||||
curl -X POST https://mcp.yourdomain.com/mcp/tool/call \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tool": "list_repositories",
|
||||
"arguments": {}
|
||||
}'
|
||||
|
||||
# Get file contents
|
||||
curl -X POST https://mcp.yourdomain.com/mcp/tool/call \
|
||||
-H "Authorization: Bearer YOUR_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tool": "get_file_contents",
|
||||
"arguments": {
|
||||
"owner": "org",
|
||||
"repo": "my-repo",
|
||||
"filepath": "README.md"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Webhook Integration (Future)
|
||||
|
||||
For automated workflows, you can integrate MCP tools into CI/CD:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions (future enhancement)
|
||||
- name: Sync to Gitea via MCP
|
||||
run: |
|
||||
curl -X POST https://mcp.yourdomain.com/mcp/tool/call \
|
||||
-H "Authorization: Bearer ${{ secrets.MCP_API_KEY }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{...}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### 1. Rate Limiting Awareness
|
||||
|
||||
Traefik limits: **60 requests/minute**
|
||||
|
||||
For heavy usage:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
labels:
|
||||
- "traefik.http.middlewares.aegis-ratelimit.ratelimit.average=120"
|
||||
```
|
||||
|
||||
### 2. Large Files
|
||||
|
||||
Files >1MB are rejected by default. To increase:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
MAX_FILE_SIZE_BYTES=5242880 # 5MB
|
||||
```
|
||||
|
||||
### 3. Batch Operations
|
||||
|
||||
Instead of:
|
||||
```
|
||||
Show me file1.py
|
||||
Show me file2.py
|
||||
Show me file3.py
|
||||
```
|
||||
|
||||
Use:
|
||||
```
|
||||
Show me all Python files in the src/ directory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I use this with Claude or other AI assistants?**
|
||||
|
||||
A: Currently optimized for ChatGPT Business, but the MCP protocol is standard. Other AI assistants with MCP support should work with the same configuration.
|
||||
|
||||
**Q: Do I need a separate key for each ChatGPT conversation?**
|
||||
|
||||
A: No. One key per user/workspace. All conversations in that workspace use the same key.
|
||||
|
||||
**Q: Can ChatGPT modify my repositories?**
|
||||
|
||||
A: **No**. AegisGitea MCP is read-only by design. ChatGPT can only read code, never write/commit/push.
|
||||
|
||||
**Q: What happens if I hit the rate limit?**
|
||||
|
||||
A: You'll receive a 429 error. Wait 1 minute and try again. The limit is per IP, not per key.
|
||||
|
||||
**Q: Can I use this from mobile ChatGPT app?**
|
||||
|
||||
A: Yes, if your ChatGPT Business account syncs to mobile. The MCP configuration follows your account.
|
||||
|
||||
**Q: How do I disconnect ChatGPT?**
|
||||
|
||||
A: Go to ChatGPT Settings > MCP Servers > Remove "AegisGitea MCP"
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Key Rotation Guide](KEY_ROTATION.md) - Rotate keys every 90 days
|
||||
- [Authentication Setup](AUTH_SETUP.md) - Detailed auth configuration
|
||||
- [Security Policy](SECURITY.md) - Best practices and threat model
|
||||
|
||||
---
|
||||
|
||||
**Happy coding with AI-assisted Gitea access!** 🚀
|
||||
548
KEY_ROTATION.md
Normal file
548
KEY_ROTATION.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# API Key Rotation Guide
|
||||
|
||||
Comprehensive guide for rotating API keys in AegisGitea MCP with zero downtime.
|
||||
|
||||
---
|
||||
|
||||
## Why Rotate Keys?
|
||||
|
||||
- **Security hygiene**: Regular rotation limits exposure window
|
||||
- **Compliance**: Many security policies require quarterly rotation
|
||||
- **Compromise mitigation**: If a key leaks, rotation limits damage
|
||||
- **Audit trail**: New keys = clear timeline of when access changed
|
||||
|
||||
**Recommended schedule**: Every 90 days
|
||||
|
||||
---
|
||||
|
||||
## Automated Rotation (Recommended)
|
||||
|
||||
### Step 1: Check Key Age
|
||||
|
||||
```bash
|
||||
make check-key-age
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
Key: a1b2c3d4...
|
||||
Created: 2026-01-29 (85 days ago)
|
||||
Expires: 2026-04-29 (in 5 days)
|
||||
Status: ⚠️ WARNING
|
||||
```
|
||||
|
||||
### Step 2: Rotate Key
|
||||
|
||||
```bash
|
||||
make rotate-key
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Show current keys
|
||||
2. Generate new 64-character key
|
||||
3. Offer rotation strategies (replace or grace period)
|
||||
4. Backup old `.env` file
|
||||
5. Update `.env` with new key
|
||||
6. Provide next steps
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Found 1 existing key(s)
|
||||
1. a1b2c3d4...e1f2
|
||||
|
||||
✓ New API key generated!
|
||||
|
||||
Rotation Strategy:
|
||||
1. Replace all keys with new key (recommended)
|
||||
2. Add new key, keep old keys (grace period)
|
||||
3. Cancel
|
||||
|
||||
Choose option [1/2/3]: 2
|
||||
|
||||
✓ New key will be added (total: 2 keys)
|
||||
|
||||
⚠️ IMPORTANT: Remove old keys manually after updating ChatGPT config
|
||||
```
|
||||
|
||||
### Step 3: Restart Server
|
||||
|
||||
```bash
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
Verify both keys work:
|
||||
```bash
|
||||
# Test old key (should still work if using grace period)
|
||||
curl -H "Authorization: Bearer OLD_KEY" \
|
||||
https://mcp.yourdomain.com/mcp/tools
|
||||
|
||||
# Test new key (should work)
|
||||
curl -H "Authorization: Bearer NEW_KEY" \
|
||||
https://mcp.yourdomain.com/mcp/tools
|
||||
```
|
||||
|
||||
### Step 4: Update ChatGPT
|
||||
|
||||
1. Go to ChatGPT Settings > MCP Servers
|
||||
2. Edit "AegisGitea MCP"
|
||||
3. Update Authorization header:
|
||||
```
|
||||
Authorization: Bearer NEW_KEY_HERE
|
||||
```
|
||||
4. Save
|
||||
|
||||
### Step 5: Verify & Clean Up
|
||||
|
||||
Test ChatGPT connection:
|
||||
```
|
||||
List my Gitea repositories
|
||||
```
|
||||
|
||||
If successful, remove old key:
|
||||
```bash
|
||||
# Edit .env - remove old key, keep only new key
|
||||
nano .env
|
||||
|
||||
# MCP_API_KEYS=old-key,new-key ← Remove old-key
|
||||
# MCP_API_KEYS=new-key ← Keep only new key
|
||||
|
||||
# Restart
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
Verify old key no longer works:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer OLD_KEY" \
|
||||
https://mcp.yourdomain.com/mcp/tools
|
||||
# Should return 401 Unauthorized
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Rotation (Step-by-Step)
|
||||
|
||||
If you prefer manual control:
|
||||
|
||||
### 1. Generate New Key
|
||||
|
||||
```bash
|
||||
make generate-key
|
||||
```
|
||||
|
||||
Save the output:
|
||||
```
|
||||
API KEY: b9c8d7e6f5g4h3i2...
|
||||
```
|
||||
|
||||
### 2. Add to .env (Grace Period)
|
||||
|
||||
```bash
|
||||
nano .env
|
||||
|
||||
# Before:
|
||||
MCP_API_KEYS=a1b2c3d4e5f6g7h8...
|
||||
|
||||
# After (both keys work):
|
||||
MCP_API_KEYS=a1b2c3d4e5f6g7h8...,b9c8d7e6f5g4h3i2...
|
||||
```
|
||||
|
||||
### 3. Restart & Verify
|
||||
|
||||
```bash
|
||||
docker-compose restart aegis-mcp
|
||||
|
||||
# Check logs
|
||||
docker-compose logs aegis-mcp | grep "authentication"
|
||||
# Should show: "API key authentication ENABLED (2 key(s) configured)"
|
||||
```
|
||||
|
||||
### 4. Update ChatGPT
|
||||
|
||||
Update Authorization header with new key.
|
||||
|
||||
### 5. Remove Old Key
|
||||
|
||||
After confirming ChatGPT works with new key:
|
||||
|
||||
```bash
|
||||
nano .env
|
||||
|
||||
# Remove old key
|
||||
MCP_API_KEYS=b9c8d7e6f5g4h3i2...
|
||||
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rotation Strategies
|
||||
|
||||
### Strategy 1: Immediate Replacement (No Downtime Risk)
|
||||
|
||||
**Use when:** You can update ChatGPT config immediately
|
||||
|
||||
1. Generate new key
|
||||
2. Replace old key in `.env`
|
||||
3. Restart server
|
||||
4. Update ChatGPT within minutes
|
||||
|
||||
**Pros:** Clean, only one key at a time
|
||||
**Cons:** Must update ChatGPT immediately or service breaks
|
||||
|
||||
### Strategy 2: Grace Period (Zero Downtime)
|
||||
|
||||
**Use when:** You need time to update ChatGPT config
|
||||
|
||||
1. Generate new key
|
||||
2. Add new key, keep old key (both work)
|
||||
3. Restart server
|
||||
4. Update ChatGPT at your convenience
|
||||
5. Remove old key after verification
|
||||
|
||||
**Pros:** Zero downtime, test thoroughly
|
||||
**Cons:** Temporarily allows two keys
|
||||
|
||||
**Recommended grace period:** 24-48 hours max
|
||||
|
||||
### Strategy 3: Scheduled Maintenance Window
|
||||
|
||||
**Use when:** You want to coordinate with team
|
||||
|
||||
1. Announce maintenance window (e.g., Saturday 2 AM)
|
||||
2. Generate new key
|
||||
3. During window:
|
||||
- Replace key in `.env`
|
||||
- Restart server
|
||||
- Update all team members' ChatGPT configs
|
||||
4. Verify all users can access
|
||||
|
||||
**Pros:** Coordinated, everyone updates at once
|
||||
**Cons:** Requires coordination
|
||||
|
||||
---
|
||||
|
||||
## Multi-User Rotation
|
||||
|
||||
If multiple team members use different keys:
|
||||
|
||||
### Option A: Rotate One Key at a Time
|
||||
|
||||
```bash
|
||||
# Current state
|
||||
MCP_API_KEYS=alice-key,bob-key,charlie-key
|
||||
|
||||
# Rotate Alice's key
|
||||
MCP_API_KEYS=alice-new-key,bob-key,charlie-key
|
||||
|
||||
# Alice updates her ChatGPT, verify, continue
|
||||
```
|
||||
|
||||
### Option B: Rotate All Keys Simultaneously
|
||||
|
||||
```bash
|
||||
# Generate 3 new keys
|
||||
make generate-key # Save as alice-new
|
||||
make generate-key # Save as bob-new
|
||||
make generate-key # Save as charlie-new
|
||||
|
||||
# Grace period
|
||||
MCP_API_KEYS=alice-old,alice-new,bob-old,bob-new,charlie-old,charlie-new
|
||||
|
||||
# Each user updates
|
||||
# After all verified, remove old keys
|
||||
MCP_API_KEYS=alice-new,bob-new,charlie-new
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automated Rotation Schedule
|
||||
|
||||
### Setup Cron Job
|
||||
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
|
||||
# Check key age weekly
|
||||
0 9 * * MON /path/to/AegisGitea-MCP/scripts/check_key_age.py || echo "Keys need rotation"
|
||||
|
||||
# Or check daily at 2 AM
|
||||
0 2 * * * cd /path/to/AegisGitea-MCP && make check-key-age
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
- `0` = All keys OK
|
||||
- `1` = Warning (rotation recommended in 14 days)
|
||||
- `2` = Critical (rotation needed NOW)
|
||||
|
||||
### Email Notifications
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Save as scripts/check_and_notify.sh
|
||||
|
||||
cd /path/to/AegisGitea-MCP
|
||||
|
||||
# Run check
|
||||
make check-key-age > /tmp/key_status.txt 2>&1
|
||||
STATUS=$?
|
||||
|
||||
if [ $STATUS -eq 2 ]; then
|
||||
# Critical - send email
|
||||
mail -s "[CRITICAL] AegisGitea API Key Expired" admin@example.com < /tmp/key_status.txt
|
||||
elif [ $STATUS -eq 1 ]; then
|
||||
# Warning - send email
|
||||
mail -s "[WARNING] AegisGitea API Key Expiring Soon" admin@example.com < /tmp/key_status.txt
|
||||
fi
|
||||
```
|
||||
|
||||
### Discord/Slack Webhook
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Save as scripts/notify_discord.sh
|
||||
|
||||
WEBHOOK_URL="https://discord.com/api/webhooks/..."
|
||||
MESSAGE="⚠️ AegisGitea MCP: API key expires in 5 days. Run \`make rotate-key\`"
|
||||
|
||||
curl -X POST "$WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"content\": \"$MESSAGE\"}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Emergency Rotation (Compromised Key)
|
||||
|
||||
If you suspect a key has been compromised:
|
||||
|
||||
### Immediate Actions (< 5 minutes)
|
||||
|
||||
1. **Revoke compromised key immediately**
|
||||
```bash
|
||||
# Edit .env - remove compromised key
|
||||
nano .env
|
||||
|
||||
# Before
|
||||
MCP_API_KEYS=compromised-key
|
||||
|
||||
# After
|
||||
MCP_API_KEYS=
|
||||
|
||||
# Or temporarily disable auth
|
||||
AUTH_ENABLED=false
|
||||
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
2. **Check audit logs for unauthorized access**
|
||||
```bash
|
||||
docker-compose exec aegis-mcp grep "compromised_key_hint" /var/log/aegis-mcp/audit.log
|
||||
|
||||
# Look for suspicious IPs, unusual times, unexpected repositories
|
||||
```
|
||||
|
||||
3. **Generate new key**
|
||||
```bash
|
||||
make generate-key
|
||||
```
|
||||
|
||||
4. **Update .env with new key**
|
||||
```bash
|
||||
MCP_API_KEYS=new-secure-key
|
||||
AUTH_ENABLED=true
|
||||
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
5. **Update ChatGPT config**
|
||||
|
||||
### Investigation (< 30 minutes)
|
||||
|
||||
```bash
|
||||
# When was compromised key last used?
|
||||
docker-compose exec aegis-mcp grep "key_hint: compromised..." /var/log/aegis-mcp/audit.log | tail -1
|
||||
|
||||
# What repositories were accessed?
|
||||
docker-compose exec aegis-mcp grep "compromised..." /var/log/aegis-mcp/audit.log | grep "repository"
|
||||
|
||||
# From which IPs?
|
||||
docker-compose exec aegis-mcp grep "compromised..." /var/log/aegis-mcp/audit.log | grep "client_ip"
|
||||
```
|
||||
|
||||
### Post-Incident (< 24 hours)
|
||||
|
||||
1. Document timeline of compromise
|
||||
2. Review and improve key storage practices
|
||||
3. Consider additional security measures (IP allowlisting, MFA)
|
||||
4. Notify team if multi-user setup
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Rotation
|
||||
|
||||
### Issue: Both old and new keys fail after rotation
|
||||
|
||||
**Cause:** Server not restarted or `.env` syntax error
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check .env syntax
|
||||
cat .env | grep MCP_API_KEYS
|
||||
# Should be: MCP_API_KEYS=key1,key2
|
||||
|
||||
# No spaces around =
|
||||
# No quotes around keys
|
||||
# Comma-separated, no spaces
|
||||
|
||||
# Restart
|
||||
docker-compose restart aegis-mcp
|
||||
|
||||
# Check logs
|
||||
docker-compose logs aegis-mcp | tail -20
|
||||
```
|
||||
|
||||
### Issue: "No API keys configured" after rotation
|
||||
|
||||
**Cause:** Empty MCP_API_KEYS in `.env`
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Verify .env has key
|
||||
grep MCP_API_KEYS .env
|
||||
# Should NOT be empty
|
||||
|
||||
# If empty, add key
|
||||
echo "MCP_API_KEYS=your-key-here" >> .env
|
||||
|
||||
docker-compose restart aegis-mcp
|
||||
```
|
||||
|
||||
### Issue: Old key still works after removal
|
||||
|
||||
**Cause:** Server not restarted or cached config
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Force restart (not just reload)
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# Verify old key fails
|
||||
curl -H "Authorization: Bearer OLD_KEY" \
|
||||
https://mcp.yourdomain.com/mcp/tools
|
||||
# Should return 401
|
||||
```
|
||||
|
||||
### Issue: Lost .env.backup file
|
||||
|
||||
**Cause:** Backup not created during rotation
|
||||
|
||||
**Prevention:**
|
||||
```bash
|
||||
# Always backup before manual edits
|
||||
cp .env .env.backup-$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# The rotate script does this automatically
|
||||
```
|
||||
|
||||
**Recovery:**
|
||||
If you lost your key and no backup:
|
||||
1. Generate new key
|
||||
2. Update `.env`
|
||||
3. Update ChatGPT
|
||||
4. The old key is lost but system still works
|
||||
|
||||
---
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
Before rotation:
|
||||
- [ ] Check current key age: `make check-key-age`
|
||||
- [ ] Backup `.env`: `cp .env .env.backup`
|
||||
- [ ] Notify team if multi-user
|
||||
- [ ] Schedule maintenance window if needed
|
||||
|
||||
During rotation:
|
||||
- [ ] Generate new key: `make generate-key`
|
||||
- [ ] Save key securely (password manager)
|
||||
- [ ] Update `.env` with new key
|
||||
- [ ] Restart server: `docker-compose restart`
|
||||
- [ ] Verify new key works (curl test)
|
||||
- [ ] Update ChatGPT config
|
||||
- [ ] Test ChatGPT connection
|
||||
|
||||
After rotation:
|
||||
- [ ] Remove old key from `.env` (if using grace period)
|
||||
- [ ] Verify old key fails (curl test)
|
||||
- [ ] Check audit logs for successful auth with new key
|
||||
- [ ] Save key metadata: Save metadata when generating key
|
||||
- [ ] Set calendar reminder for next rotation (90 days)
|
||||
- [ ] Document rotation in changelog/runbook
|
||||
|
||||
---
|
||||
|
||||
## Key Metadata Tracking
|
||||
|
||||
When generating keys, save metadata:
|
||||
|
||||
```bash
|
||||
make generate-key
|
||||
# Choose "y" when prompted to save metadata
|
||||
```
|
||||
|
||||
This creates `keys/key-<id>-<date>.txt`:
|
||||
```
|
||||
API Key Metadata
|
||||
================
|
||||
|
||||
Key ID: a1b2c3d4...
|
||||
Description: Production ChatGPT Key
|
||||
Created: 2026-01-29T12:00:00+00:00
|
||||
Expires: 2026-04-29T12:00:00+00:00
|
||||
|
||||
NOTE: The actual API key is NOT stored in this file for security.
|
||||
Only metadata is saved for reference.
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Track key age automatically
|
||||
- Audit key lifecycle
|
||||
- Identify which key is which (if multiple)
|
||||
- Automated expiration warnings
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: What's the grace period for rotation?**
|
||||
|
||||
A: 24-48 hours is recommended. Keep both keys working just long enough to update ChatGPT, then remove the old key.
|
||||
|
||||
**Q: Can I rotate keys without downtime?**
|
||||
|
||||
A: Yes! Use Strategy 2 (Grace Period). Both old and new keys work simultaneously during the transition.
|
||||
|
||||
**Q: How do I know which key is oldest?**
|
||||
|
||||
A: Use `make check-key-age` if you saved metadata. Otherwise, check audit logs for last usage.
|
||||
|
||||
**Q: Should I rotate after a team member leaves?**
|
||||
|
||||
A: Yes, if they had access to the shared key. Or better: use per-user keys and just remove their key.
|
||||
|
||||
**Q: Can I automate the entire rotation?**
|
||||
|
||||
A: Partially. You can automate generation and notification, but ChatGPT config update requires manual action.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Authentication Setup](AUTH_SETUP.md) - Initial setup guide
|
||||
- [ChatGPT Configuration](CHATGPT_SETUP.md) - Update ChatGPT after rotation
|
||||
- [Security Policy](SECURITY.md) - Overall security best practices
|
||||
|
||||
---
|
||||
|
||||
**Set a reminder now:** Rotate your keys 90 days from today! 🔑
|
||||
17
Makefile
17
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install install-dev test lint format clean build run docker-build docker-up docker-down docker-logs
|
||||
.PHONY: help install install-dev test lint format clean build run docker-build docker-up docker-down docker-logs generate-key rotate-key check-key-age
|
||||
|
||||
help:
|
||||
@echo "AegisGitea MCP - Available Commands"
|
||||
@@ -11,6 +11,11 @@ help:
|
||||
@echo " make format Format code with black"
|
||||
@echo " make clean Remove build artifacts"
|
||||
@echo ""
|
||||
@echo "API Key Management:"
|
||||
@echo " make generate-key Generate new API key"
|
||||
@echo " make rotate-key Rotate existing API key"
|
||||
@echo " make check-key-age Check API key age and expiration"
|
||||
@echo ""
|
||||
@echo "Local Execution:"
|
||||
@echo " make run Run server locally (requires .env)"
|
||||
@echo ""
|
||||
@@ -19,6 +24,7 @@ help:
|
||||
@echo " make docker-up Start containers"
|
||||
@echo " make docker-down Stop containers"
|
||||
@echo " make docker-logs View container logs"
|
||||
@echo " make docker-restart Restart MCP container"
|
||||
@echo ""
|
||||
|
||||
install:
|
||||
@@ -67,3 +73,12 @@ docker-restart:
|
||||
|
||||
docker-shell:
|
||||
docker-compose exec aegis-mcp /bin/bash
|
||||
|
||||
generate-key:
|
||||
python3 scripts/generate_api_key.py
|
||||
|
||||
rotate-key:
|
||||
python3 scripts/rotate_api_key.py
|
||||
|
||||
check-key-age:
|
||||
python3 scripts/check_key_age.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Convenience symlink to docker/docker-compose.yml
|
||||
# AegisGitea MCP - Docker Compose Configuration
|
||||
# Usage: docker-compose up -d
|
||||
|
||||
version: '3.8'
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
|
||||
networks:
|
||||
- aegis-network
|
||||
- traefik # Connect to Traefik network (if using Traefik)
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -42,6 +43,36 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Traefik labels for automatic HTTPS and routing
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
# Router configuration
|
||||
- "traefik.http.routers.aegis-mcp.rule=Host(`${MCP_DOMAIN:-mcp.example.com}`)"
|
||||
- "traefik.http.routers.aegis-mcp.entrypoints=websecure"
|
||||
- "traefik.http.routers.aegis-mcp.tls=true"
|
||||
- "traefik.http.routers.aegis-mcp.tls.certresolver=letsencrypt"
|
||||
|
||||
# Service configuration
|
||||
- "traefik.http.services.aegis-mcp.loadbalancer.server.port=8080"
|
||||
|
||||
# Rate limiting middleware (60 req/min per IP)
|
||||
- "traefik.http.middlewares.aegis-ratelimit.ratelimit.average=60"
|
||||
- "traefik.http.middlewares.aegis-ratelimit.ratelimit.period=1m"
|
||||
- "traefik.http.middlewares.aegis-ratelimit.ratelimit.burst=10"
|
||||
|
||||
# Security headers middleware
|
||||
- "traefik.http.middlewares.aegis-security.headers.sslredirect=true"
|
||||
- "traefik.http.middlewares.aegis-security.headers.stsSeconds=31536000"
|
||||
- "traefik.http.middlewares.aegis-security.headers.stsIncludeSubdomains=true"
|
||||
- "traefik.http.middlewares.aegis-security.headers.stsPreload=true"
|
||||
- "traefik.http.middlewares.aegis-security.headers.contentTypeNosniff=true"
|
||||
- "traefik.http.middlewares.aegis-security.headers.browserXssFilter=true"
|
||||
- "traefik.http.middlewares.aegis-security.headers.forceSTSHeader=true"
|
||||
|
||||
# Apply middlewares to router
|
||||
- "traefik.http.routers.aegis-mcp.middlewares=aegis-ratelimit@docker,aegis-security@docker"
|
||||
|
||||
volumes:
|
||||
aegis-mcp-logs:
|
||||
driver: local
|
||||
@@ -49,3 +80,8 @@ volumes:
|
||||
networks:
|
||||
aegis-network:
|
||||
driver: bridge
|
||||
|
||||
# External Traefik network (create with: docker network create traefik)
|
||||
# Comment out if not using Traefik
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
140
scripts/check_key_age.py
Executable file
140
scripts/check_key_age.py
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check API key age and alert if rotation needed."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Check API key age from keys/ metadata directory."""
|
||||
print("=" * 70)
|
||||
print("AegisGitea MCP - API Key Age Check")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Check keys directory
|
||||
keys_dir = Path(__file__).parent.parent / "keys"
|
||||
|
||||
if not keys_dir.exists():
|
||||
print("⚠️ No keys/ directory found")
|
||||
print()
|
||||
print(" Key metadata is not being tracked.")
|
||||
print(" Run scripts/generate_api_key.py and save metadata to track key age.")
|
||||
print()
|
||||
sys.exit(0)
|
||||
|
||||
# Find all key metadata files
|
||||
metadata_files = list(keys_dir.glob("key-*.txt"))
|
||||
|
||||
if not metadata_files:
|
||||
print("⚠️ No key metadata files found in keys/")
|
||||
print()
|
||||
print(" Run scripts/generate_api_key.py and save metadata to track key age.")
|
||||
print()
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Found {len(metadata_files)} key metadata file(s)")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
warnings = []
|
||||
critical = []
|
||||
|
||||
for metadata_file in sorted(metadata_files):
|
||||
with open(metadata_file, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract metadata
|
||||
key_id_match = re.search(r"Key ID:\s+([^\n]+)", content)
|
||||
created_match = re.search(r"Created:\s+([^\n]+)", content)
|
||||
expires_match = re.search(r"Expires:\s+([^\n]+)", content)
|
||||
|
||||
if not all([key_id_match, created_match, expires_match]):
|
||||
print(f"⚠️ Could not parse: {metadata_file.name}")
|
||||
continue
|
||||
|
||||
key_id = key_id_match.group(1).strip()
|
||||
created_str = created_match.group(1).strip()
|
||||
expires_str = expires_match.group(1).strip()
|
||||
|
||||
# Parse dates
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
|
||||
expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
|
||||
except ValueError as e:
|
||||
print(f"⚠️ Could not parse dates in: {metadata_file.name}")
|
||||
print(f" Error: {e}")
|
||||
continue
|
||||
|
||||
# Calculate age and time to expiration
|
||||
age_days = (now - created_at).days
|
||||
days_to_expiration = (expires_at - now).days
|
||||
|
||||
# Status
|
||||
if days_to_expiration < 0:
|
||||
status = "❌ EXPIRED"
|
||||
critical.append(key_id)
|
||||
elif days_to_expiration <= 7:
|
||||
status = "⚠️ CRITICAL"
|
||||
critical.append(key_id)
|
||||
elif days_to_expiration <= 14:
|
||||
status = "⚠️ WARNING"
|
||||
warnings.append(key_id)
|
||||
else:
|
||||
status = "✓ OK"
|
||||
|
||||
print(f"Key: {key_id}")
|
||||
print(f" Created: {created_at.strftime('%Y-%m-%d')} ({age_days} days ago)")
|
||||
print(f" Expires: {expires_at.strftime('%Y-%m-%d')} (in {days_to_expiration} days)")
|
||||
print(f" Status: {status}")
|
||||
print()
|
||||
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Summary
|
||||
if critical:
|
||||
print("❌ CRITICAL: {} key(s) require immediate rotation!".format(len(critical)))
|
||||
print()
|
||||
print(" Keys:")
|
||||
for key_id in critical:
|
||||
print(f" • {key_id}")
|
||||
print()
|
||||
print(" Action: Run scripts/rotate_api_key.py NOW")
|
||||
print()
|
||||
sys.exit(2)
|
||||
elif warnings:
|
||||
print("⚠️ WARNING: {} key(s) will expire soon".format(len(warnings)))
|
||||
print()
|
||||
print(" Keys:")
|
||||
for key_id in warnings:
|
||||
print(f" • {key_id}")
|
||||
print()
|
||||
print(" Action: Schedule key rotation in the next few days")
|
||||
print(" Run: scripts/rotate_api_key.py")
|
||||
print()
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✓ All keys are valid and within rotation schedule")
|
||||
print()
|
||||
print(" Next check: Run this script again in 1 week")
|
||||
print()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
113
scripts/generate_api_key.py
Executable file
113
scripts/generate_api_key.py
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a cryptographically secure API key for AegisGitea MCP."""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from aegis_gitea_mcp.auth import generate_api_key
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Generate and display a new API key."""
|
||||
print("=" * 70)
|
||||
print("AegisGitea MCP - API Key Generator")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Get optional description
|
||||
description = input("Enter description for this key (e.g., 'ChatGPT Business'): ").strip()
|
||||
if not description:
|
||||
description = "Generated key"
|
||||
|
||||
# Generate key
|
||||
api_key = generate_api_key(length=64)
|
||||
|
||||
# Calculate expiration date (90 days from now)
|
||||
created_at = datetime.now(timezone.utc)
|
||||
expires_at = created_at + timedelta(days=90)
|
||||
|
||||
print()
|
||||
print("✓ API Key Generated Successfully!")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print(f"Description: {description}")
|
||||
print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)")
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("API KEY:")
|
||||
print(f" {api_key}")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("📋 Next Steps:")
|
||||
print()
|
||||
print("1. Add this key to your .env file:")
|
||||
print()
|
||||
print(f" MCP_API_KEYS={api_key}")
|
||||
print()
|
||||
print(" (If you have multiple keys, separate them with commas)")
|
||||
print()
|
||||
print("2. Restart the MCP server:")
|
||||
print()
|
||||
print(" docker-compose restart aegis-mcp")
|
||||
print()
|
||||
print("3. Configure ChatGPT Business:")
|
||||
print()
|
||||
print(" - Go to ChatGPT Settings > MCP Servers")
|
||||
print(" - Add custom header:")
|
||||
print(f" Authorization: Bearer {api_key}")
|
||||
print()
|
||||
print("4. Test the connection:")
|
||||
print()
|
||||
print(" Ask ChatGPT: 'List my Gitea repositories'")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("⚠️ IMPORTANT:")
|
||||
print()
|
||||
print(" • Store this key securely - it won't be shown again")
|
||||
print(" • This key should be rotated in 90 days")
|
||||
print(" • Set a reminder to rotate before expiration")
|
||||
print(" • Never commit this key to version control")
|
||||
print()
|
||||
print("=" * 70)
|
||||
|
||||
# Offer to save metadata to file
|
||||
save = input("\nSave key metadata to keys/ directory? [y/N]: ").strip().lower()
|
||||
if save == "y":
|
||||
keys_dir = Path(__file__).parent.parent / "keys"
|
||||
keys_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create metadata file
|
||||
key_id = api_key[:12]
|
||||
metadata_file = keys_dir / f"key-{key_id}-{created_at.strftime('%Y%m%d')}.txt"
|
||||
|
||||
with open(metadata_file, "w") as f:
|
||||
f.write(f"API Key Metadata\n")
|
||||
f.write(f"================\n\n")
|
||||
f.write(f"Key ID: {key_id}...\n")
|
||||
f.write(f"Description: {description}\n")
|
||||
f.write(f"Created: {created_at.isoformat()}\n")
|
||||
f.write(f"Expires: {expires_at.isoformat()}\n")
|
||||
f.write(f"\n")
|
||||
f.write(f"NOTE: The actual API key is NOT stored in this file for security.\n")
|
||||
f.write(f" Only metadata is saved for reference.\n")
|
||||
|
||||
print(f"\n✓ Metadata saved to: {metadata_file}")
|
||||
print(f"\n (The actual key is NOT saved - only you have it)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
175
scripts/rotate_api_key.py
Executable file
175
scripts/rotate_api_key.py
Executable file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rotate API key for AegisGitea MCP."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from aegis_gitea_mcp.auth import generate_api_key
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Rotate API key in .env file."""
|
||||
print("=" * 70)
|
||||
print("AegisGitea MCP - API Key Rotation")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Find .env file
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
|
||||
if not env_file.exists():
|
||||
print("❌ Error: .env file not found")
|
||||
print(f" Expected location: {env_file}")
|
||||
print()
|
||||
print(" Please create .env from .env.example first")
|
||||
sys.exit(1)
|
||||
|
||||
# Read current .env
|
||||
with open(env_file, "r") as f:
|
||||
env_content = f.read()
|
||||
|
||||
# Check if MCP_API_KEYS exists
|
||||
if "MCP_API_KEYS" not in env_content:
|
||||
print("❌ Error: MCP_API_KEYS not found in .env file")
|
||||
print()
|
||||
print(" Please add MCP_API_KEYS to your .env file first")
|
||||
sys.exit(1)
|
||||
|
||||
# Extract current key
|
||||
match = re.search(r"MCP_API_KEYS=([^\n]+)", env_content)
|
||||
if not match:
|
||||
print("❌ Error: Could not parse MCP_API_KEYS from .env")
|
||||
sys.exit(1)
|
||||
|
||||
current_keys = match.group(1).strip()
|
||||
key_list = [k.strip() for k in current_keys.split(",") if k.strip()]
|
||||
|
||||
print(f"Found {len(key_list)} existing key(s)")
|
||||
print()
|
||||
|
||||
# Show current keys (first 8 chars only)
|
||||
for i, key in enumerate(key_list, 1):
|
||||
key_hint = f"{key[:8]}...{key[-4:]}" if len(key) >= 12 else "invalid"
|
||||
print(f" {i}. {key_hint}")
|
||||
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Generate new key
|
||||
new_key = generate_api_key(length=64)
|
||||
created_at = datetime.now(timezone.utc)
|
||||
expires_at = created_at + timedelta(days=90)
|
||||
|
||||
print("✓ New API key generated!")
|
||||
print()
|
||||
print(f"Created: {created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
print(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')} (90 days)")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Ask what to do with old keys
|
||||
print("Rotation Strategy:")
|
||||
print()
|
||||
print(" 1. Replace all keys with new key (recommended)")
|
||||
print(" 2. Add new key, keep old keys (grace period)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
|
||||
choice = input("Choose option [1/2/3]: ").strip()
|
||||
|
||||
if choice == "3":
|
||||
print("\nRotation cancelled.")
|
||||
sys.exit(0)
|
||||
elif choice == "2":
|
||||
# Add new key to list
|
||||
key_list.append(new_key)
|
||||
new_keys_str = ",".join(key_list)
|
||||
print("\n✓ New key will be added (total: {} keys)".format(len(key_list)))
|
||||
print("\n⚠️ IMPORTANT: Remove old keys manually after updating ChatGPT config")
|
||||
elif choice == "1":
|
||||
# Replace with only new key
|
||||
new_keys_str = new_key
|
||||
print("\n✓ All old keys will be replaced with new key")
|
||||
else:
|
||||
print("\n❌ Invalid choice. Operation cancelled.")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
|
||||
# Update .env file
|
||||
new_env_content = re.sub(
|
||||
r"MCP_API_KEYS=([^\n]+)",
|
||||
f"MCP_API_KEYS={new_keys_str}",
|
||||
env_content
|
||||
)
|
||||
|
||||
# Backup old .env
|
||||
backup_file = env_file.with_suffix(f".env.backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}")
|
||||
with open(backup_file, "w") as f:
|
||||
f.write(env_content)
|
||||
|
||||
print(f"✓ Backed up old .env to: {backup_file.name}")
|
||||
|
||||
# Write new .env
|
||||
with open(env_file, "w") as f:
|
||||
f.write(new_env_content)
|
||||
|
||||
print(f"✓ Updated .env file with new key(s)")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("📋 Next Steps:")
|
||||
print()
|
||||
print("1. Restart the MCP server:")
|
||||
print()
|
||||
print(" docker-compose restart aegis-mcp")
|
||||
print()
|
||||
print("2. Update ChatGPT Business configuration:")
|
||||
print()
|
||||
print(" - Go to ChatGPT Settings > MCP Servers")
|
||||
print(" - Update Authorization header:")
|
||||
print(f" Authorization: Bearer {new_key}")
|
||||
print()
|
||||
print("3. Test the connection:")
|
||||
print()
|
||||
print(" Ask ChatGPT: 'List my Gitea repositories'")
|
||||
print()
|
||||
print("4. If using grace period (option 2):")
|
||||
print()
|
||||
print(" - After confirming ChatGPT works with new key")
|
||||
print(" - Manually remove old keys from .env")
|
||||
print(" - Restart server again")
|
||||
print()
|
||||
print("-" * 70)
|
||||
print()
|
||||
print("⚠️ IMPORTANT:")
|
||||
print()
|
||||
print(f" • New API Key: {new_key}")
|
||||
print(" • Store this securely - it won't be shown again")
|
||||
print(" • Set a reminder to rotate in 90 days")
|
||||
print(" • Old .env backed up to:", backup_file.name)
|
||||
print()
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nOperation cancelled.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
243
src/aegis_gitea_mcp/auth.py
Normal file
243
src/aegis_gitea_mcp/auth.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Authentication module for MCP server API key validation."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APIKeyValidator:
|
||||
"""Validates API keys for MCP server access."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize API key validator."""
|
||||
self.settings = get_settings()
|
||||
self.audit = get_audit_logger()
|
||||
self._failed_attempts: dict[str, list[datetime]] = {}
|
||||
|
||||
def _constant_time_compare(self, a: str, b: str) -> bool:
|
||||
"""Compare two strings in constant time to prevent timing attacks.
|
||||
|
||||
Args:
|
||||
a: First string
|
||||
b: Second string
|
||||
|
||||
Returns:
|
||||
True if strings are equal, False otherwise
|
||||
"""
|
||||
return hmac.compare_digest(a, b)
|
||||
|
||||
def _check_rate_limit(self, identifier: str) -> bool:
|
||||
"""Check if identifier has exceeded failed authentication rate limit.
|
||||
|
||||
Args:
|
||||
identifier: IP address or other identifier
|
||||
|
||||
Returns:
|
||||
True if within rate limit, False if exceeded
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
window_start = now.timestamp() - self.settings.auth_failure_window
|
||||
|
||||
# Clean up old attempts
|
||||
if identifier in self._failed_attempts:
|
||||
self._failed_attempts[identifier] = [
|
||||
attempt
|
||||
for attempt in self._failed_attempts[identifier]
|
||||
if attempt.timestamp() > window_start
|
||||
]
|
||||
|
||||
# Check count
|
||||
attempt_count = len(self._failed_attempts.get(identifier, []))
|
||||
return attempt_count < self.settings.max_auth_failures
|
||||
|
||||
def _record_failed_attempt(self, identifier: str) -> None:
|
||||
"""Record a failed authentication attempt.
|
||||
|
||||
Args:
|
||||
identifier: IP address or other identifier
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if identifier not in self._failed_attempts:
|
||||
self._failed_attempts[identifier] = []
|
||||
self._failed_attempts[identifier].append(now)
|
||||
|
||||
# Check if threshold exceeded
|
||||
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
|
||||
self.audit.log_security_event(
|
||||
event_type="auth_rate_limit_exceeded",
|
||||
description=f"IP {identifier} exceeded auth failure threshold",
|
||||
severity="high",
|
||||
metadata={
|
||||
"identifier": identifier,
|
||||
"failure_count": len(self._failed_attempts[identifier]),
|
||||
"window_seconds": self.settings.auth_failure_window,
|
||||
},
|
||||
)
|
||||
|
||||
def validate_api_key(
|
||||
self, provided_key: Optional[str], client_ip: str, user_agent: str
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Validate an API key.
|
||||
|
||||
Args:
|
||||
provided_key: API key provided by client
|
||||
client_ip: Client IP address
|
||||
user_agent: Client user agent string
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Check if authentication is enabled
|
||||
if not self.settings.auth_enabled:
|
||||
self.audit.log_security_event(
|
||||
event_type="auth_disabled",
|
||||
description="Authentication is disabled - allowing all requests",
|
||||
severity="critical",
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return True, None
|
||||
|
||||
# Check rate limit
|
||||
if not self._check_rate_limit(client_ip):
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="rate_limit_exceeded",
|
||||
)
|
||||
return False, "Too many failed authentication attempts. Please try again later."
|
||||
|
||||
# Check if key was provided
|
||||
if not provided_key:
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="missing_api_key",
|
||||
)
|
||||
return False, "Authorization header missing. Required: Authorization: Bearer <api-key>"
|
||||
|
||||
# Validate key format (should be at least 32 characters)
|
||||
if len(provided_key) < 32:
|
||||
self._record_failed_attempt(client_ip)
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="invalid_key_format",
|
||||
)
|
||||
return False, "Invalid API key format"
|
||||
|
||||
# Get valid API keys from config
|
||||
valid_keys = self.settings.mcp_api_keys
|
||||
|
||||
if not valid_keys:
|
||||
self.audit.log_security_event(
|
||||
event_type="no_api_keys_configured",
|
||||
description="No API keys configured in environment",
|
||||
severity="critical",
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return False, "Server configuration error: No API keys configured"
|
||||
|
||||
# Check against all valid keys (constant time comparison)
|
||||
is_valid = any(self._constant_time_compare(provided_key, valid_key) for valid_key in valid_keys)
|
||||
|
||||
if is_valid:
|
||||
# Success - log and return
|
||||
key_hint = f"{provided_key[:8]}...{provided_key[-4:]}"
|
||||
self.audit.log_tool_invocation(
|
||||
tool_name="api_authentication",
|
||||
result_status="success",
|
||||
params={"client_ip": client_ip, "user_agent": user_agent, "key_hint": key_hint},
|
||||
)
|
||||
return True, None
|
||||
else:
|
||||
# Failure - record attempt and log
|
||||
self._record_failed_attempt(client_ip)
|
||||
key_hint = f"{provided_key[:8]}..." if len(provided_key) >= 8 else "too_short"
|
||||
self.audit.log_access_denied(
|
||||
tool_name="api_authentication",
|
||||
reason="invalid_api_key",
|
||||
)
|
||||
self.audit.log_security_event(
|
||||
event_type="invalid_api_key_attempt",
|
||||
description=f"Invalid API key attempted from {client_ip}",
|
||||
severity="medium",
|
||||
metadata={
|
||||
"client_ip": client_ip,
|
||||
"user_agent": user_agent,
|
||||
"key_hint": key_hint,
|
||||
},
|
||||
)
|
||||
return False, "Invalid API key"
|
||||
|
||||
def extract_bearer_token(self, authorization_header: Optional[str]) -> Optional[str]:
|
||||
"""Extract bearer token from Authorization header.
|
||||
|
||||
Args:
|
||||
authorization_header: Authorization header value
|
||||
|
||||
Returns:
|
||||
Extracted token or None if invalid format
|
||||
"""
|
||||
if not authorization_header:
|
||||
return None
|
||||
|
||||
parts = authorization_header.split()
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
scheme, token = parts
|
||||
if scheme.lower() != "bearer":
|
||||
return None
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def generate_api_key(length: int = 64) -> str:
|
||||
"""Generate a cryptographically secure API key.
|
||||
|
||||
Args:
|
||||
length: Length of the key in characters (default: 64)
|
||||
|
||||
Returns:
|
||||
Generated API key as hex string
|
||||
"""
|
||||
return secrets.token_hex(length // 2)
|
||||
|
||||
|
||||
def hash_api_key(api_key: str) -> str:
|
||||
"""Hash an API key for secure storage (future use).
|
||||
|
||||
Args:
|
||||
api_key: Plain text API key
|
||||
|
||||
Returns:
|
||||
SHA256 hash of the key
|
||||
"""
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
|
||||
# Global validator instance
|
||||
_validator: Optional[APIKeyValidator] = None
|
||||
|
||||
|
||||
def get_validator() -> APIKeyValidator:
|
||||
"""Get or create global API key validator instance."""
|
||||
global _validator
|
||||
if _validator is None:
|
||||
_validator = APIKeyValidator()
|
||||
return _validator
|
||||
|
||||
|
||||
def reset_validator() -> None:
|
||||
"""Reset global validator instance (primarily for testing)."""
|
||||
global _validator
|
||||
_validator = None
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Configuration management for AegisGitea MCP server."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field, HttpUrl, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -67,6 +67,26 @@ class Settings(BaseSettings):
|
||||
ge=1,
|
||||
)
|
||||
|
||||
# Authentication configuration
|
||||
auth_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable API key authentication (disable only for testing)",
|
||||
)
|
||||
mcp_api_keys: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of valid API keys for MCP access (comma-separated in env)",
|
||||
)
|
||||
max_auth_failures: int = Field(
|
||||
default=5,
|
||||
description="Maximum authentication failures before rate limiting",
|
||||
ge=1,
|
||||
)
|
||||
auth_failure_window: int = Field(
|
||||
default=300, # 5 minutes
|
||||
description="Time window for counting auth failures (in seconds)",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
@@ -85,6 +105,41 @@ class Settings(BaseSettings):
|
||||
raise ValueError("gitea_token cannot be empty or whitespace")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("mcp_api_keys", mode="before")
|
||||
@classmethod
|
||||
def parse_api_keys(cls, v: object) -> List[str]:
|
||||
"""Parse API keys from comma-separated string or list."""
|
||||
if isinstance(v, str):
|
||||
# Split by comma and strip whitespace
|
||||
keys = [key.strip() for key in v.split(",") if key.strip()]
|
||||
return keys
|
||||
elif isinstance(v, list):
|
||||
return v
|
||||
return []
|
||||
|
||||
@field_validator("mcp_api_keys")
|
||||
@classmethod
|
||||
def validate_api_keys(cls, v: List[str], info) -> List[str]:
|
||||
"""Validate API keys if authentication is enabled."""
|
||||
# Get auth_enabled from values (it's already been processed)
|
||||
auth_enabled = info.data.get("auth_enabled", True)
|
||||
|
||||
if auth_enabled and not v:
|
||||
raise ValueError(
|
||||
"At least one API key must be configured when auth_enabled=True. "
|
||||
"Set MCP_API_KEYS environment variable or disable auth with AUTH_ENABLED=false"
|
||||
)
|
||||
|
||||
# Validate key format (at least 32 characters for security)
|
||||
for key in v:
|
||||
if len(key) < 32:
|
||||
raise ValueError(
|
||||
f"API keys must be at least 32 characters long. "
|
||||
f"Use scripts/generate_api_key.py to generate secure keys."
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
@property
|
||||
def gitea_base_url(self) -> str:
|
||||
"""Get Gitea base URL as string."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
from aegis_gitea_mcp.auth import get_validator
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient
|
||||
from aegis_gitea_mcp.mcp_protocol import (
|
||||
@@ -38,9 +39,10 @@ app = FastAPI(
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Global settings and audit logger
|
||||
# Global settings, audit logger, and auth validator
|
||||
settings = get_settings()
|
||||
audit = get_audit_logger()
|
||||
auth_validator = get_validator()
|
||||
|
||||
|
||||
# Tool dispatcher mapping
|
||||
@@ -52,6 +54,44 @@ TOOL_HANDLERS = {
|
||||
}
|
||||
|
||||
|
||||
# Authentication middleware
|
||||
@app.middleware("http")
|
||||
async def authenticate_request(request: Request, call_next):
|
||||
"""Authenticate all requests except health checks and root."""
|
||||
# Skip authentication for health check and root endpoints
|
||||
if request.url.path in ["/", "/health"]:
|
||||
return await call_next(request)
|
||||
|
||||
# Only authenticate MCP endpoints
|
||||
if not request.url.path.startswith("/mcp/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract client information
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent", "unknown")
|
||||
|
||||
# Extract Authorization header
|
||||
auth_header = request.headers.get("authorization")
|
||||
api_key = auth_validator.extract_bearer_token(auth_header)
|
||||
|
||||
# Validate API key
|
||||
is_valid, error_message = auth_validator.validate_api_key(api_key, client_ip, user_agent)
|
||||
|
||||
if not is_valid:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "Authentication failed",
|
||||
"message": error_message,
|
||||
"detail": "Please provide a valid API key in the Authorization header: Bearer <api-key>",
|
||||
},
|
||||
)
|
||||
|
||||
# Authentication successful - continue to endpoint
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event() -> None:
|
||||
"""Initialize server on startup."""
|
||||
@@ -59,6 +99,13 @@ async def startup_event() -> None:
|
||||
logger.info(f"Connected to Gitea instance: {settings.gitea_base_url}")
|
||||
logger.info(f"Audit logging enabled: {settings.audit_log_path}")
|
||||
|
||||
# Log authentication status
|
||||
if settings.auth_enabled:
|
||||
key_count = len(settings.mcp_api_keys)
|
||||
logger.info(f"API key authentication ENABLED ({key_count} key(s) configured)")
|
||||
else:
|
||||
logger.warning("API key authentication DISABLED - server is open to all requests!")
|
||||
|
||||
# Test Gitea connection
|
||||
try:
|
||||
async with GiteaClient() as gitea:
|
||||
|
||||
Reference in New Issue
Block a user