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:
2026-01-29 20:05:49 +01:00
parent a9708b33e2
commit eeaad748a6
13 changed files with 2263 additions and 4 deletions

View File

@@ -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
View File

@@ -44,6 +44,11 @@ htmlcov/
# Environment variables
.env
.env.local
.env.backup-*
# API keys
keys/
*.key
# Logs
*.log

434
AUTH_SETUP.md Normal file
View 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
View 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
View 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! 🔑

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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

View File

@@ -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."""

View File

@@ -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: