Skip to content

Commit c8b60fe

Browse files
authored
Merge pull request #1 from BelCo94/enrich-token-expired
feat: add jti to TokenExpired exception and get_unverified_jwt method
2 parents 648bf45 + 1ed81d0 commit c8b60fe

3 files changed

Lines changed: 91 additions & 7 deletions

File tree

src/fastapi_jwt_harmony/base.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,33 @@ def configure(
170170
if denylist_callback:
171171
cls._token_in_denylist_callback = denylist_callback
172172

173+
def get_unverified_jwt(self, encoded_token: Optional[str] = None) -> Optional[dict[str, Union[str, int, bool]]]:
174+
"""
175+
Decode a JWT token without signature or expiration verification.
176+
177+
This method decodes a JWT token to extract its claims without validating
178+
the signature or checking if it has expired. Useful for extracting information
179+
from tokens that may be expired or when signature verification is not needed.
180+
181+
Args:
182+
encoded_token: Encoded JWT as a string. If not provided, uses internal token.
183+
184+
Returns:
185+
Decoded token payload if successful, None if no token is available.
186+
187+
Raises:
188+
JWTDecodeError: If the token structure is invalid and cannot be decoded.
189+
"""
190+
token = encoded_token or self._token
191+
if not token:
192+
return None
193+
194+
try:
195+
decoded: dict[str, Union[str, int, bool]] = jwt.decode(token, options={'verify_signature': False, 'verify_exp': False})
196+
return decoded
197+
except jwt.DecodeError as e:
198+
raise JWTDecodeError(str(e)) from e
199+
173200
def get_raw_jwt(self, encoded_token: Optional[str] = None) -> Optional[dict[str, Union[str, int, bool]]]:
174201
"""
175202
Decodes and verifies a JSON Web Token (JWT).
@@ -185,10 +212,9 @@ def get_raw_jwt(self, encoded_token: Optional[str] = None) -> Optional[dict[str,
185212
return None
186213

187214
# Decode token without verification first to check if it's in denylist
188-
try:
189-
unverified_token = jwt.decode(token, options={'verify_signature': False, 'verify_exp': False})
190-
except jwt.DecodeError as e:
191-
raise JWTDecodeError(str(e)) from e
215+
unverified_token = self.get_unverified_jwt(token)
216+
if not unverified_token:
217+
return None
192218

193219
# Check if denylist is enabled
194220
if self.config.denylist_enabled:
@@ -494,7 +520,8 @@ def _verified_token(self, encoded_token: str) -> dict[str, Any]:
494520
)
495521
return decoded
496522
except jwt.ExpiredSignatureError as exc:
497-
raise TokenExpired('Token expired') from exc
523+
jti_value = (self.get_unverified_jwt(encoded_token) or {}).get('jti')
524+
raise TokenExpired('Token expired', jti=str(jti_value) if jti_value else None) from exc
498525
except jwt.InvalidTokenError as e:
499526
raise JWTDecodeError(str(e)) from e
500527

src/fastapi_jwt_harmony/exceptions.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,15 @@ class TokenExpired(JWTHarmonyException):
231231
status_code (int): HTTP status code corresponding to the exception (401).
232232
message (str): Description or message explaining the reason for the
233233
exception.
234+
jti (str | None): The JWT ID (jti claim) of the expired token, if available.
234235
"""
235236

236-
def __init__(self, message: str) -> None:
237+
def __init__(self, message: str, jti: str | None = None) -> None:
237238
"""Initializes a token expired error with a message.
238239
239240
Args:
240241
message (str): The message explaining that the token has expired.
242+
jti (str | None): The JWT ID (jti claim) of the expired token, if available.
241243
"""
242244
super().__init__(401, message)
245+
self.jti = jti

tests/test_decode_token.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from fastapi_jwt_harmony import JWTHarmony, JWTHarmonyDep, JWTHarmonyRefresh
1212
from fastapi_jwt_harmony.config import JWTHarmonyConfig
13-
from fastapi_jwt_harmony.exceptions import JWTHarmonyException
13+
from fastapi_jwt_harmony.exceptions import JWTDecodeError, JWTHarmonyException, TokenExpired
1414
from tests.user_models import SimpleUser
1515

1616

@@ -147,6 +147,39 @@ def test_get_raw_jwt(default_access_token, encoded_token):
147147
assert auth.get_raw_jwt(encoded_token) == default_access_token
148148

149149

150+
def test_get_unverified_jwt(default_access_token):
151+
"""Test that get_unverified_jwt decodes tokens without verification."""
152+
# Reset configuration
153+
JWTHarmony._config = None
154+
155+
JWTHarmony.configure(SimpleUser, JWTHarmonyConfig(secret_key='secret-key'))
156+
157+
auth = JWTHarmony()
158+
159+
# Test with valid token
160+
token = jwt.encode(default_access_token, 'secret-key', algorithm='HS256')
161+
result = auth.get_unverified_jwt(token)
162+
assert result == default_access_token
163+
164+
# Test with expired token (should still decode)
165+
expired_payload = {**default_access_token, 'exp': 0}
166+
expired_token = jwt.encode(expired_payload, 'secret-key', algorithm='HS256')
167+
result = auth.get_unverified_jwt(expired_token)
168+
assert result == expired_payload
169+
170+
# Test with invalid signature (should still decode)
171+
token_wrong_sig = jwt.encode(default_access_token, 'wrong-key', algorithm='HS256')
172+
result = auth.get_unverified_jwt(token_wrong_sig)
173+
assert result == default_access_token
174+
175+
# Test with completely malformed token (should raise JWTDecodeError)
176+
with pytest.raises(JWTDecodeError):
177+
auth.get_unverified_jwt('invalid.token')
178+
179+
# Test with no token provided
180+
assert auth.get_unverified_jwt() is None
181+
182+
150183
def test_get_jwt_jti(client, default_access_token, encoded_token):
151184
# Reset configuration
152185
JWTHarmony._config = None
@@ -339,3 +372,24 @@ def test_invalid_asymmetric_algorithms(client):
339372
token = auth2.create_access_token(user_claims=user)
340373
with pytest.raises(RuntimeError, match=r'public_key'):
341374
client.get('/protected', headers={'Authorization': f'Bearer {token}'})
375+
376+
377+
def test_expired_token_includes_jti(default_access_token):
378+
"""Test that TokenExpired exception includes the jti from expired tokens."""
379+
# Reset configuration
380+
JWTHarmony._config = None
381+
382+
JWTHarmony.configure(SimpleUser, JWTHarmonyConfig(secret_key='secret-key'))
383+
384+
auth = JWTHarmony()
385+
386+
# Create an expired token using the fixture
387+
expired_payload = {**default_access_token, 'exp': 0} # Expired in 1970
388+
expired_token = jwt.encode(expired_payload, 'secret-key', algorithm='HS256')
389+
390+
# Try to verify the expired token
391+
with pytest.raises(TokenExpired, match='Token expired') as exc_info:
392+
auth.get_raw_jwt(expired_token)
393+
394+
# Verify the exception contains the jti
395+
assert exc_info.value.jti == default_access_token['jti']

0 commit comments

Comments
 (0)