GeezerVison is showing github.com — fix: add jwt standard claim · IDTS-LAB/python-codebase@6cf7435
File treeExpand file treeCollapse file tree Open diff view settings application/refresh_token
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: .env.example +3-1Lines changed: 3 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -10,7 +10,9 @@ SECRET_KEY=your-super-secret-production-key-here | 10 | 10 | MAX_REQUEST_SIZE_MB=5242880 #5mb | 11 | 11 |
| 12 | 12 | ALGORITHM=HS256 | | 13 | +JWT_ISSUER=todo-modulith-api | | 14 | +JWT_AUDIENCE=todo-modulith-client | 13 | 15 | ACCESS_TOKEN_EXPIRE_MINUTES=30 | 14 | 16 | REFRESH_TOKEN_EXPIRE_MINUTES=10080 | 15 | 17 |
| 16 | | -RATE_LIMIT="100/minute" | | 18 | +RATE_LIMIT="100/minute" |
Collapse file Copy file name to clipboardExpand all lines: README.md +3Lines changed: 3 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -233,7 +233,10 @@ APP_NAME=Todo Modulith API | 233 | 233 | DATABASE_URL= | 234 | 234 | SECRET_KEY= | 235 | 235 | ALGORITHM=HS256 | | 236 | +JWT_ISSUER=todo-modulith-api | | 237 | +JWT_AUDIENCE=todo-modulith-client | 236 | 238 | ACCESS_TOKEN_EXPIRE_MINUTES=30 | | 239 | +REFRESH_TOKEN_EXPIRE_MINUTES=10080 | 237 | 240 | ``` | 238 | 241 |
| 239 | 242 | For local development without Docker, point `DATABASE_URL` at your local PostgreSQL host, for example: | |
Collapse file Copy file name to clipboardExpand all lines: src/core/config/setting.py +2Lines changed: 2 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -19,6 +19,8 @@ class Settings(BaseSettings): | 19 | 19 | alias="SECRET_KEY", default="super-secret-key-change-in-production" | 20 | 20 | ) | 21 | 21 | 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") | 22 | 24 | ACCESS_TOKEN_EXPIRE_MINUTES: int = Field( | 23 | 25 | alias="ACCESS_TOKEN_EXPIRE_MINUTES", default=30 | 24 | 26 | ) | |
Collapse file Copy file name to clipboardExpand all lines: src/core/middleware/auth.py +7Lines changed: 7 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 6 | 6 |
| 7 | 7 | from src.core.security.jwt import JWTService | 8 | 8 | from src.core.security.token_revocation import TokenRevocationService | | 9 | +from src.shared.exceptions.credential_exception import InvalidCredentialsError | 9 | 10 |
| 10 | 11 | PUBLIC_PATHS = frozenset( | 11 | 12 | { | @@ -44,6 +45,7 @@ async def dispatch( | 44 | 45 |
| 45 | 46 | try: | 46 | 47 | payload = JWTService.decode_token(token) | | 48 | + JWTService.require_token_type(payload, JWTService.ACCESS_TOKEN_TYPE) | 47 | 49 | if await TokenRevocationService.is_access_token_revoked(token): | 48 | 50 | return JSONResponse( | 49 | 51 | status_code=status.HTTP_401_UNAUTHORIZED, | @@ -61,6 +63,11 @@ async def dispatch( | 61 | 63 | status_code=status.HTTP_401_UNAUTHORIZED, | 62 | 64 | content={"detail": f"Invalid or expired token: {str(e)}"}, | 63 | 65 | ) | | 66 | + except InvalidCredentialsError as e: | | 67 | + return JSONResponse( | | 68 | + status_code=status.HTTP_401_UNAUTHORIZED, | | 69 | + content={"detail": str(e)}, | | 70 | + ) | 64 | 71 | except Exception: | 65 | 72 | return JSONResponse( | 66 | 73 | status_code=status.HTTP_401_UNAUTHORIZED, | |
Collapse file Copy file name to clipboardExpand all lines: src/core/security/jwt.py +38-10Lines changed: 38 additions & 10 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 10 | 10 |
| 11 | 11 |
| 12 | 12 | class JWTService: | | 13 | + ACCESS_TOKEN_TYPE = "access" | | 14 | + REFRESH_TOKEN_TYPE = "refresh" | | 15 | + | 13 | 16 | @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) | 15 | 19 | 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 | + } | 18 | 31 | ) | 19 | | - to_encode.update({"exp": expire, "jti": str(uuid4())}) | 20 | 32 | return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) | 21 | 33 |
| | 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 | + | 22 | 42 | @staticmethod | 23 | 43 | 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), | 27 | 48 | ) | 28 | | - to_encode.update({"exp": expire}) | 29 | | - return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) | 30 | 49 |
| 31 | 50 | @staticmethod | 32 | 51 | def decode_token(token: str) -> dict: | 33 | 52 | try: | 34 | 53 | 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, | 36 | 59 | ) | 37 | 60 | except JWTError: | 38 | 61 | 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") |
Collapse file Copy file name to clipboardExpand all lines: src/modules/user/application/refresh_token/handler.py +13-1Lines changed: 13 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 11 | 11 | from src.modules.user.domain.repositories.refresh_token_repository import ( | 12 | 12 | RefreshTokenRepository, | 13 | 13 | ) | 14 | | -from src.shared.exceptions.credential_exception import InvalidRefreshTokenError | | 14 | +from src.shared.exceptions.credential_exception import ( | | 15 | + InvalidCredentialsError, | | 16 | + InvalidRefreshTokenError, | | 17 | +) | 15 | 18 | from src.shared.unit_of_work import UnitOfWork | 16 | 19 |
| 17 | 20 | settings = get_settings() | @@ -42,6 +45,15 @@ async def execute(self, command: RefreshTokenCommand) -> dict: | 42 | 45 | if stored_token.expires_at < datetime.now(timezone.utc): | 43 | 46 | raise InvalidRefreshTokenError("Refresh token has expired") | 44 | 47 |
| | 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 | + | 45 | 57 | async with self._unit_of_work: | 46 | 58 | stored_token.revoke() | 47 | 59 | await self._refresh_token_repo.save(stored_token) | |
Collapse file Copy file name to clipboard +3-18Lines changed: 3 additions & 18 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | | -from datetime import datetime, timedelta, timezone | 2 | | - | 3 | | -from jose import JWTError, jwt | 4 | 1 | from passlib.context import CryptContext | 5 | 2 |
| 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 | 8 | 4 |
| 9 | | -settings = get_settings() | 10 | 5 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") | 11 | 6 |
| 12 | 7 |
| @@ -15,17 +10,7 @@ def verifyy_password(self, password: str, hashed_password: str) -> bool: | 15 | 10 | return pwd_context.verify(password, hashed_password) | 16 | 11 |
| 17 | 12 | 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) | 24 | 14 |
| 25 | 15 | 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) |
Collapse file Copy file name to clipboardExpand all lines: tests/core/test_auth_middleware.py +41Lines changed: 41 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -40,3 +40,44 @@ async def call_next(_request): | 40 | 40 | assert response.status_code == 401 | 41 | 41 |
| 42 | 42 | 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()) |
Collapse file Copy file name to clipboardExpand all lines: tests/core/test_jwt_claims.py +8Lines changed: 8 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -9,10 +9,14 @@ def test_access_token_uses_standard_claim_payload(): | 9 | 9 | claims = JWTService.decode_token(token) | 10 | 10 |
| 11 | 11 | assert claims["sub"] == "user-id" | | 12 | + assert claims["iss"] | | 13 | + assert claims["aud"] | 12 | 14 | assert claims["token_type"] == "access" | 13 | 15 | assert claims["jti"] | 14 | 16 | assert claims["iat"] | | 17 | + assert claims["nbf"] | 15 | 18 | assert claims["exp"] | | 19 | + assert claims["nbf"] <= claims["iat"] | 16 | 20 | assert claims["iat"] <= claims["exp"] | 17 | 21 |
| 18 | 22 |
| @@ -22,10 +26,14 @@ def test_refresh_token_uses_standard_claim_payload(): | 22 | 26 | claims = JWTService.decode_token(token) | 23 | 27 |
| 24 | 28 | assert claims["sub"] == "user-id" | | 29 | + assert claims["iss"] | | 30 | + assert claims["aud"] | 25 | 31 | assert claims["token_type"] == "refresh" | 26 | 32 | assert claims["jti"] | 27 | 33 | assert claims["iat"] | | 34 | + assert claims["nbf"] | 28 | 35 | assert claims["exp"] | | 36 | + assert claims["nbf"] <= claims["iat"] | 29 | 37 | assert claims["iat"] <= claims["exp"] | 30 | 38 |
| 31 | 39 |
| |
Collapse file Copy file name to clipboardExpand all lines: tests/user/test_refresh_token_flow.py +29-6Lines changed: 29 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 6 | 6 |
| 7 | 7 | import pytest | 8 | 8 |
| | 9 | +from src.core.security.jwt import JWTService | 9 | 10 | from src.modules.user.application.refresh_token.command import RefreshTokenCommand | 10 | 11 | from src.modules.user.application.refresh_token import handler as refresh_handler_module | 11 | 12 | from src.modules.user.application.refresh_token.handler import RefreshTokenCommandHandler | @@ -90,11 +91,32 @@ async def run(): | 90 | 91 | asyncio.run(run()) | 91 | 92 |
| 92 | 93 |
| | 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 | + | 93 | 115 | def test_refresh_token_rotates_token_and_revokes_existing_token(): | 94 | 116 | async def run(): | 95 | | - raw_token = "raw-refresh-token" | 96 | | - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() | 97 | 117 | user_id = uuid4() | | 118 | + raw_token = JWTService.create_refresh_token({"sub": str(user_id)}) | | 119 | + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() | 98 | 120 | stored_token = RefreshToken.create( | 99 | 121 | user_id=user_id, | 100 | 122 | token_hash=token_hash, | @@ -126,9 +148,9 @@ async def run(): | 126 | 148 | "REFRESH_TOKEN_EXPIRE_MINUTES", | 127 | 149 | 15, | 128 | 150 | ) | 129 | | - raw_token = "raw-refresh-token" | 130 | | - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() | 131 | 151 | user_id = uuid4() | | 152 | + raw_token = JWTService.create_refresh_token({"sub": str(user_id)}) | | 153 | + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() | 132 | 154 | stored_token = RefreshToken.create( | 133 | 155 | user_id=user_id, | 134 | 156 | token_hash=token_hash, | @@ -152,10 +174,11 @@ async def run(): | 152 | 174 |
| 153 | 175 | def test_refresh_token_rotation_rolls_back_when_new_token_save_fails(): | 154 | 176 | async def run(): | 155 | | - raw_token = "raw-refresh-token" | | 177 | + user_id = uuid4() | | 178 | + raw_token = JWTService.create_refresh_token({"sub": str(user_id)}) | 156 | 179 | token_hash = hashlib.sha256(raw_token.encode()).hexdigest() | 157 | 180 | stored_token = RefreshToken.create( | 158 | | - user_id=uuid4(), | | 181 | + user_id=user_id, | 159 | 182 | token_hash=token_hash, | 160 | 183 | expires_at=datetime.now(timezone.utc) + timedelta(days=1), | 161 | 184 | ) | |
|