fix: restore app-local API key functions in auth.py

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-04 19:11:00 +00:00
parent 75d2c1fa9d
commit d128c6e6bb

View file

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