Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,12 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d

## API Authentication

The server supports per-user API keys with rate limiting.
**API endpoints require authentication.** Health data should never be publicly accessible.

### Authentication Methods

1. **Per-User API Keys** (recommended) - Each user gets their own API key via OAuth
1. **Per-User API Keys** (recommended) - Create from the admin dashboard or via OAuth flow
2. **Master API Key** - Set `API_KEY` env var for full access (bypasses rate limits)
3. **Open Access** - If no `API_KEY` is set and no key provided, endpoints are open

### Using API Keys

Expand Down
201 changes: 201 additions & 0 deletions src/polar_flow_server/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
login_admin,
logout_admin,
)
from polar_flow_server.core.api_keys import (
create_api_key_for_user,
regenerate_api_key,
revoke_api_key,
)
from polar_flow_server.core.config import settings
from polar_flow_server.core.security import token_encryption
from polar_flow_server.models.activity import Activity
Expand Down Expand Up @@ -979,6 +984,198 @@ async def reset_oauth_credentials(
)


# ============================================================================
# API Key Management (Admin-authenticated, no API key required)
# ============================================================================
#
# Authorization Model:
# These routes use session-based admin authentication, not per-key authorization.
# The admin who logs into the dashboard has full access to manage all API keys.
# This is intentional:
# - Self-hosted: Single admin manages all keys for their deployment
# - SaaS: System admin manages keys across all users
#
# Per-key ownership checks are not needed since admin access itself is the
# authorization boundary. If you need user-level key management, use the
# per-user API endpoints in api/keys.py which require API key authentication.


@post("/api-keys/{key_id:int}/regenerate", sync_to_thread=False, status_code=HTTP_200_OK)
async def admin_regenerate_api_key(
request: Request[Any, Any, Any],
session: AsyncSession,
key_id: int,
) -> Template:
"""Regenerate an API key from the admin panel.

Uses session authentication (admin login), not API key auth.
This allows admins to regenerate keys when the original is lost.
"""
if not is_authenticated(request):
return Template(
template_name="admin/partials/sync_error.html",
context={"error": "Authentication required. Please log in."},
)

try:
# Find the API key by ID
stmt = select(APIKey).where(APIKey.id == key_id)
result = await session.execute(stmt)
api_key = result.scalar_one_or_none()

if not api_key:
return Template(
template_name="admin/partials/sync_error.html",
context={"error": f"API key with ID {key_id} not found."},
)

if not api_key.is_active:
return Template(
template_name="admin/partials/sync_error.html",
context={"error": "Cannot regenerate a revoked key. The key must be active."},
)

# Regenerate the key
new_raw_key = await regenerate_api_key(api_key, session)
await session.commit()

return Template(
template_name="admin/partials/api_key_regenerated.html",
context={
"api_key": new_raw_key,
"key_prefix": api_key.key_prefix,
"key_name": api_key.name,
},
)

except Exception as e:
await session.rollback()
return Template(
template_name="admin/partials/sync_error.html",
context={"error": f"Failed to regenerate key: {str(e)}"},
)


@post("/api-keys/{key_id:int}/revoke", sync_to_thread=False, status_code=HTTP_200_OK)
async def admin_revoke_api_key(
request: Request[Any, Any, Any],
session: AsyncSession,
key_id: int,
) -> Template:
"""Revoke an API key from the admin panel.

Uses session authentication (admin login), not API key auth.
"""
if not is_authenticated(request):
return Template(
template_name="admin/partials/sync_error.html",
context={"error": "Authentication required. Please log in."},
)

try:
# Find the API key by ID
stmt = select(APIKey).where(APIKey.id == key_id)
result = await session.execute(stmt)
api_key = result.scalar_one_or_none()

if not api_key:
return Template(
template_name="admin/partials/sync_error.html",
context={"error": f"API key with ID {key_id} not found."},
)

if not api_key.is_active:
return Template(
template_name="admin/partials/sync_error.html",
context={"error": "Key is already revoked."},
)

# Revoke the key
await revoke_api_key(api_key, session)
await session.commit()

return Template(
template_name="admin/partials/api_key_revoked.html",
context={
"key_prefix": api_key.key_prefix,
"key_name": api_key.name,
},
)

