Skip to content

Commit 5d42928

Browse files
refactor(api): migrate from python-jose to jwcrypto for JWE
- Replace deprecated python-jose with actively maintained jwcrypto - Update JWE algorithm to A256KW/A256GCM (RFC standard key wrapping) - Fix deprecated datetime.utcnow() to datetime.now(UTC) - Add mypy override for jwcrypto (no type stubs available) BREAKING CHANGE: existing tokens are incompatible, users need to re-login Amp-Thread-ID: https://ampcode.com/threads/T-019bc1ce-d2ad-74b5-88a3-ec2fe6ca9fb0 Co-authored-by: Amp <amp@ampcode.com>
1 parent d079bd3 commit 5d42928

3 files changed

Lines changed: 58 additions & 118 deletions

File tree

apps/api/pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ dependencies = [
2222
"opentelemetry-instrumentation-httpx>=0.49b0",
2323
"opentelemetry-instrumentation-redis>=0.49b0",
2424
"opentelemetry-exporter-otlp>=1.28.0",
25-
"python-jose>=3.3.0",
26-
"cryptography>=44.0.0",
25+
"jwcrypto>=1.5.6",
2726
"psycopg2-binary>=2.9.9",
2827
]
2928

@@ -36,7 +35,7 @@ dev = [
3635
"pytest-asyncio>=0.26.0",
3736
"pytest-cov>=6.1.1",
3837
"factory-boy>=3.3.3",
39-
"types-python-jose>=3.3.0.13",
38+
4039
]
4140

4241
[tool.uv]
@@ -84,3 +83,7 @@ ignore_missing_imports = true
8483
[[tool.mypy.overrides]]
8584
module = ["redis.asyncio.*"]
8685
ignore_missing_imports = true
86+
87+
[[tool.mypy.overrides]]
88+
module = ["jwcrypto.*"]
89+
ignore_missing_imports = true

apps/api/src/lib/auth.py

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import json
22
from collections.abc import Callable
3-
from datetime import datetime, timedelta
3+
from datetime import UTC, datetime, timedelta
44
from functools import wraps
5-
from typing import Annotated, Any, Literal, cast
5+
from typing import Annotated, Any, Literal
66

77
import httpx
88
from fastapi import Depends, HTTPException, Request, status
9-
from jose import jwe
10-
from jose.exceptions import JWEError
9+
from jwcrypto import jwe, jwk
10+
from jwcrypto.common import JWException
1111
from pydantic import BaseModel
1212

1313
from src.lib.config import settings
@@ -65,59 +65,64 @@ class CurrentUserInfo(BaseModel):
6565
email_verified: bool = False
6666

6767

68+
def _get_jwe_key() -> jwk.JWK:
69+
"""Get JWK key for JWE encryption/decryption."""
70+
key_bytes = settings.JWE_SECRET_KEY.encode("utf-8")
71+
if len(key_bytes) < 32:
72+
key_bytes = key_bytes.ljust(32, b"\0")
73+
elif len(key_bytes) > 32:
74+
key_bytes = key_bytes[:32]
75+
return jwk.JWK(kty="oct", k=jwk.base64url_encode(key_bytes))
76+
77+
6878
def create_access_token(user_id: str) -> str:
6979
"""Create JWE access token."""
70-
now = datetime.utcnow()
80+
now = datetime.now(UTC)
7181
payload = {
7282
"user_id": user_id,
7383
"token_type": "access",
7484
"exp": int((now + timedelta(hours=1)).timestamp()),
7585
"iat": int(now.timestamp()),
7686
}
77-
encrypted = jwe.encrypt(
78-
json.dumps(payload).encode(),
79-
settings.JWE_SECRET_KEY,
80-
algorithm="A256GCM",
81-
encryption="A256GCM",
87+
88+
key = _get_jwe_key()
89+
jwe_token = jwe.JWE(
90+
json.dumps(payload).encode("utf-8"),
91+
recipient=key,
92+
protected={"alg": "A256KW", "enc": "A256GCM"},
8293
)
83-
if isinstance(encrypted, bytes):
84-
return encrypted.decode()
85-
return cast(str, encrypted)
94+
return str(jwe_token.serialize(compact=True))
8695

8796

8897
def create_refresh_token(user_id: str) -> str:
8998
"""Create JWE refresh token."""
90-
now = datetime.utcnow()
99+
now = datetime.now(UTC)
91100
payload = {
92101
"user_id": user_id,
93102
"token_type": "refresh",
94103
"exp": int((now + timedelta(days=7)).timestamp()),
95104
"iat": int(now.timestamp()),
96105
}
97-
encrypted = jwe.encrypt(
98-
json.dumps(payload).encode(),
99-
settings.JWE_SECRET_KEY,
100-
algorithm="A256GCM",
101-
encryption="A256GCM",
106+
107+
key = _get_jwe_key()
108+
jwe_token = jwe.JWE(
109+
json.dumps(payload).encode("utf-8"),
110+
recipient=key,
111+
protected={"alg": "A256KW", "enc": "A256GCM"},
102112
)
103-
if isinstance(encrypted, bytes):
104-
return encrypted.decode()
105-
return cast(str, encrypted)
113+
return str(jwe_token.serialize(compact=True))
106114

107115

108116
def decode_token(token: str) -> TokenPayload:
109117
"""Decode and validate JWE token."""
110118
try:
111-
decrypted = jwe.decrypt(token, settings.JWE_SECRET_KEY)
112-
if decrypted is None:
113-
raise HTTPException(
114-
status_code=status.HTTP_401_UNAUTHORIZED,
115-
detail="Invalid token",
116-
headers={"WWW-Authenticate": "Bearer"},
117-
)
118-
payload = json.loads(decrypted.decode())
119+
key = _get_jwe_key()
120+
jwe_token = jwe.JWE()
121+
jwe_token.deserialize(token)
122+
jwe_token.decrypt(key)
123+
payload = json.loads(jwe_token.payload.decode("utf-8"))
119124
return TokenPayload(**payload)
120-
except JWEError as e:
125+
except JWException:
121126
raise HTTPException(
122127
status_code=status.HTTP_401_UNAUTHORIZED,
123128
detail="Invalid token",
@@ -233,7 +238,7 @@ async def get_current_user(request: Request) -> CurrentUserInfo:
233238
detail="Invalid token type",
234239
)
235240

236-
if datetime.utcnow().timestamp() > payload.exp:
241+
if datetime.now(UTC).timestamp() > payload.exp:
237242
raise HTTPException(
238243
status_code=status.HTTP_401_UNAUTHORIZED,
239244
detail="Token expired",

apps/api/uv.lock

Lines changed: 15 additions & 83 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)