Skip to content

Commit 1f18262

Browse files
author
Anuj Shrivastava
committed
feat: add API key authentication (Model B key store)
- Add keystore.py: SHA-256 hashed, file-backed, thread-safe key store - Add APIKeyMiddleware: Bearer token auth for /sse and /messages - Add admin endpoints: POST/GET/DELETE /admin/keys (localhost-only) - Add /health endpoint for liveness probes - Update Dockerfile: VOLUME /data, EXPOSE 8005 - Update build.yaml: push to ghcr.io/ibm/verify-mcp-server - Update deploy-appserver.sh: port 8005, volume mount, key gen instructions - Update pyproject.toml: add uvicorn, starlette dependencies Signed-off-by: Anuj Shrivastava <anujshrivastava@Anujs-MacBook-Pro-691.local>
1 parent 2909249 commit 1f18262

6 files changed

Lines changed: 338 additions & 15 deletions

File tree

.github/workflows/build.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
platforms: linux/amd64,linux/arm64
3838
push: true
3939
tags: |
40-
ghcr.io/anujshrivastava15/verify-mcp-server:latest
41-
ghcr.io/anujshrivastava15/verify-mcp-server:${{ github.sha }}
40+
ghcr.io/ibm/verify-mcp-server:latest
41+
ghcr.io/ibm/verify-mcp-server:${{ github.sha }}
4242
cache-from: type=gha
4343
cache-to: type=gha,mode=max

container/Dockerfile

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@ RUN pip install --no-cache-dir .
1313
# Copy source code
1414
COPY src/ ./src/
1515

16-
# Pre-define environment variables (users must set values)
17-
ENV VERIFY_TENANT=""
18-
ENV API_CLIENT_ID=""
19-
ENV API_CLIENT_SECRET=""
20-
ENV VERIFY_SSL="true"
16+
# Pre-define environment variables (users must set values at runtime via -e)
17+
ENV VERIFY_TENANT="" \
18+
API_CLIENT_ID="" \
19+
VERIFY_SSL="true"
20+
# Note: API_CLIENT_SECRET passed at runtime via -e (not baked into image)
21+
22+
# Persistent volume for API key store (/data/keys.json)
23+
VOLUME /data
2124

2225
# Expose HTTP port (used in SSE mode)
23-
EXPOSE 8004
26+
EXPOSE 8005
2427

2528
# Default: stdio mode (for Claude Desktop / VS Code)
2629
# Use: --transport sse --host 0.0.0.0 --port 8004 for HTTP/SSE mode

deploy/deploy-appserver.sh

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
set -euo pipefail
77

88
IMAGE="ghcr.io/anujshrivastava15/verify-mcp-server:latest"
9-
CONTAINER="verify-mcp"
10-
PORT=8004
9+
CONTAINER="verify-mcp-server"
10+
PORT=8005
1111

1212
# ── Env (fill in real values or copy from your .env) ─────────────────────────
1313
VERIFY_TENANT="${VERIFY_TENANT:-https://security-squad-gsilab.verify.ibm.com}"
@@ -26,17 +26,26 @@ docker run -d \
2626
--name "$CONTAINER" \
2727
--restart unless-stopped \
2828
-p "${PORT}:${PORT}" \
29+
-v verify-mcp-data:/data \
2930
-e VERIFY_TENANT="$VERIFY_TENANT" \
3031
-e API_CLIENT_ID="$API_CLIENT_ID" \
3132
-e API_CLIENT_SECRET="$API_CLIENT_SECRET" \
3233
-e VERIFY_SSL="$VERIFY_SSL" \
3334
-e MCP_TRANSPORT=sse \
35+
-e MCP_PORT="${PORT}" \
3436
"$IMAGE"
3537

3638
echo "==> Waiting for startup…"
3739
sleep 4
3840

3941
echo "==> Health check"
40-
curl -sf --max-time 5 http://localhost:${PORT}/sse -o /dev/null && \
42+
curl -sf --max-time 5 http://localhost:${PORT}/health -o /dev/null && \
4143
echo "✅ Verify MCP Server is UP at http://$(hostname -I | awk '{print $1}'):${PORT}/sse" || \
4244
echo "❌ Health check failed — check: docker logs $CONTAINER"
45+
46+
echo ""
47+
echo "==> To generate an API key (required for MCP client auth):"
48+
echo " docker exec -it $CONTAINER curl -s -X POST http://localhost:${PORT}/admin/keys -H 'Content-Type: application/json' -d '{\"user\":\"admin@ibm.com\"}'"
49+
echo ""
50+
echo "==> To list keys: docker exec -it $CONTAINER curl -s http://localhost:${PORT}/admin/keys"
51+
echo "==> To revoke key: docker exec -it $CONTAINER curl -s -X DELETE http://localhost:${PORT}/admin/keys/<PREFIX>"

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ dependencies = [
1616
"mcp[cli]>=1.0.0",
1717
"httpx>=0.27.0",
1818
"python-dotenv>=1.0.0",
19+
"uvicorn>=0.30.0",
20+
"starlette>=0.37.0",
1921
]
2022

2123
[project.scripts]

