Skip to content

Commit 67115a9

Browse files
authored
Merge pull request #288 from DevanshuNEU/feat/api-key-management
feat: API key list, revoke, and specific 401 errors (OPE-164)
2 parents 5ae2eba + f43116e commit 67115a9

5 files changed

Lines changed: 165 additions & 24 deletions

File tree

backend/middleware/auth.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,21 @@ def _validate_api_key(token: str) -> Optional[AuthContext]:
133133
except Exception:
134134
pass # Non-critical; don't fail auth over timestamp update
135135

136+
# Reject keys with no linked user at the auth layer
137+
# so routes get a clear 401 instead of silently passing with user_id=None
138+
if not key_data.get("user_id"):
139+
raise HTTPException(
140+
status_code=401,
141+
detail="API key has no linked user. Contact admin."
142+
)
143+
136144
return AuthContext(
137145
api_key_name=key_data.get("name"),
138146
user_id=key_data.get("user_id"),
139147
tier=key_data.get("tier", "free")
140148
)
149+
except HTTPException:
150+
raise
141151
except Exception:
142152
return None
143153

@@ -160,10 +170,29 @@ def _authenticate(token: str) -> AuthContext:
160170
set_user_context(user_id=ctx.user_id or ctx.api_key_name)
161171
return ctx
162172

163-
# Neither worked
173+
# Provide specific error for ci_ keys so users can debug
174+
if token.startswith("ci_"):
175+
try:
176+
from services.supabase_service import get_supabase_service
177+
db = get_supabase_service().client
178+
key_hash = hashlib.sha256(token.encode()).hexdigest()
179+
result = db.table("api_keys").select(
180+
"active, user_id"
181+
).eq("key_hash", key_hash).execute()
182+
if not result.data:
183+
detail = "API key not found. Check that the key is correct."
184+
elif not result.data[0].get("active"):
185+
detail = "API key has been revoked."
186+
else:
187+
detail = "API key validation failed."
188+
except Exception:
189+
detail = "API key validation failed."
190+
else:
191+
detail = "Invalid token or API key"
192+
164193
raise HTTPException(
165194
status_code=status.HTTP_401_UNAUTHORIZED,
166-
detail="Invalid token or API key",
195+
detail=detail,
167196
headers={"WWW-Authenticate": "Bearer"}
168197
)
169198

backend/routes/api_keys.py

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
"""API key management and metrics routes."""
2+
from typing import Any, Dict
3+
from uuid import UUID
4+
25
from fastapi import APIRouter, HTTPException, Depends
36
from pydantic import BaseModel
47

@@ -15,9 +18,11 @@
1518
router = APIRouter(prefix="", tags=["API Keys"])
1619

1720

21+
MAX_KEYS_PER_USER = 5
22+
23+
1824
class CreateAPIKeyRequest(BaseModel):
1925
name: str
20-
tier: str = "free"
2126

2227

2328
@router.get("/metrics")
@@ -46,37 +51,54 @@ async def generate_api_key(
4651
auth: AuthContext = Depends(require_auth)
4752
):
4853
"""Generate a new API key."""
49-
set_operation_context("generate_api_key", user_id=auth.user_id, tier=request.tier)
50-
add_breadcrumb("API key generation requested", category="api_keys", tier=request.tier)
54+
if not auth.user_id:
55+
raise HTTPException(status_code=401, detail="User ID required")
56+
57+
set_operation_context("generate_api_key", user_id=auth.user_id, tier=auth.tier)
58+
add_breadcrumb("API key generation requested", category="api_keys", tier=auth.tier)
5159

5260
logger.info(
5361
"API key generation requested",
5462
user_id=auth.user_id,
5563
key_name=request.name,
56-
tier=request.tier
64+
tier=auth.tier
5765
)
5866

