From d128c6e6bbac8e5a82f05dcfcd0907dd7eb15b24 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 4 Jul 2026 19:11:00 +0000 Subject: [PATCH] fix: restore app-local API key functions in auth.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.py and public.py import get_api_key_auth/get_all_api_keys/ create_api_key/revoke_api_key/delete_api_key — the public-API key system is app-local (api_keys table), not part of the central auth that was replaced, so it should have been kept in the port. Co-Authored-By: Claude Sonnet 4.6 --- backend/auth.py | 187 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 1 deletion(-) diff --git a/backend/auth.py b/backend/auth.py index eda098a..c4a9ef2 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1,10 +1,20 @@ """ Auth middleware — verifies the stack's hnf_session cookie using the shared CENTRAL_AUTH_SECRET. Replaces the old per-app JWT/users system. +The app-local API-key system (public API access) is kept as-is. """ import os -from fastapi import Depends, HTTPException, Request, status +import base64 +import hashlib +import secrets +from typing import Optional + +from fastapi import Depends, Header, HTTPException, Request, status from jose import JWTError, jwt +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET", "") JWT_ALGORITHM = "HS256" @@ -66,3 +76,178 @@ async def get_admin_user(user: dict = Depends(get_current_user)) -> dict: if not user.get("is_admin", False): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return user + + +# ============================================ +# API KEY AUTHENTICATION (app-local, for the public API) +# ============================================ + +def generate_api_key() -> tuple[str, str, str]: + """ + Generate a new API key. + Returns: (full_key, key_hash, key_prefix) + - full_key: The complete key to give to user (shown only once) + - key_hash: SHA256 hash to store in database + - key_prefix: First 12 chars for display purposes + """ + random_bytes = secrets.token_bytes(32) + key_body = base64.urlsafe_b64encode(random_bytes).decode('utf-8').rstrip('=') + full_key = f"fk_{key_body}" + key_hash = hashlib.sha256(full_key.encode()).hexdigest() + key_prefix = full_key[:12] + "..." + return full_key, key_hash, key_prefix + + +async def verify_api_key(api_key: str, db: AsyncSession) -> Optional[dict]: + """ + Verify an API key and return key info if valid. + Also updates last_used_at timestamp. + """ + if not api_key or not api_key.startswith("fk_"): + return None + + key_hash = hashlib.sha256(api_key.encode()).hexdigest() + + result = await db.execute( + text(""" + SELECT id, name, key_prefix, is_active, created_at + FROM api_keys + WHERE key_hash = :key_hash + """), + {"key_hash": key_hash} + ) + key_record = result.fetchone() + + if not key_record: + return None + + if not key_record.is_active: + return None + + await db.execute( + text("UPDATE api_keys SET last_used_at = NOW() WHERE id = :key_id"), + {"key_id": key_record.id} + ) + await db.commit() + + return { + "id": key_record.id, + "name": key_record.name, + "key_prefix": key_record.key_prefix, + "is_active": key_record.is_active + } + + +async def get_api_key_auth( + x_api_key: Optional[str] = Header(None, alias="X-API-Key"), + db: AsyncSession = Depends(get_db) +) -> dict: + """ + FastAPI dependency for API key authentication. + Raises 401 if key is missing or invalid. + """ + if not x_api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing API key. Provide X-API-Key header.", + ) + + key_info = await verify_api_key(x_api_key, db) + if not key_info: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or revoked API key", + ) + + return key_info + + +async def get_all_api_keys(db: AsyncSession) -> list: + """Get all API keys (without the actual key values)""" + result = await db.execute( + text(""" + SELECT id, key_prefix, name, is_active, created_at, last_used_at, created_by + FROM api_keys + ORDER BY created_at DESC + """) + ) + keys = result.fetchall() + return [ + { + "id": k.id, + "key_prefix": k.key_prefix, + "name": k.name, + "is_active": k.is_active, + "created_at": k.created_at.isoformat() if k.created_at else None, + "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None, + "created_by": k.created_by + } + for k in keys + ] + + +async def create_api_key(db: AsyncSession, name: str, created_by: str) -> dict: + """ + Create a new API key. + Returns the full key (only time it's shown) plus metadata. + """ + full_key, key_hash, key_prefix = generate_api_key() + + result = await db.execute( + text(""" + INSERT INTO api_keys (key_hash, key_prefix, name, is_active, created_at, created_by) + VALUES (:key_hash, :key_prefix, :name, true, NOW(), :created_by) + RETURNING id, key_prefix, name, is_active, created_at + """), + { + "key_hash": key_hash, + "key_prefix": key_prefix, + "name": name, + "created_by": created_by + } + ) + await db.commit() + key_record = result.fetchone() + + return { + "id": key_record.id, + "key": full_key, # Only time the full key is returned! + "key_prefix": key_record.key_prefix, + "name": key_record.name, + "is_active": key_record.is_active, + "created_at": key_record.created_at.isoformat() if key_record.created_at else None + } + + +async def revoke_api_key(db: AsyncSession, key_id: int) -> bool: + """Revoke (deactivate) an API key""" + result = await db.execute( + text("SELECT id FROM api_keys WHERE id = :key_id"), + {"key_id": key_id} + ) + if not result.fetchone(): + raise HTTPException(status_code=404, detail="API key not found") + + await db.execute( + text("UPDATE api_keys SET is_active = false WHERE id = :key_id"), + {"key_id": key_id} + ) + await db.commit() + return True + + +async def delete_api_key(db: AsyncSession, key_id: int) -> bool: + """Permanently delete an API key""" + result = await db.execute( + text("SELECT id FROM api_keys WHERE id = :key_id"), + {"key_id": key_id} + ) + if not result.fetchone(): + raise HTTPException(status_code=404, detail="API key not found") + + await db.execute( + text("DELETE FROM api_keys WHERE id = :key_id"), + {"key_id": key_id} + ) + await db.commit() + return True