src/keystore.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""API Key Store — server-side key management for MCP client authentication.
2+
3+
Implements admin-managed API keys with SHA-256 hashing, thread-safe
4+
file-backed persistence, and prefix-based identification/revocation.
5+
6+
Keys are stored as SHA-256 hashes in a JSON file (/data/keys.json by
7+
default). Raw keys are never persisted — only returned once at
8+
generation time.
9+
10+
Design mirrors the GDP MCP Server's Model B: Admin-Managed Key Store.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import hashlib
16+
import json
17+
import logging
18+
import os
19+
import secrets
20+
import threading
21+
from dataclasses import asdict, dataclass, field
22+
from datetime import datetime, timezone
23+
from pathlib import Path
24+
from typing import Any
25+
26+
logger = logging.getLogger(__name__)
27+
28+
# Default location — mounted as a Docker volume for persistence
29+
DEFAULT_KEYS_FILE = os.getenv("KEYS_FILE", "/data/keys.json")
30+
31+
32+
@dataclass
33+
class KeyRecord:
34+
"""Metadata for a single API key (raw key is never stored)."""
35+
36+
hash: str # SHA-256 hex digest of the raw key
37+
prefix: str # First 8 characters of the raw key (for identification)
38+
user: str # Email / label of the authorised user
39+
created_at: str = field( # ISO-8601 timestamp
40+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
41+
)
42+
43+
44+
class KeyStore:
45+
"""Thread-safe, file-backed API key store with SHA-256 hashing.
46+
47+
Public API:
48+
generate(user) → raw key (shown once)
49+
validate(raw_key) → bool
50+
list_keys() → list of {prefix, user, created_at}
51+
revoke(prefix) → bool
52+
has_any_keys() → bool
53+
"""
54+
55+
def __init__(self, keys_file: str = DEFAULT_KEYS_FILE) -> None:
56+
self._path = Path(keys_file)
57+
self._lock = threading.Lock()
58+
self._keys: list[KeyRecord] = []
59+
self._load()
60+
61+
# ── Public API ──────────────────────────────────────────────────
62+
63+
def generate(self, user: str) -> str:
64+
"""Generate a new 64-char hex API key for *user*.
65+
66+
Returns the raw key (displayed once — never stored in plain text).
67+
"""
68+
raw_key = secrets.token_hex(32) # 64 hex chars
69+
record = KeyRecord(
70+
hash=self._hash(raw_key),
71+
prefix=raw_key[:8],
72+
user=user,
73+
)
74+
with self._lock:
75+
self._keys.append(record)
76+
self._save()
77+
logger.info("Generated API key for user=%s prefix=%s", user, record.prefix)
78+
return raw_key
79+
80+
def validate(self, raw_key: str) -> bool:
81+
"""Return True if *raw_key* matches any stored hash."""
82+
key_hash = self._hash(raw_key)
83+
with self._lock:
84+
return any(k.hash == key_hash for k in self._keys)
85+
86+
def list_keys(self) -> list[dict[str, str]]:
87+
"""Return a list of key metadata (prefix, user, created_at) — no hashes."""
88+
with self._lock:
89+
return [
90+
{"prefix": k.prefix, "user": k.user, "created_at": k.created_at}
91+
for k in self._keys
92+
]
93+
94+
def revoke(self, prefix: str) -> bool:
95+
"""Revoke (delete) the key identified by *prefix*. Returns True if found."""
96+
with self._lock:
97+
before = len(self._keys)
98+
self._keys = [k for k in self._keys if k.prefix != prefix]
99+
if len(self._keys) < before:
100+
self._save()
101+
logger.info("Revoked API key with prefix=%s", prefix)
102+
return True
103+
logger.warning("Revoke failed — no key with prefix=%s", prefix)
104+
return False
105+
106+
def has_any_keys(self) -> bool:
107+
"""Return True if at least one key exists."""
108+
with self._lock:
109+
return len(self._keys) > 0
110+
111+
# ── Internal helpers ────────────────────────────────────────────
112+
113+
@staticmethod
114+
def _hash(raw_key: str) -> str:
115+
return hashlib.sha256(raw_key.encode()).hexdigest()
116+
117+
def _load(self) -> None:
118+
"""Load keys from disk (if the file exists)."""
119+
if not self._path.exists():
120+
logger.info("No keys file at %s — starting with empty store", self._path)
121+
return
122+
try:
123+
data: list[dict[str, Any]] = json.loads(self._path.read_text())
124+
self._keys = [KeyRecord(**rec) for rec in data]
125+
logger.info("Loaded %d API key(s) from %s", len(self._keys), self._path)
126+
except Exception:
127+
logger.exception("Failed to load keys from %s — starting empty", self._path)
128+
self._keys = []
129+
130+
def _save(self) -> None:
131+
"""Persist keys to disk with owner-only permissions (0o600)."""
132+
self._path.parent.mkdir(parents=True, exist_ok=True)
133+
self._path.write_text(json.dumps([asdict(k) for k in self._keys], indent=2))
134+
try:
135+
os.chmod(self._path, 0o600)
136+
except OSError:
137+
pass # Windows or permission issues — best-effort

0 commit comments

Comments
 (0)