67+
# Tier is locked to the user's auth tier (no self-escalation)
68+
tier = auth.tier
69+
70+
# Enforce key limit per user
71+
key_count = api_key_manager.count_keys(auth.user_id)
72+
if key_count >= MAX_KEYS_PER_USER:
73+
raise HTTPException(
74+
status_code=403,
75+
detail=f"Maximum {MAX_KEYS_PER_USER} active API keys allowed. Revoke an existing key first."
76+
)
77+
5978
try:
60-
with track_time("generate_api_key", tier=request.tier):
61-
new_key = api_key_manager.generate_key(
79+
with track_time("generate_api_key", tier=tier):
80+
result = api_key_manager.generate_key(
6281
name=request.name,
63-
tier=request.tier,
82+
tier=tier,
6483
user_id=auth.user_id
6584
)
6685

6786
logger.info(
6887
"API key generated successfully",
6988
user_id=auth.user_id,
7089
key_name=request.name,
71-
tier=request.tier
90+
tier=tier
7291
)
7392

7493
return {
75-
"api_key": new_key,
76-
"tier": request.tier,
94+
"api_key": result["key"],
95+
"id": result["id"],
96+
"tier": tier,
7797
"name": request.name,
7898
"message": "Save this key securely - it won't be shown again"
7999
}
100+
except HTTPException:
101+
raise
80102
except Exception as e:
81103
logger.error(
82104
"API key generation failed",
@@ -93,6 +115,49 @@ async def generate_api_key(
93115
raise HTTPException(status_code=500, detail="Failed to generate API key")
94116

95117

118+
@router.get("/keys")
119+
async def list_api_keys(
120+
auth: AuthContext = Depends(require_auth)
121+
) -> Dict[str, Any]:
122+
"""List all API keys for the authenticated user."""
123+
if not auth.user_id:
124+
raise HTTPException(status_code=401, detail="User ID required")
125+
126+
try:
127+
keys = api_key_manager.list_keys(auth.user_id)
128+
return {"keys": keys}
129+
except Exception as e:
130+
logger.error("Failed to list API keys", user_id=auth.user_id, error=str(e))
131+
capture_exception(e, operation="list_api_keys", user_id=auth.user_id)
132+
raise HTTPException(status_code=500, detail="Failed to list API keys")
133+
134+
135+
@router.delete("/keys/{key_id}")
136+
async def revoke_api_key(
137+
key_id: UUID,
138+
auth: AuthContext = Depends(require_auth)
139+
) -> Dict[str, Any]:
140+
"""Revoke an API key by ID. Soft-deletes (sets active=false)."""
141+
if not auth.user_id:
142+
raise HTTPException(status_code=401, detail="User ID required")
143+
144+
try:
145+
success = api_key_manager.revoke_key_by_id(str(key_id), auth.user_id)
146+
if not success:
147+
raise HTTPException(
148+
status_code=404,
149+
detail="API key not found or not owned by you"
150+
)
151+
logger.info("API key revoked", user_id=auth.user_id, key_id=key_id)
152+
return {"message": "API key revoked", "key_id": key_id}
153+
except HTTPException:
154+
raise
155+
except Exception as e:
156+
logger.error("Failed to revoke API key", user_id=auth.user_id, error=str(e))
157+
capture_exception(e, operation="revoke_api_key", user_id=auth.user_id)
158+
raise HTTPException(status_code=500, detail="Failed to revoke API key")
159+
160+
96161
@router.get("/keys/usage")
97162
async def get_api_usage(
98163
auth: AuthContext = Depends(require_auth)

backend/services/rate_limiter.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -177,21 +177,35 @@ class APIKeyManager:
177177
def __init__(self, supabase_client):
178178
self.db = supabase_client
179179

180-
def generate_key(self, name: str, tier: str = 'free', user_id: Optional[str] = None) -> str:
181-
"""Generate a new API key"""
182-
# Generate secure random key
180+
def generate_key(self, name: str, tier: str = 'free', user_id: Optional[str] = None) -> Dict:
181+
"""Generate a new API key. Returns dict with raw key + metadata."""
183182
key = f"ci_{secrets.token_urlsafe(32)}"
184-
185-
# Store in database
186-
self.db.table("api_keys").insert({
183+
# Persist last 8 chars of raw key for display (ci_...xYz12345)
184+
suffix = key[-8:]
185+
186+
result = self.db.table("api_keys").insert({
187187
"key_hash": hashlib.sha256(key.encode()).hexdigest(),
188+
"key_suffix": suffix,
188189
"name": name,
189190
"tier": tier,
190191
"user_id": user_id,
191192
"created_at": datetime.utcnow().isoformat()
192193
}).execute()
193-
194-
return key
194+
195+
row = result.data[0] if result.data else {}
196+
return {
197+
"key": key,
198+
"id": row.get("id"),
199+
"name": name,
200+
"tier": tier,
201+
}
202+
203+
def count_keys(self, user_id: str) -> int:
204+
"""Count active keys for a user."""
205+
result = self.db.table("api_keys").select(
206+
"id", count="exact"
207+
).eq("user_id", user_id).eq("active", True).execute()
208+
return result.count or 0
195209

196210
def verify_key(self, api_key: str) -> Optional[Dict]:
197211
"""Verify API key and return metadata"""
@@ -204,7 +218,33 @@ def verify_key(self, api_key: str) -> Optional[Dict]:
204218
return result.data[0] if result.data else None
205219

206220
def revoke_key(self, api_key: str) -> bool:
207-
"""Revoke an API key"""
221+
"""Revoke an API key by raw key value"""
208222
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
209223
self.db.table("api_keys").update({"active": False}).eq("key_hash", key_hash).execute()
210224
return True
225+
226+
def revoke_key_by_id(self, key_id: str, user_id: str) -> bool:
227+
"""Revoke an API key by UUID. Verifies ownership via user_id."""
228+
result = self.db.table("api_keys").update(
229+
{"active": False}
230+
).eq("id", key_id).eq("user_id", user_id).execute()
231+
return len(result.data) > 0
232+
233+
def list_keys(self, user_id: str) -> list:
234+
"""List all API keys for a user. Returns masked keys (no raw values)."""
235+
result = self.db.table("api_keys").select(
236+
"id, name, tier, active, created_at, last_used_at, key_suffix"
237+
).eq("user_id", user_id).order("created_at", desc=True).execute()
238+
keys = []
239+
for row in result.data:
240+
suffix = row.get("key_suffix", "")
241+
keys.append({
242+
"id": row["id"],
243+
"name": row["name"],
244+
"tier": row["tier"],
245+
"active": row["active"],
246+
"created_at": row["created_at"],
247+
"last_used_at": row.get("last_used_at"),
248+
"key_preview": f"ci_...{suffix}" if suffix else "ci_...",
249+
})
250+
return keys

backend/tests/test_observability_routes.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,13 @@ def mock_dependencies(self):
355355
patch("routes.api_keys.rate_limiter") as mock_limiter, \
356356
patch("routes.api_keys.metrics") as mock_metrics:
357357

358-
mock_manager.generate_key = MagicMock(return_value="sk-test-key-123")
358+
mock_manager.generate_key = MagicMock(return_value={
359+
"key": "ci_test-key-123",
360+
"id": "test-uuid",
361+
"name": "test",
362+
"tier": "free",
363+
})
364+
mock_manager.count_keys = MagicMock(return_value=0)
359365
mock_limiter.get_usage = MagicMock(return_value={"minute": 5, "hour": 50})
360366
mock_metrics.get_metrics = MagicMock(return_value={"searches": 100})
361367

@@ -371,7 +377,7 @@ async def test_generate_key_logs_request(self, mock_observability, mock_dependen
371377
from routes.api_keys import generate_api_key, CreateAPIKeyRequest
372378
from middleware.auth import AuthContext
373379

374-
request = CreateAPIKeyRequest(name="Production Key", tier="pro")
380+
request = CreateAPIKeyRequest(name="Production Key")
375381
auth = AuthContext(user_id="user-123", email="test@test.com", tier="pro")
376382

377383
await generate_api_key(request, auth)
@@ -387,7 +393,7 @@ async def test_generate_key_sets_context_with_tier(self, mock_observability, moc
387393
from routes.api_keys import generate_api_key, CreateAPIKeyRequest
388394
from middleware.auth import AuthContext
389395

390-
request = CreateAPIKeyRequest(name="Key", tier="enterprise")
396+
request = CreateAPIKeyRequest(name="Key")
391397
auth = AuthContext(user_id="user", email="test@test.com", tier="enterprise")
392398

393399
await generate_api_key(request, auth)

supabase/migrations/004_api_keys.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS api_keys (
1313
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
1414
name text NOT NULL,
1515
key_hash text NOT NULL UNIQUE,
16+
key_suffix text, -- last 8 chars of raw key for masked display (ci_...xYz12345)
1617
tier text DEFAULT 'free' CHECK (tier IN ('free', 'pro', 'enterprise')),
1718
active boolean DEFAULT true,
1819
created_at timestamptz DEFAULT now(),

0 commit comments

Comments
 (0)