Skip to content

Commit 781ee64

Browse files
committed
fix: keep legacy api keys active
1 parent 20214a7 commit 781ee64

7 files changed

Lines changed: 133 additions & 29 deletions

File tree

apps/api/alembic/versions/f6a7b8c9d0e1_key_api_key_hashes.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,19 @@
88

99
from typing import Sequence, Union
1010

11-
from alembic import op
12-
13-
1411
revision: str = "f6a7b8c9d0e1"
1512
down_revision: Union[str, Sequence[str], None] = "e5f6a7b8c9d0"
1613
branch_labels: Union[str, Sequence[str], None] = None
1714
depends_on: Union[str, Sequence[str], None] = None
1815

1916

2017
def upgrade() -> None:
21-
op.execute("UPDATE api_keys SET is_active = false")
18+
# Existing API key hashes are legacy unsalted SHA-256 values. They remain
19+
# active for backwards compatibility; runtime lookup accepts both legacy
20+
# SHA-256 and new keyed HMAC-SHA-256 digests.
21+
pass
2222

2323

2424
def downgrade() -> None:
25+
# No schema or data migration is needed.
2526
pass

apps/api/app/core/dependencies.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
AuthException,
1818
PermissionDeniedException,
1919
)
20-
from shared.utils.api_key_hashing import hash_api_key
20+
from shared.utils.api_key_hashing import get_api_key_lookup_hashes
2121

