Skip to content

Commit 2d41a5c

Browse files
authored
feat: Add API key regenerate/revoke from admin dashboard (#15)
* feat: Add API key regenerate/revoke from admin dashboard Adds admin-level key management that uses session authentication, solving the chicken-and-egg problem where you needed an API key to regenerate your API key. Changes: - Add POST /admin/api-keys/{key_id}/regenerate endpoint - Add POST /admin/api-keys/{key_id}/revoke endpoint - Add Regenerate/Revoke action buttons to API Keys table - Add confirmation modals with warnings about key invalidation - Show new key once after regeneration with copy button - Add partial templates for success/revoked states Stu Mason + AI <me@stumason.dev> * feat: Add Create API Key button and fix CSRF for key management - Add "Create API Key" button for when no keys exist - Add "Create New Key" button in header when all keys are revoked - Add POST /admin/api-keys/create endpoint for creating keys - Exclude /admin/api-keys/ from CSRF (uses session auth) - Add CSRF token to fetch requests for key management Stu Mason + AI <me@stumason.dev> * security: Require API key authentication by default BREAKING CHANGE: API endpoints now ALWAYS require authentication. Previously, if no API_KEY env var was set, all data endpoints were publicly accessible. This was a dangerous default for health data. Now authentication is required regardless of configuration: - Use X-API-Key header with a per-user key (from OAuth flow) - Or use X-API-Key header with the master API_KEY (if configured) This ensures health data is never accidentally exposed publicly. Stu Mason + AI <me@stumason.dev> * fix: Formatting, copy confirmation UX, and update docs - Fix ruff formatting in routes.py - Add confirmation dialog before closing modal if key not copied - Update README to reflect that API auth is always required - Remove references to old "open access" mode Stu Mason + AI <me@stumason.dev> * fix: Remove stale open access code from api_key_guard The api_key_guard function (currently unused) still had the old insecure behavior that skipped auth when no API_KEY was configured. Removed this to prevent future accidents and ensure consistency. Stu Mason + AI <me@stumason.dev> * fix: Address PR review feedback for API key management - Reset keyWasCopied flag in confirmRegenerate() and confirmCreateKey() to prevent false copy warnings when generating multiple keys - Update revoke message to reference dashboard instead of OAuth - Add auth model comment explaining admin vs per-key authorization Stu Mason + AI <me@stumason.dev>
1 parent bf8c39e commit 2d41a5c

File tree

7 files changed

+709
-22
lines changed

7 files changed

+709
-22
lines changed

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,12 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d
120120

121121
## API Authentication
122122

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

125125
### Authentication Methods
126126

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

131130
### Using API Keys
132131

src/polar_flow_server/admin/routes.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
login_admin,
2525
logout_admin,
2626
)
27+
from polar_flow_server.core.api_keys import (
28+
create_api_key_for_user,
29+
regenerate_api_key,
30+
revoke_api_key,
31+
)
2732
from polar_flow_server.core.config import settings
2833
from polar_flow_server.core.security import token_encryption
2934
from polar_flow_server.models.activity import Activity
@@ -979,6 +984,198 @@ async def reset_oauth_credentials(
979984
)
980985

981986