except Exception as e:
await session.rollback()
return Template(
template_name="admin/partials/sync_error.html",
context={"error": f"Failed to revoke key: {str(e)}"},
)


@post("/api-keys/create", sync_to_thread=False, status_code=HTTP_200_OK)
async def admin_create_api_key(
request: Request[Any, Any, Any],
session: AsyncSession,
) -> Template:
"""Create a new API key for the connected user from the admin panel.

Uses session authentication (admin login), not API key auth.
"""
if not is_authenticated(request):
return Template(
template_name="admin/partials/sync_error.html",
context={"error": "Authentication required. Please log in."},
)

try:
# Get the connected user
stmt = select(User).where(User.is_active == True).limit(1) # noqa: E712
result = await session.execute(stmt)
user = result.scalar_one_or_none()

if not user:
return Template(
template_name="admin/partials/sync_error.html",
context={"error": "No connected user found. Please connect via OAuth first."},
)

# Check if user already has an active key
key_stmt = select(APIKey).where(
APIKey.user_id == user.polar_user_id,
APIKey.is_active == True, # noqa: E712
)
key_result = await session.execute(key_stmt)
existing_key = key_result.scalar_one_or_none()

if existing_key:
return Template(
template_name="admin/partials/sync_error.html",
context={"error": "User already has an active API key. Use regenerate instead."},
)

# Create the API key
api_key, raw_key = await create_api_key_for_user(
user_id=user.polar_user_id,
name=f"Admin-created key for {user.polar_user_id}",
session=session,
)
await session.commit()

return Template(
template_name="admin/partials/api_key_regenerated.html",
context={
"api_key": raw_key,
"key_prefix": api_key.key_prefix,
"key_name": api_key.name,
},
)

except Exception as e:
await session.rollback()
return Template(
template_name="admin/partials/sync_error.html",
context={"error": f"Failed to create key: {str(e)}"},
)


# ============================================================================
# Chart Data API Routes (JSON endpoints for Chart.js)
# ============================================================================
Expand Down Expand Up @@ -1338,6 +1535,10 @@ async def export_cardio_load_csv(
oauth_authorize,
admin_settings,
reset_oauth_credentials,
# API Key management
admin_regenerate_api_key,
admin_revoke_api_key,
admin_create_api_key,
# Chart API endpoints
chart_sleep_data,
chart_activity_data,
Expand Down
1 change: 1 addition & 0 deletions src/polar_flow_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def create_app() -> Litestar:
"/admin/settings", # Settings pages (reset-oauth, etc.)
"/admin/sync", # Sync trigger from dashboard
"/admin/logout", # Logout action
"/admin/api-keys/", # API key management (uses session auth)
"/oauth/", # OAuth endpoints for SaaS (callback, exchange, start)
"/users/", # API routes use API key auth, not sessions
"/health",
Expand Down
20 changes: 5 additions & 15 deletions src/polar_flow_server/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,21 +164,15 @@ async def api_key_guard(
) -> None:
"""Litestar guard that validates API key from request header.

If no API_KEY is configured, authentication is skipped (open access).
Otherwise validates against config or database.
Validates against config-based master key or database keys.

Args:
connection: The ASGI connection
_: The route handler (unused)

Raises:
NotAuthorizedException: If API key is required but missing/invalid
NotAuthorizedException: If API key is missing or invalid
"""
# If no API_KEY configured, skip authentication (simple self-hosted mode)
if not settings.api_key:
logger.debug("No API_KEY configured - authentication disabled")
return

# Extract API key from headers
api_key = _extract_api_key(connection)

Expand Down Expand Up @@ -234,14 +228,10 @@ async def per_user_api_key_guard(
raw_key = _extract_api_key(connection)

if not raw_key:
# Check if simple API key mode is enabled (config-based)
if settings.api_key:
raise NotAuthorizedException("Missing API key. Use X-API-Key header.")
# No API key required in open access mode
logger.debug("No API_KEY configured - authentication disabled")
return
# API key is ALWAYS required - health data should never be public
raise NotAuthorizedException("Missing API key. Use X-API-Key header.")

# First check if it matches the config-based master key
# First check if it matches the config-based master key (if configured)
if settings.api_key and secrets.compare_digest(raw_key, settings.api_key):
logger.debug("Config-based master API key validated")
return
Expand Down
Loading