Skip to content

Commit 6cf7435

Browse files
committed
fix: add jwt standard claim
1 parent 685e167 commit 6cf7435

10 files changed

Lines changed: 147 additions & 36 deletions

File tree

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ SECRET_KEY=your-super-secret-production-key-here
1010
MAX_REQUEST_SIZE_MB=5242880 #5mb
1111

1212
ALGORITHM=HS256
13+
JWT_ISSUER=todo-modulith-api
14+
JWT_AUDIENCE=todo-modulith-client
1315
ACCESS_TOKEN_EXPIRE_MINUTES=30
1416
REFRESH_TOKEN_EXPIRE_MINUTES=10080
1517

16-
RATE_LIMIT="100/minute"
18+
RATE_LIMIT="100/minute"

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,10 @@ APP_NAME=Todo Modulith API
233233
DATABASE_URL=
234234
SECRET_KEY=
235235
ALGORITHM=HS256
236+
JWT_ISSUER=todo-modulith-api
237+
JWT_AUDIENCE=todo-modulith-client
236238
ACCESS_TOKEN_EXPIRE_MINUTES=30
239+
REFRESH_TOKEN_EXPIRE_MINUTES=10080
237240
```
238241

239242
For local development without Docker, point `DATABASE_URL` at your local PostgreSQL host, for example:

src/core/config/setting.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class Settings(BaseSettings):
1919
alias="SECRET_KEY", default="super-secret-key-change-in-production"
2020
)
2121
ALGORITHM: str = Field(alias="ALGORITHM", default="HS256")
22+
JWT_ISSUER: str = Field(alias="JWT_ISSUER", default="todo-modulith-api")
23+
JWT_AUDIENCE: str = Field(alias="JWT_AUDIENCE", default="todo-modulith-client")
2224
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(
2325
alias="ACCESS_TOKEN_EXPIRE_MINUTES", default=30
2426
)

src/core/middleware/auth.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from src.core.security.jwt import JWTService
88
from src.core.security.token_revocation import TokenRevocationService
9+
from src.shared.exceptions.credential_exception import InvalidCredentialsError
910

1011
PUBLIC_PATHS = frozenset(
1112
{
@@ -44,6 +45,7 @@ async def dispatch(
4445

4546
try:
4647
payload = JWTService.decode_token(token)
48+
JWTService.require_token_type(payload, JWTService.ACCESS_TOKEN_TYPE)
4749
if await TokenRevocationService.is_access_token_revoked(token):
4850
return JSONResponse(
4951
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -61,6 +63,11 @@ async def dispatch(
6163
status_code=status.HTTP_401_UNAUTHORIZED,
6264
content={"detail": f"Invalid or expired token: {str(e)}"},
6365
)
66+
except InvalidCredentialsError as e:
67+
return JSONResponse(
68+
status_code=status.HTTP_401_UNAUTHORIZED,
69+
content={"detail": str(e)},
70+
)
6471
except Exception:
6572
return JSONResponse(
6673
status_code=status.HTTP_401_UNAUTHORIZED,

src/core/security/jwt.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,57 @@
1010

1111

1212
class JWTService:
13+
ACCESS_TOKEN_TYPE = "access"
14+
REFRESH_TOKEN_TYPE = "refresh"
15+
1316
@staticmethod
14-
def create_access_token(data: dict) -> str:
17+
def _create_token(data: dict, token_type: str, expires_delta: timedelta) -> str:
18+
now = datetime.now(timezone.utc)
1519
to_encode = data.copy()
16-
expire = datetime.now(timezone.utc) + timedelta(
17-
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
20+
to_encode.update(
21+
{
22+
"iss": settings.JWT_ISSUER,
23+
"sub": str(to_encode["sub"]),
24+
"aud": settings.JWT_AUDIENCE,
25+
"exp": now + expires_delta,
26+
"nbf": now,
27+
"iat": now,
28+
"jti": str(uuid4()),
29+
"token_type": token_type,
30+
}
1831
)
19-
to_encode.update({"exp": expire, "jti": str(uuid4())})
2032
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
2133

34+
@staticmethod
35+
def create_access_token(data: dict) -> str:
36+
return JWTService._create_token(
37+
data=data,
38+
token_type=JWTService.ACCESS_TOKEN_TYPE,
39+
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
40+
)
41+
2242
@staticmethod
2343
def create_refresh_token(data: dict) -> str:
24-
to_encode = data.copy()
25-
expire = datetime.now(timezone.utc) + timedelta(
26-
minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES,
44+
return JWTService._create_token(
45+
data=data,
46+
token_type=JWTService.REFRESH_TOKEN_TYPE,
47+
expires_delta=timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES),
2748
)
28-
to_encode.update({"exp": expire})
29-
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
3049

3150
@staticmethod
3251
def decode_token(token: str) -> dict:
3352
try:
3453
return jwt.decode(
35-
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
54+
token,
55+
settings.SECRET_KEY,
56+
algorithms=[settings.ALGORITHM],
57+
issuer=settings.JWT_ISSUER,
58+
audience=settings.JWT_AUDIENCE,
3659
)
3760
except JWTError:
3861
raise InvalidCredentialsError("Invalid or expired token")
62+
63+
@staticmethod
64+
def require_token_type(payload: dict, expected_token_type: str) -> None:
65+
if payload.get("token_type") != expected_token_type:
66+
raise InvalidCredentialsError(f"{expected_token_type.title()} token required")

src/modules/user/application/refresh_token/handler.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
from src.modules.user.domain.repositories.refresh_token_repository import (
1212
RefreshTokenRepository,
1313
)
14-
from src.shared.exceptions.credential_exception import InvalidRefreshTokenError
14+
from src.shared.exceptions.credential_exception import (
15+
InvalidCredentialsError,
16+
InvalidRefreshTokenError,
17+
)
1518
from src.shared.unit_of_work import UnitOfWork
1619

1720
settings = get_settings()
@@ -42,6 +45,15 @@ async def execute(self, command: RefreshTokenCommand) -> dict:
4245
if stored_token.expires_at < datetime.now(timezone.utc):
4346
raise InvalidRefreshTokenError("Refresh token has expired")
4447

48+
try:
49+
payload = JWTService.decode_token(command.token)
50+
JWTService.require_token_type(payload, JWTService.REFRESH_TOKEN_TYPE)
51+
except InvalidCredentialsError:
52+
raise InvalidRefreshTokenError("Invalid refresh token")
53+
54+
if payload.get("sub") != str(stored_token.user_id):
55+
raise InvalidRefreshTokenError("Invalid refresh token")
56+
4557
async with self._unit_of_work:
4658
stored_token.revoke()
4759
await self._refresh_token_repo.save(stored_token)
Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
from datetime import datetime, timedelta, timezone
2-
3-
from jose import JWTError, jwt
41
from passlib.context import CryptContext
52

6-
from src.core.config.setting import get_settings
7-
from src.modules.user.domain.exceptions import InvalidCredentialsError
3+
from src.core.security.jwt import JWTService
84

9-
settings = get_settings()
105
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
116

127

@@ -15,17 +10,7 @@ def verifyy_password(self, password: str, hashed_password: str) -> bool:
1510
return pwd_context.verify(password, hashed_password)
1611

1712
def create_access_token(self, data: dict) -> str:
18-
to_encode = data.copy()
19-
expire = datetime.now(timezone.utc) + timedelta(
20-
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
21-
)
22-
to_encode.update({"exp": expire})
23-
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
13+
return JWTService.create_access_token(data)
2414

2515
def decode_token(self, token: str) -> dict:
26-
try:
27-
return jwt.decode(
28-
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
29-
)
30-
except JWTError:
31-
raise InvalidCredentialsError("Invalid or expired token")
16+
return JWTService.decode_token(token)

tests/core/test_auth_middleware.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,44 @@ async def call_next(_request):
4040
assert response.status_code == 401
4141

4242
asyncio.run(run())
43+
44+
45+
def test_authentication_middleware_rejects_refresh_token_on_protected_endpoint(
46+
monkeypatch,
47+
):
48+
async def run():
49+
async def active_token(_token):
50+
return False
51+
52+
monkeypatch.setattr(
53+
TokenRevocationService,
54+
"is_access_token_revoked",
55+
active_token,
56+
)
57+
58+
token = JWTService.create_refresh_token({"sub": "user-id"})
59+
call_next_called = False
60+
request = Request(
61+
{
62+
"type": "http",
63+
"method": "GET",
64+
"path": "/protected",
65+
"headers": [(b"authorization", f"Bearer {token}".encode())],
66+
"query_string": b"",
67+
"server": ("testserver", 80),
68+
"scheme": "http",
69+
"client": ("testclient", 50000),
70+
}
71+
)
72+
73+
async def call_next(_request):
74+
nonlocal call_next_called
75+
call_next_called = True
76+
return JSONResponse({"ok": True})
77+
78+
response = await AuthenticationMiddleware(None).dispatch(request, call_next)
79+
80+
assert response.status_code == 401
81+
assert call_next_called is False
82+
83+
asyncio.run(run())

tests/core/test_jwt_claims.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ def test_access_token_uses_standard_claim_payload():
99
claims = JWTService.decode_token(token)
1010

1111
assert claims["sub"] == "user-id"
12+
assert claims["iss"]
13+
assert claims["aud"]
1214
assert claims["token_type"] == "access"
1315
assert claims["jti"]
1416
assert claims["iat"]
17+
assert claims["nbf"]
1518
assert claims["exp"]
19+
assert claims["nbf"] <= claims["iat"]
1620
assert claims["iat"] <= claims["exp"]
1721

1822

@@ -22,10 +26,14 @@ def test_refresh_token_uses_standard_claim_payload():
2226
claims = JWTService.decode_token(token)
2327

2428
assert claims["sub"] == "user-id"
29+
assert claims["iss"]
30+
assert claims["aud"]
2531
assert claims["token_type"] == "refresh"
2632
assert claims["jti"]
2733
assert claims["iat"]
34+
assert claims["nbf"]
2835
assert claims["exp"]
36+
assert claims["nbf"] <= claims["iat"]
2937
assert claims["iat"] <= claims["exp"]
3038

3139

tests/user/test_refresh_token_flow.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88

9+
from src.core.security.jwt import JWTService
910
from src.modules.user.application.refresh_token.command import RefreshTokenCommand
1011
from src.modules.user.application.refresh_token import handler as refresh_handler_module
1112
from src.modules.user.application.refresh_token.handler import RefreshTokenCommandHandler
@@ -90,11 +91,32 @@ async def run():
9091
asyncio.run(run())
9192

9293

94+
def test_refresh_token_rejects_access_token_even_when_hash_exists():
95+
async def run():
96+
user_id = uuid4()
97+
access_token = JWTService.create_access_token({"sub": str(user_id)})
98+
token_hash = hashlib.sha256(access_token.encode()).hexdigest()
99+
stored_token = RefreshToken.create(
100+
user_id=user_id,
101+
token_hash=token_hash,
102+
expires_at=datetime.now(timezone.utc) + timedelta(days=1),
103+
)
104+
handler = RefreshTokenCommandHandler(
105+
FakeRefreshTokenRepository(stored_token),
106+
FakeUnitOfWork(),
107+
)
108+
109+
with pytest.raises(InvalidRefreshTokenError, match="Invalid refresh token"):
110+
await handler.execute(RefreshTokenCommand(token=access_token))
111+
112+
asyncio.run(run())
113+
114+
93115
def test_refresh_token_rotates_token_and_revokes_existing_token():
94116
async def run():
95-
raw_token = "raw-refresh-token"
96-
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
97117
user_id = uuid4()
118+
raw_token = JWTService.create_refresh_token({"sub": str(user_id)})
119+
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
98120
stored_token = RefreshToken.create(
99121
user_id=user_id,
100122
token_hash=token_hash,
@@ -126,9 +148,9 @@ async def run():
126148
"REFRESH_TOKEN_EXPIRE_MINUTES",
127149
15,
128150
)
129-
raw_token = "raw-refresh-token"
130-
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
131151
user_id = uuid4()
152+
raw_token = JWTService.create_refresh_token({"sub": str(user_id)})
153+
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
132154
stored_token = RefreshToken.create(
133155
user_id=user_id,
134156
token_hash=token_hash,
@@ -152,10 +174,11 @@ async def run():
152174

153175
def test_refresh_token_rotation_rolls_back_when_new_token_save_fails():
154176
async def run():
155-
raw_token = "raw-refresh-token"
177+
user_id = uuid4()
178+
raw_token = JWTService.create_refresh_token({"sub": str(user_id)})
156179
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
157180
stored_token = RefreshToken.create(
158-
user_id=uuid4(),
181+
user_id=user_id,
159182
token_hash=token_hash,
160183
expires_at=datetime.now(timezone.utc) + timedelta(days=1),
161184
)

0 commit comments

Comments
 (0)