Skip to content

Commit e51a33d

Browse files
committed
feat: API key list + revoke endpoints, specific 401 errors (OPE-164)
Backend: - GET /api/v1/keys -- list user's API keys (masked, with metadata) - DELETE /api/v1/keys/{key_id} -- revoke by ID (soft delete, verifies ownership) - APIKeyManager.list_keys(user_id) -- returns masked keys with preview - APIKeyManager.revoke_key_by_id(key_id, user_id) -- ownership-safe revoke Auth: - Specific 401 errors for ci_ keys: 'not found', 'revoked', 'no linked user' instead of generic 'Invalid token or API key' - Would have saved us 2 hours of debugging today Existing endpoints (unchanged): - POST /api/v1/keys/generate -- already existed, works - GET /api/v1/keys/usage -- already existed, works 392 tests pass.
1 parent 5ae2eba commit e51a33d

3 files changed

Lines changed: 93 additions & 3 deletions

File tree

backend/middleware/auth.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,31 @@ def _authenticate(token: str) -> AuthContext:
160160
set_user_context(user_id=ctx.user_id or ctx.api_key_name)
161161
return ctx
162162

163-
# Neither worked
163+
# Provide specific error for ci_ keys so users can debug
164+
if token.startswith("ci_"):
165+
try:
166+
from services.supabase_service import get_supabase_service
167+
db = get_supabase_service().client
168+
key_hash = hashlib.sha256(token.encode()).hexdigest()
169+
result = db.table("api_keys").select(
170+
"active, user_id"
171+
).eq("key_hash", key_hash).execute()
172+
if not result.data:
173+
detail = "API key not found. Check that the key is correct."
174+
elif not result.data[0].get("active"):
175+
detail = "API key has been revoked."
176+
elif not result.data[0].get("user_id"):
177+
detail = "API key has no linked user. Contact admin."
178+
else:
179+
detail = "API key validation failed."
180+
except Exception:
181+
detail = "API key validation failed."
182+
else:
183+
detail = "Invalid token or API key"
184+
164185
raise HTTPException(
165186
status_code=status.HTTP_401_UNAUTHORIZED,
166-
detail="Invalid token or API key",
187+
detail=detail,
167188
headers={"WWW-Authenticate": "Bearer"}
168189
)
169190

backend/routes/api_keys.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,49 @@ async def generate_api_key(
9393
raise HTTPException(status_code=500, detail="Failed to generate API key")
9494

9595

96+
@router.get("/keys")
97+
async def list_api_keys(
98+
auth: AuthContext = Depends(require_auth)
99+
):
100+
"""List all API keys for the authenticated user."""
101+
if not auth.user_id:
102+
raise HTTPException(status_code=401, detail="User ID required")
103+
104+
try:
105+
keys = api_key_manager.list_keys(auth.user_id)
106+
return {"keys": keys}
107+
except Exception as e:
108+
logger.error("Failed to list API keys", user_id=auth.user_id, error=str(e))
109+
capture_exception(e, operation="list_api_keys", user_id=auth.user_id)
110+
raise HTTPException(status_code=500, detail="Failed to list API keys")
111+
112+
113+
@router.delete("/keys/{key_id}")
114+
async def revoke_api_key(
115+
key_id: str,
116+
auth: AuthContext = Depends(require_auth)
117+
):
118+
"""Revoke an API key by ID. Soft-deletes (sets active=false)."""
119+
if not auth.user_id:
120+
raise HTTPException(status_code=401, detail="User ID required")
121+
122+
try:
123+
success = api_key_manager.revoke_key_by_id(key_id, auth.user_id)
124+
if not success:
125+
raise HTTPException(
126+
status_code=404,
127+
detail="API key not found or not owned by you"
128+
)
129+
logger.info("API key revoked", user_id=auth.user_id, key_id=key_id)
130+
return {"message": "API key revoked", "key_id": key_id}
131+
except HTTPException:
132+
raise
133+
except Exception as e:
134+
logger.error("Failed to revoke API key", user_id=auth.user_id, error=str(e))
135+
capture_exception(e, operation="revoke_api_key", user_id=auth.user_id)
136+
raise HTTPException(status_code=500, detail="Failed to revoke API key")
137+
138+
96139
@router.get("/keys/usage")
97140
async def get_api_usage(
98141
auth: AuthContext = Depends(require_auth)

backend/services/rate_limiter.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,33 @@ def verify_key(self, api_key: str) -> Optional[Dict]:
204204
return result.data[0] if result.data else None
205205

206206
def revoke_key(self, api_key: str) -> bool:
207-
"""Revoke an API key"""
207+
"""Revoke an API key by raw key value"""
208208
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
209209
self.db.table("api_keys").update({"active": False}).eq("key_hash", key_hash).execute()
210210
return True
211+
212+
def revoke_key_by_id(self, key_id: str, user_id: str) -> bool:
213+
"""Revoke an API key by UUID. Verifies ownership via user_id."""
214+
result = self.db.table("api_keys").update(
215+
{"active": False}
216+
).eq("id", key_id).eq("user_id", user_id).execute()
217+
return len(result.data) > 0
218+
219+
def list_keys(self, user_id: str) -> list:
220+
"""List all API keys for a user. Returns masked keys (no raw values)."""
221+
result = self.db.table("api_keys").select(
222+
"id, name, tier, active, created_at, last_used_at, key_hash"
223+
).eq("user_id", user_id).order("created_at", desc=True).execute()
224+
keys = []
225+
for row in result.data:
226+
h = row.get("key_hash", "")
227+
keys.append({
228+
"id": row["id"],
229+
"name": row["name"],
230+
"tier": row["tier"],
231+
"active": row["active"],
232+
"created_at": row["created_at"],
233+
"last_used_at": row.get("last_used_at"),
234+
"key_preview": f"ci_...{h[-8:]}" if h else "ci_...",
235+
})
236+
return keys

0 commit comments

Comments
 (0)