2222
# Standard JWKS endpoint path (fixed, following OpenID Connect convention)
2323
JWKS_ENDPOINT_PATH = "/api/auth/jwks"
@@ -186,19 +186,27 @@ async def get_current_user_id(
186186
# Mode 1: API Key verification (for external clients)
187187
if token.startswith("sk_"):
188188
# Check identity cache first — skip DB on cache hit
189-
api_key_hash = hash_api_key(token)
189+
api_key_hashes = get_api_key_lookup_hashes(token)
190190
try:
191-
cached = await identity_cache.get_cached_identity(
192-
redis_pool_manager.get_redis_service(),
193-
identity_cache._apikey_key(api_key_hash),
194-
)
191+
cached = None
192+
matched_api_key_hash = None
193+
redis_service = redis_pool_manager.get_redis_service()
194+
for api_key_hash in api_key_hashes:
195+
cached = await identity_cache.get_cached_identity(
196+
redis_service,
197+
identity_cache._apikey_key(api_key_hash),
198+
)
199+
if cached is not None:
200+
matched_api_key_hash = api_key_hash
201+
break
195202
if cached is not None:
196203
cached_user_id = cached.get("user_id")
197204
cached_user_tier = cached.get("user_tier")
198205
if cached_user_id and isinstance(cached_user_tier, str):
199206
request.state.cached_user_tier = cached_user_tier
200207
request.state.cached_identity_hit = True
201208
request.state.user_id = cached_user_id
209+
request.state.api_key_hash = matched_api_key_hash
202210
_enforce_guest_api_key_scope(route_path, cached_user_tier)
203211
return cached_user_id
204212
except PermissionDeniedException:
@@ -213,6 +221,7 @@ async def get_current_user_id(
213221
request.state.cached_user_tier = identity.user_tier
214222
request.state.cached_identity_hit = False
215223
request.state.user_id = identity.user_id
224+
request.state.api_key_hash = identity.key_hash
216225
_enforce_guest_api_key_scope(route_path, identity.user_tier)
217226
return identity.user_id
218227
else:

apps/api/app/repositories/api_key_repository.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ async def get_by_key_hash(
3232
)
3333
return result.scalar_one_or_none()
3434

35+
async def get_by_key_hashes(
36+
self, session: AsyncSession, key_hashes: Sequence[str]
37+
) -> Optional[APIKey]:
38+
"""Get an API key by candidate key hash, preserving candidate priority."""
39+
for key_hash in key_hashes:
40+
api_key = await self.get_by_key_hash(session, key_hash)
41+
if api_key is not None:
42+
return api_key
43+
return None
44+
3545
async def get_by_user_id(
3646
self, session: AsyncSession, user_id: str
3747
) -> Sequence[APIKey]:

apps/api/app/services/auth/api_key_service.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from shared.models.database.api_key import APIKey
2424
from shared.models.database.user_balance import UserBalance
25-
from shared.utils.api_key_hashing import hash_api_key
25+
from shared.utils.api_key_hashing import get_api_key_lookup_hashes, hash_api_key
2626

2727
_DEFAULT_USER_TIER: str = "free"
2828

@@ -33,6 +33,7 @@ class APIKeyIdentity:
3333

3434
user_id: str
3535
user_tier: str
36+
key_hash: str
3637

3738

3839
class APIKeyService:
@@ -116,8 +117,8 @@ async def validate_api_key_identity(
116117
api_key: str,
117118
) -> Optional[APIKeyIdentity]:
118119
"""Validate API key and return the authenticated identity."""
119-
key_hash = hash_api_key(api_key)
120-
api_key_record = await self.repository.get_by_key_hash(session, key_hash)
120+
key_hashes = get_api_key_lookup_hashes(api_key)
121+
api_key_record = await self.repository.get_by_key_hashes(session, key_hashes)
121122

122123
if not api_key_record or not api_key_record.is_valid():
123124
return None
@@ -129,6 +130,7 @@ async def validate_api_key_identity(
129130
return APIKeyIdentity(
130131
user_id=user_id,
131132
user_tier=user_tier,
133+
key_hash=str(api_key_record.key_hash),
132134
)
133135

134136
async def _resolve_user_tier(
@@ -267,8 +269,8 @@ async def check_module_permission(
267269
self, session: AsyncSession, api_key: str, module: str
268270
) -> bool:
269271
"""Check whether an API key can access the requested module."""
270-
key_hash = hash_api_key(api_key)
271-
api_key_record = await self.repository.get_by_key_hash(session, key_hash)
272+
key_hashes = get_api_key_lookup_hashes(api_key)
273+
api_key_record = await self.repository.get_by_key_hashes(session, key_hashes)
272274

273275
if not api_key_record or not api_key_record.is_valid():
274276
return False

apps/api/app/services/rate_limit/dependencies.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import math
1818
from datetime import datetime, timezone
19-
from typing import AsyncGenerator
19+
from typing import Any, AsyncGenerator
2020

2121
from app.core.dependencies import get_current_user_id
2222
from app.services.rate_limit.config import (
@@ -41,9 +41,9 @@
4141
from shared.core.logging import log_context
4242
from shared.core.state_machine.states import JobStatus
4343
from shared.models.database.api_key import APIKey
44-
from shared.utils.api_key_hashing import hash_api_key
4544
from shared.models.database.job import Job
4645
from shared.models.database.user_balance import UserBalance
46+
from shared.utils.api_key_hashing import get_api_key_lookup_hashes
4747

4848
_DEFAULT_TIER: str = "free"
4949
_ACTIVE_JOB_STATES: tuple[str, ...] = (
@@ -140,6 +140,21 @@ async def _resolve_apikey_cache_ttl_seconds(api_key_hash: str) -> int:
140140
return max_ttl_seconds
141141

142142

143+
async def _get_cached_apikey_identity(
144+
redis_service: Any,
145+
api_key_hashes: list[str],
146+
) -> tuple[dict | None, str | None]:
147+
"""Return cached API key identity and the hash key that matched it."""
148+
for api_key_hash in api_key_hashes:
149+
cached = await identity_cache.get_cached_identity(
150+
redis_service,
151+
identity_cache._apikey_key(api_key_hash),
152+
)
153+
if cached is not None:
154+
return cached, api_key_hash
155+
return None, None
156+
157+
143158
# ---------------------------------------------------------------------------
144159
# with_current_user -- Layer 0 (matched system limit)
145160
# ---------------------------------------------------------------------------
@@ -172,11 +187,16 @@ async def with_current_user(
172187
if isinstance(user_tier, str) and stashed_user_id == user_id:
173188
if cached_identity_hit is False:
174189
token = _extract_bearer_token(request.headers.get("authorization"))
175-
api_key_hash = None
190+
api_key_hash = getattr(request.state, "api_key_hash", None)
176191
is_api_key_auth = isinstance(token, str) and token.startswith("sk_")
177-
if token is not None and is_api_key_auth:
178-
api_key_hash = hash_api_key(token)
179-
if is_api_key_auth and api_key_hash:
192+
if (
193+
is_api_key_auth
194+
and not api_key_hash
195+
and isinstance(token, str)
196+
):
197+
api_key_hashes = get_api_key_lookup_hashes(token)
198+
api_key_hash = api_key_hashes[0] if api_key_hashes else None
199+
if is_api_key_auth and isinstance(api_key_hash, str):
180200
try:
181201
ttl_seconds = await _resolve_apikey_cache_ttl_seconds(api_key_hash)
182202
await identity_cache.set_apikey_identity(
@@ -194,24 +214,35 @@ async def with_current_user(
194214
)
195215
else:
196216
token = _extract_bearer_token(request.headers.get("authorization"))
197-
api_key_hash = None
217+
api_key_hashes: list[str] = []
198218
is_api_key_auth = isinstance(token, str) and token.startswith("sk_")
199219
if token is not None and is_api_key_auth:
200-
api_key_hash = hash_api_key(token)
220+
api_key_hashes = get_api_key_lookup_hashes(token)
201221
cache_key: str = (
202-
identity_cache._apikey_key(api_key_hash)
203-
if is_api_key_auth and api_key_hash
222+
identity_cache._apikey_key(api_key_hashes[0])
223+
if is_api_key_auth and api_key_hashes
204224
else identity_cache._jwt_key(user_id)
205225
)
206226
try:
207-
cached: dict | None = await identity_cache.get_cached_identity(
208-
redis_service, cache_key
209-
)
227+
if is_api_key_auth and api_key_hashes:
228+
cached, matched_api_key_hash = await _get_cached_apikey_identity(
229+
redis_service,
230+
api_key_hashes,
231+
)
232+
else:
233+
cached = await identity_cache.get_cached_identity(
234+
redis_service,
235+
cache_key,
236+
)
237+
matched_api_key_hash = None
210238
if cached is not None:
211239
user_tier = cached.get("user_tier", _DEFAULT_TIER)
240+
if matched_api_key_hash:
241+
request.state.api_key_hash = matched_api_key_hash
212242
else:
213243
user_tier = await _resolve_user_tier_from_db(user_id)
214-
if is_api_key_auth and api_key_hash:
244+
api_key_hash = getattr(request.state, "api_key_hash", None)
245+
if is_api_key_auth and isinstance(api_key_hash, str):
215246
ttl_seconds = await _resolve_apikey_cache_ttl_seconds(api_key_hash)
216247
await identity_cache.set_apikey_identity(
217248
redis_service,

apps/api/tests/contract/test_api_key_contract.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import pytest
77
from httpx import AsyncClient
88

9+
from tests.support.contract_database import ContractDatabase
10+
from shared.utils.api_key_hashing import hash_legacy_api_key
11+
912

1013
@pytest.mark.asyncio
1114
async def test_should_revoke_a_created_api_key_through_http_only(
@@ -78,6 +81,37 @@ async def test_should_revoke_a_created_api_key_through_http_only(
7881
assert "details" not in error
7982

8083

84+
@pytest.mark.asyncio
85+
async def test_should_accept_an_active_legacy_sha256_api_key_hash(
86+
api_client_factory: Callable[[], AbstractAsyncContextManager[AsyncClient]],
87+
) -> None:
88+
user_id = f"legacy-user-{uuid4().hex[:12]}"
89+
raw_api_key = f"sk_legacy_{uuid4().hex}"
90+
legacy_key_hash = hash_legacy_api_key(raw_api_key)
91+
92+
async with api_client_factory() as api_client:
93+
await ContractDatabase.insert_authenticated_user(
94+
user_id=user_id,
95+
api_key=raw_api_key,
96+
user_tier="tier_5",
97+
)
98+
await ContractDatabase.execute(
99+
"""
100+
UPDATE api_keys
101+
SET key_hash = :legacy_key_hash
102+
WHERE user_id = :user_id
103+
""",
104+
{
105+
"legacy_key_hash": legacy_key_hash,
106+
"user_id": user_id,
107+
},
108+
)
109+
api_client.headers.update({"Authorization": f"Bearer {raw_api_key}"})
110+
response = await api_client.get("/api/v1/jobs")
111+
112+
assert response.status_code == 200
113+
114+
81115
@pytest.mark.asyncio
82116
async def test_should_regenerate_an_api_key_and_invalidate_the_previous_raw_key(
83117
developer_api_client_factory: Callable[

packages/shared-python/shared/utils/api_key_hashing.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import hmac
44
from hashlib import sha256
55

6+
LEGACY_API_KEY_HASH_VERSION = "sha256"
7+
CURRENT_API_KEY_HASH_VERSION = "hmac-sha256"
8+
69

710
def hash_api_key(api_key: str) -> str:
811
"""Return a deterministic keyed digest for API key lookup."""
@@ -11,3 +14,17 @@ def hash_api_key(api_key: str) -> str:
1114
secret_key = settings.SECRET_KEY.encode("utf-8")
1215
api_key_bytes = api_key.encode("utf-8")
1316
return hmac.new(secret_key, api_key_bytes, sha256).hexdigest()
17+
18+
19+
def hash_legacy_api_key(api_key: str) -> str:
20+
"""Return the legacy unkeyed digest used before keyed API key hashing."""
21+
return sha256(api_key.encode("utf-8")).hexdigest()
22+
23+
24+
def get_api_key_lookup_hashes(api_key: str) -> list[str]:
25+
"""Return hashes that may identify this API key, strongest first."""
26+
current_hash = hash_api_key(api_key)
27+
legacy_hash = hash_legacy_api_key(api_key)
28+
if legacy_hash == current_hash:
29+
return [current_hash]
30+
return [current_hash, legacy_hash]

0 commit comments

Comments
 (0)