Skip to content

Commit 6af766f

Browse files
frankbriaTest User
andauthored
feat(auth): add API key authentication for CLI and REST API (#326)
* feat(auth): add API key authentication for server endpoints Implement dual authentication supporting both JWT tokens and API keys. API keys enable programmatic access with scope-based permissions. New features: - API key generation with SHA256 hashing (industry standard for high-entropy secrets) - Scope hierarchy: admin → write → read - REST endpoints: POST/GET/DELETE /api/auth/api-keys - Dual auth dependency accepting either JWT or X-API-Key header - Prefix-based lookup optimization for fast authentication Security considerations: - API key creation requires JWT auth (prevents privilege escalation) - Keys hashed with SHA256, constant-time comparison via hmac.compare_digest - Key shown only once at creation, never retrievable again - Ownership verified on revoke/delete operations Test coverage: 68 new tests following TDD principles * feat(cli): add API key CLI commands using core service Add CLI commands for API key management that share business logic with REST endpoints via ApiKeyService: - cf auth api-key-create: Create new API key with scopes - cf auth api-key-list: List user's API keys (masked values) - cf auth api-key-revoke: Revoke an API key with confirmation - cf auth api-key-rotate: Rotate key (revoke old, create new) Core changes: - Extract ApiKeyService to codeframe/core/api_key_service.py - Refactor api_key_router.py to use ApiKeyService - Add get_db_for_cli() helper for CLI database access This ensures 1-1 mapping between CLI and REST API with shared business logic, eliminating duplication and potential drift. Test results: 85 tests pass (14 endpoints + 16 repo + 23 utils + 15 dual auth + 7 integration + 10 CLI commands) * chore: remove unused imports in test files * fix: address PR review feedback - Reverse rotate_api_key order: revoke before create (prevents both keys being active if create fails) - Use app-scoped db in api_key_router.py to avoid connection leaks - Add warning for fallback db creation in dependencies.py - Normalize expires_at to UTC in api_key_repository for correct string comparison in expiration checks - Remove unused get_test_db function from test fixture - Add pytest.mark.v2 to CLI test file - Update mock requests in dual_auth tests to include app.state.db * fix: inject app.state.db in test fixture for proper isolation The client fixture now sets server.app.state.db directly instead of relying on DATABASE_PATH env var. This ensures the test database is used by routes that access request.app.state.db, fixing 'Cannot operate on a closed database' errors when tests run in batch. * fix: address additional PR review feedback Security improvements: - Add expires_at validation: must be in the future if provided - Deduplicate scopes and require at least one scope - Bump failed auth logging from DEBUG to WARNING level Code quality: - Document scope hierarchy in require_scope docstring - Clarify KEY_PREFIX_LENGTH comment (8 prefix + 4 random chars) - Fix rotate_api_key order: create new key first, then revoke old (ensures user always has a working key if creation fails) --------- Co-authored-by: Test User <test@example.com>
1 parent a7d2223 commit 6af766f

16 files changed

Lines changed: 2838 additions & 6 deletions

codeframe/auth/api_key_router.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""API key management endpoints.
2+
3+
Provides endpoints for creating, listing, and revoking API keys.
4+
API key creation requires JWT authentication (not API key auth) to prevent
5+
privilege escalation attacks.
6+
7+
Uses core/api_key_service.py for business logic (shared with CLI).
8+
"""
9+
10+
import logging
11+
import os
12+
from datetime import datetime, timezone
13+
from typing import List, Optional
14+
15+
from fastapi import APIRouter, Depends, HTTPException, Request, status
16+
from pydantic import BaseModel, field_validator
17+
18+
from codeframe.auth.dependencies import get_current_user, require_auth
19+
from codeframe.auth.models import User
20+
from codeframe.auth.api_keys import (
21+
validate_scopes,
22+
SCOPE_READ,
23+
SCOPE_WRITE,
24+
)
25+
from codeframe.core.api_key_service import ApiKeyService
26+
from codeframe.persistence.database import Database
27+
28+
logger = logging.getLogger(__name__)
29+
30+
router = APIRouter(prefix="/api/auth/api-keys", tags=["auth", "api-keys"])
31+
32+
33+
# =============================================================================
34+
# Pydantic Models
35+
# =============================================================================
36+
37+
38+
class CreateApiKeyRequest(BaseModel):
39+
"""Request body for creating an API key."""
40+
41+
name: str
42+
scopes: List[str] = [SCOPE_READ, SCOPE_WRITE]
43+
expires_at: Optional[datetime] = None
44+
45+
@field_validator("scopes")
46+
@classmethod
47+
def validate_scopes_field(cls, v: List[str]) -> List[str]:
48+
if not validate_scopes(v):
49+
raise ValueError(f"Invalid scopes: {v}. Valid scopes are: read, write, admin")
50+
# Deduplicate while preserving order
51+
seen = set()
52+
deduped = [s for s in v if not (s in seen or seen.add(s))]
53+
if not deduped:
54+
raise ValueError("At least one scope is required")
55+
return deduped
56+
57+
@field_validator("expires_at")
58+
@classmethod
59+
def validate_expires_at(cls, v: Optional[datetime]) -> Optional[datetime]:
60+
if v is not None:
61+
now = datetime.now(timezone.utc)
62+
# Handle naive datetime by assuming UTC
63+
if v.tzinfo is None:
64+
v = v.replace(tzinfo=timezone.utc)
65+
if v <= now:
66+
raise ValueError("expires_at must be in the future")
67+
return v
68+
69+
70+
class CreateApiKeyResponse(BaseModel):
71+
"""Response body for API key creation."""
72+
73+
key: str # Full key - shown only once
74+
id: str
75+
prefix: str
76+
created_at: str
77+
78+
79+
class ApiKeyInfoResponse(BaseModel):
80+
"""API key information (without sensitive data)."""
81+
82+
id: str
83+
name: str
84+
prefix: str
85+
scopes: List[str]
86+
created_at: str
87+
last_used_at: Optional[str]
88+
expires_at: Optional[str]
89+
is_active: bool
90+
91+
92+
class RevokeApiKeyResponse(BaseModel):
93+
"""Response body for API key revocation."""
94+
95+
id: str
96+
revoked: bool
97+
98+
99+
# =============================================================================
100+
# Database Helper
101+
# =============================================================================
102+
103+
104+
def get_db(request: Request) -> Database:
105+
"""Get database instance from app state (singleton managed by lifespan handler).
106+
107+
Uses the app-scoped database to avoid per-request connection leaks.
108+
Falls back to DATABASE_PATH env var if app.state.db not available.
109+
"""
110+
# Prefer app-scoped singleton (set by lifespan handler in server.py)
111+
db = getattr(request.app.state, "db", None)
112+
if db is not None:
113+
return db
114+
115+
# Fallback for tests or standalone usage
116+
db = getattr(request.state, "db", None)
117+
if db is None:
118+
logger.warning("No db in app.state, creating fallback connection")
119+
db_path = os.getenv(
120+
"DATABASE_PATH",
121+
os.path.join(os.getcwd(), ".codeframe", "state.db")
122+
)
123+
db = Database(db_path)
124+
db.initialize()
125+
request.state.db = db
126+
return db
127+
128+
129+
def get_api_key_service(request: Request) -> ApiKeyService:
130+
"""Get API key service instance."""
131+
db = get_db(request)
132+
return ApiKeyService(db)
133+
134+
135+
# =============================================================================
136+
# Endpoints
137+
# =============================================================================
138+
139+
140+
@router.post("", status_code=status.HTTP_201_CREATED, response_model=CreateApiKeyResponse)
141+
async def create_api_key(
142+
request: Request,
143+
body: CreateApiKeyRequest,
144+
current_user: User = Depends(get_current_user), # JWT required, not API key
145+
):
146+
"""Create a new API key.
147+
148+
**Important**: This endpoint requires JWT authentication. API keys cannot
149+
be used to create new API keys (prevents privilege escalation).
150+
151+
The full API key is returned only once. Store it securely - it cannot
152+
be retrieved again.
153+
154+
Args:
155+
body: API key configuration (name, scopes, optional expiration)
156+
157+
Returns:
158+
Created API key details including the full key (shown once)
159+
"""
160+
service = get_api_key_service(request)
161+
162+
result = service.create_api_key(
163+
user_id=current_user.id,
164+
name=body.name,
165+
scopes=body.scopes,
166+
expires_at=body.expires_at,
167+
)
168+
169+
return CreateApiKeyResponse(
170+
key=result.key,
171+
id=result.id,
172+
prefix=result.prefix,
173+
created_at=result.created_at,
174+
)
175+
176+
177+
@router.get("", response_model=List[ApiKeyInfoResponse])
178+
async def list_api_keys(
179+
request: Request,
180+
auth: dict = Depends(require_auth), # Either JWT or API key
181+
):
182+
"""List all API keys for the authenticated user.
183+
184+
Does not expose the key hash or full key. Shows prefix for identification.
185+
186+
Returns:
187+
List of API key information
188+
"""
189+
service = get_api_key_service(request)
190+
191+
keys = service.list_api_keys(user_id=auth["user_id"])
192+
193+
return [
194+
ApiKeyInfoResponse(
195+
id=k.id,
196+
name=k.name,
197+
prefix=k.prefix,
198+
scopes=k.scopes,
199+
created_at=k.created_at,
200+
last_used_at=k.last_used_at,
201+
expires_at=k.expires_at,
202+
is_active=k.is_active,
203+
)
204+
for k in keys
205+
]
206+
207+
208+
@router.delete("/{key_id}", response_model=RevokeApiKeyResponse)
209+
async def revoke_api_key(
210+
request: Request,
211+
key_id: str,
212+
auth: dict = Depends(require_auth), # Either JWT or API key
213+
):
214+
"""Revoke an API key.
215+
216+
The key will be marked as inactive and can no longer be used for
217+
authentication.
218+
219+
Args:
220+
key_id: The API key ID to revoke
221+
222+
Returns:
223+
Confirmation of revocation
224+
225+
Raises:
226+
404: If key not found or not owned by user
227+
"""
228+
service = get_api_key_service(request)
229+
230+
success = service.revoke_api_key(key_id, user_id=auth["user_id"])
231+
232+
if not success:
233+
raise HTTPException(
234+
status_code=status.HTTP_404_NOT_FOUND,
235+
detail="API key not found",
236+
)
237+
238+
return RevokeApiKeyResponse(id=key_id, revoked=True)

codeframe/auth/api_keys.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""API key generation, verification, and utility functions.
2+
3+
Provides utilities for creating and validating API keys for server authentication.
4+
API keys follow the format: cf_{environment}_{32_hex_chars}
5+
6+
Security considerations:
7+
- Keys are hashed using SHA256 (industry standard for high-entropy API keys)
8+
- API keys have 128 bits of entropy from secrets.token_hex(16)
9+
- SHA256 is appropriate here (vs bcrypt for passwords) because API keys
10+
are already high-entropy random strings, not human-chosen passwords
11+
- Only the prefix is stored for efficient lookup
12+
- Full key is shown only once during creation
13+
14+
This approach matches GitHub, Stripe, and other API key implementations.
15+
"""
16+
17+
import hashlib
18+
import hmac
19+
import secrets
20+
import logging
21+
from typing import Tuple
22+
23+
logger = logging.getLogger(__name__)
24+
25+
# Scope constants for API key permissions
26+
SCOPE_READ = "read"
27+
SCOPE_WRITE = "write"
28+
SCOPE_ADMIN = "admin"
29+
30+
# All valid scopes
31+
VALID_SCOPES = frozenset({SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN})
32+
33+
# Key format constants
34+
# PREFIX_LENGTH=12 captures "cf_live_" (8) + 4 random chars for efficient DB lookup
35+
# Example: "cf_live_1abc" from "cf_live_1abc2b2f78d93788b9fa00e383832be3"
36+
KEY_PREFIX_LENGTH = 12
37+
KEY_RANDOM_BYTES = 16 # 16 bytes = 32 hex characters
38+
39+
40+
def generate_api_key(environment: str = "live") -> Tuple[str, str, str]:
41+
"""Generate a new API key with SHA256 hash.
42+
43+
Args:
44+
environment: Environment identifier ('live' or 'test')
45+
46+
Returns:
47+
Tuple of (full_key, key_hash, prefix):
48+
- full_key: The complete API key to give to the user (shown once)
49+
- key_hash: SHA256 hash of the key for storage (bcrypt-like format)
50+
- prefix: First 12 characters for efficient lookup
51+
52+
Example:
53+
>>> key, hash, prefix = generate_api_key()
54+
>>> key
55+
'cf_live_a1b2c3d4e5f6789012345678abcdef01'
56+
>>> prefix
57+
'cf_live_a1b2'
58+
"""
59+
# Generate 16 random bytes = 32 hex characters (128 bits entropy)
60+
random_part = secrets.token_hex(KEY_RANDOM_BYTES)
61+
62+
# Construct the full key: cf_{environment}_{32_hex}
63+
full_key = f"cf_{environment}_{random_part}"
64+
65+
# Hash using SHA256 - appropriate for high-entropy API keys
66+
# Format: $sha256${hex_digest} to distinguish from bcrypt hashes
67+
digest = hashlib.sha256(full_key.encode()).hexdigest()
68+
key_hash = f"$sha256${digest}"
69+
70+
# Extract prefix for database lookup optimization
71+
prefix = full_key[:KEY_PREFIX_LENGTH]
72+
73+
logger.debug(f"Generated API key with prefix {prefix}")
74+
75+
return full_key, key_hash, prefix
76+
77+
78+
def verify_api_key(key: str, key_hash: str) -> bool:
79+
"""Verify an API key against its stored hash.
80+
81+
Supports both SHA256 (preferred for API keys) and bcrypt hashes.
82+
83+
Args:
84+
key: The full API key provided by the user
85+
key_hash: The stored hash (SHA256 or bcrypt format)
86+
87+
Returns:
88+
True if the key matches the hash, False otherwise
89+
90+
Note:
91+
Uses constant-time comparison to prevent timing attacks.
92+
Returns False (not exception) for invalid inputs.
93+
"""
94+
if not key or not key_hash:
95+
return False
96+
97+
try:
98+
if key_hash.startswith("$sha256$"):
99+
# SHA256 hash verification with constant-time comparison
100+
expected_digest = key_hash[8:] # Remove "$sha256$" prefix
101+
actual_digest = hashlib.sha256(key.encode()).hexdigest()
102+
return hmac.compare_digest(expected_digest, actual_digest)
103+
104+
elif key_hash.startswith("$2"):
105+
# Bcrypt hash (legacy or if we switch back)
106+
# Import here to avoid startup issues with bcrypt compatibility
107+
from passlib.context import CryptContext
108+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
109+
return pwd_context.verify(key, key_hash)
110+
111+
else:
112+
# Unknown hash format
113+
logger.warning(f"Unknown hash format: {key_hash[:10]}...")
114+
return False
115+
116+
except Exception as e:
117+
# Handle malformed hashes gracefully
118+
logger.debug(f"API key verification failed: {e}")
119+
return False
120+
121+
122+
def extract_prefix(key: str) -> str:
123+
"""Extract the prefix from a full API key for database lookup.
124+
125+
The prefix is the first 12 characters (cf_{env}_xxxx) which provides
126+
enough entropy for efficient indexed lookup while avoiding full key
127+
comparison in the database.
128+
129+
Args:
130+
key: The full API key
131+
132+
Returns:
133+
The 12-character prefix
134+
135+
Raises:
136+
ValueError: If key is shorter than 12 characters
137+
"""
138+
if len(key) < KEY_PREFIX_LENGTH:
139+
raise ValueError(f"API key too short: expected at least {KEY_PREFIX_LENGTH} chars")
140+
141+
return key[:KEY_PREFIX_LENGTH]
142+
143+
144+
def validate_scopes(scopes: list[str]) -> bool:
145+
"""Validate that all scopes in the list are valid.
146+
147+
Args:
148+
scopes: List of scope strings to validate
149+
150+
Returns:
151+
True if all scopes are valid and list is non-empty, False otherwise
152+
"""
153+
if not scopes:
154+
return False
155+
156+
return all(scope in VALID_SCOPES for scope in scopes)

0 commit comments

Comments
 (0)