987+
# ============================================================================
988+
# API Key Management (Admin-authenticated, no API key required)
989+
# ============================================================================
990+
#
991+
# Authorization Model:
992+
# These routes use session-based admin authentication, not per-key authorization.
993+
# The admin who logs into the dashboard has full access to manage all API keys.
994+
# This is intentional:
995+
# - Self-hosted: Single admin manages all keys for their deployment
996+
# - SaaS: System admin manages keys across all users
997+
#
998+
# Per-key ownership checks are not needed since admin access itself is the
999+
# authorization boundary. If you need user-level key management, use the
1000+
# per-user API endpoints in api/keys.py which require API key authentication.
1001+
1002+
1003+
@post("/api-keys/{key_id:int}/regenerate", sync_to_thread=False, status_code=HTTP_200_OK)
1004+
async def admin_regenerate_api_key(
1005+
request: Request[Any, Any, Any],
1006+
session: AsyncSession,
1007+
key_id: int,
1008+
) -> Template:
1009+
"""Regenerate an API key from the admin panel.
1010+
1011+
Uses session authentication (admin login), not API key auth.
1012+
This allows admins to regenerate keys when the original is lost.
1013+
"""
1014+
if not is_authenticated(request):
1015+
return Template(
1016+
template_name="admin/partials/sync_error.html",
1017+
context={"error": "Authentication required. Please log in."},
1018+
)
1019+
1020+
try:
1021+
# Find the API key by ID
1022+
stmt = select(APIKey).where(APIKey.id == key_id)
1023+
result = await session.execute(stmt)
1024+
api_key = result.scalar_one_or_none()
1025+
1026+
if not api_key:
1027+
return Template(
1028+
template_name="admin/partials/sync_error.html",
1029+
context={"error": f"API key with ID {key_id} not found."},
1030+
)
1031+
1032+
if not api_key.is_active:
1033+
return Template(
1034+
template_name="admin/partials/sync_error.html",
1035+
context={"error": "Cannot regenerate a revoked key. The key must be active."},
1036+
)
1037+
1038+
# Regenerate the key
1039+
new_raw_key = await regenerate_api_key(api_key, session)
1040+
await session.commit()
1041+
1042+
return Template(
1043+
template_name="admin/partials/api_key_regenerated.html",
1044+
context={
1045+
"api_key": new_raw_key,
1046+
"key_prefix": api_key.key_prefix,
1047+
"key_name": api_key.name,
1048+
},
1049+
)
1050+
1051+
except Exception as e:
1052+
await session.rollback()
1053+
return Template(
1054+
template_name="admin/partials/sync_error.html",
1055+
context={"error": f"Failed to regenerate key: {str(e)}"},
1056+
)
1057+
1058+
1059+
@post("/api-keys/{key_id:int}/revoke", sync_to_thread=False, status_code=HTTP_200_OK)
1060+
async def admin_revoke_api_key(
1061+
request: Request[Any, Any, Any],
1062+
session: AsyncSession,
1063+
key_id: int,
1064+
) -> Template:
1065+
"""Revoke an API key from the admin panel.
1066+
1067+
Uses session authentication (admin login), not API key auth.
1068+
"""
1069+
if not is_authenticated(request):
1070+
return Template(
1071+
template_name="admin/partials/sync_error.html",
1072+
context={"error": "Authentication required. Please log in."},
1073+
)
1074+
1075+
try:
1076+
# Find the API key by ID
1077+
stmt = select(APIKey).where(APIKey.id == key_id)
1078+
result = await session.execute(stmt)
1079+
api_key = result.scalar_one_or_none()
1080+
1081+
if not api_key:
1082+
return Template(
1083+
template_name="admin/partials/sync_error.html",
1084+
context={"error": f"API key with ID {key_id} not found."},
1085+
)
1086+
1087+
if not api_key.is_active:
1088+
return Template(
1089+
template_name="admin/partials/sync_error.html",
1090+
context={"error": "Key is already revoked."},
1091+
)
1092+
1093+
# Revoke the key
1094+
await revoke_api_key(api_key, session)
1095+
await session.commit()
1096+
1097+
return Template(
1098+
template_name="admin/partials/api_key_revoked.html",
1099+
context={
1100+
"key_prefix": api_key.key_prefix,
1101+
"key_name": api_key.name,
1102+
},
1103+
)
1104+
1105+
except Exception as e:
1106+
await session.rollback()
1107+
return Template(
1108+
template_name="admin/partials/sync_error.html",
1109+
context={"error": f"Failed to revoke key: {str(e)}"},
1110+
)
1111+
1112+
1113+
@post("/api-keys/create", sync_to_thread=False, status_code=HTTP_200_OK)
1114+
async def admin_create_api_key(
1115+
request: Request[Any, Any, Any],
1116+
session: AsyncSession,
1117+
) -> Template:
1118+
"""Create a new API key for the connected user from the admin panel.
1119+
1120+
Uses session authentication (admin login), not API key auth.
1121+
"""
1122+
if not is_authenticated(request):
1123+
return Template(
1124+
template_name="admin/partials/sync_error.html",
1125+
context={"error": "Authentication required. Please log in."},
1126+
)
1127+
1128+
try:
1129+
# Get the connected user
1130+
stmt = select(User).where(User.is_active == True).limit(1) # noqa: E712
1131+
result = await session.execute(stmt)
1132+
user = result.scalar_one_or_none()
1133+
1134+
if not user:
1135+
return Template(
1136+
template_name="admin/partials/sync_error.html",
1137+
context={"error": "No connected user found. Please connect via OAuth first."},
1138+
)
1139+
1140+
# Check if user already has an active key
1141+
key_stmt = select(APIKey).where(
1142+
APIKey.user_id == user.polar_user_id,
1143+
APIKey.is_active == True, # noqa: E712
1144+
)
1145+
key_result = await session.execute(key_stmt)
1146+
existing_key = key_result.scalar_one_or_none()
1147+
1148+
if existing_key:
1149+
return Template(
1150+
template_name="admin/partials/sync_error.html",
1151+
context={"error": "User already has an active API key. Use regenerate instead."},
1152+
)
1153+
1154+
# Create the API key
1155+
api_key, raw_key = await create_api_key_for_user(
1156+
user_id=user.polar_user_id,
1157+
name=f"Admin-created key for {user.polar_user_id}",
1158+
session=session,
1159+
)
1160+
await session.commit()
1161+
1162+
return Template(
1163+
template_name="admin/partials/api_key_regenerated.html",
1164+
context={
1165+
"api_key": raw_key,
1166+
"key_prefix": api_key.key_prefix,
1167+
"key_name": api_key.name,
1168+
},
1169+
)
1170+
1171+
except Exception as e:
1172+
await session.rollback()
1173+
return Template(
1174+
template_name="admin/partials/sync_error.html",
1175+
context={"error": f"Failed to create key: {str(e)}"},
1176+
)
1177+
1178+
9821179
# ============================================================================
9831180
# Chart Data API Routes (JSON endpoints for Chart.js)
9841181
# ============================================================================
@@ -1338,6 +1535,10 @@ async def export_cardio_load_csv(
13381535
oauth_authorize,
13391536
admin_settings,
13401537
reset_oauth_credentials,
1538+
# API Key management
1539+
admin_regenerate_api_key,
1540+
admin_revoke_api_key,
1541+
admin_create_api_key,
13411542
# Chart API endpoints
13421543
chart_sleep_data,
13431544
chart_activity_data,

src/polar_flow_server/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def create_app() -> Litestar:
117117
"/admin/settings", # Settings pages (reset-oauth, etc.)
118118
"/admin/sync", # Sync trigger from dashboard
119119
"/admin/logout", # Logout action
120+
"/admin/api-keys/", # API key management (uses session auth)
120121
"/oauth/", # OAuth endpoints for SaaS (callback, exchange, start)
121122
"/users/", # API routes use API key auth, not sessions
122123
"/health",

src/polar_flow_server/core/auth.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -164,21 +164,15 @@ async def api_key_guard(
164164
) -> None:
165165
"""Litestar guard that validates API key from request header.
166166
167-
If no API_KEY is configured, authentication is skipped (open access).
168-
Otherwise validates against config or database.
167+
Validates against config-based master key or database keys.
169168
170169
Args:
171170
connection: The ASGI connection
172171
_: The route handler (unused)
173172
174173
Raises:
175-
NotAuthorizedException: If API key is required but missing/invalid
174+
NotAuthorizedException: If API key is missing or invalid
176175
"""
177-
# If no API_KEY configured, skip authentication (simple self-hosted mode)
178-
if not settings.api_key:
179-
logger.debug("No API_KEY configured - authentication disabled")
180-
return
181-
182176
# Extract API key from headers
183177
api_key = _extract_api_key(connection)
184178

@@ -234,14 +228,10 @@ async def per_user_api_key_guard(
234228
raw_key = _extract_api_key(connection)
235229

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

244-
# First check if it matches the config-based master key
234+
# First check if it matches the config-based master key (if configured)
245235
if settings.api_key and secrets.compare_digest(raw_key, settings.api_key):
246236
logger.debug("Config-based master API key validated")
247237
return

0 commit comments

Comments
 (0)