Skip to content

Commit 7320fc4

Browse files
committed
Merge branch 'master' of https://github.com/jpadilla/pyjwt
2 parents 85c13a7 + 151c84e commit 7320fc4

File tree

7 files changed

+136
-29
lines changed

7 files changed

+136
-29
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
99
### Fixed
1010
- Exclude Python cache files from PyPI releases.
1111

12+
### Added
13+
- Added new options to require certain claims
14+
(require_nbf, require_iat, require_exp) and raise `MissingRequiredClaimError`
15+
if they are not present.
16+
- If `audience=` or `issuer=` is specified but the claim is not present,
17+
`MissingRequiredClaimError` is now raised instead of `InvalidAudienceError`
18+
and `InvalidIssuerError`
1219

1320
[v1.3][1.3.0]
1421
-------------------------------------------------------------------------

README.md

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ You can still get the payload by setting the `verify` argument to `False`.
5555
{u'some': u'payload'}
5656
```
5757

58-
The `decode()` function can raise other exceptions, e.g. for invalid issuer or
59-
audience (see below). All exceptions that signify that the token is invalid
60-
extend from the base `InvalidTokenError` exception class, so applications can
61-
use this approach to catch any issues relating to invalid tokens:
58+
## Validation
59+
Exceptions can be raised during `decode()` for other errors besides an
60+
invalid signature (e.g. for invalid issuer or audience (see below). All
61+
exceptions that signify that the token is invalid extend from the base
62+
`InvalidTokenError` exception class, so applications can use this approach to
63+
catch any issues relating to invalid tokens:
6264

6365
```python
6466
try:
@@ -67,8 +69,9 @@ except jwt.InvalidTokenError:
6769
pass # do something sensible here, e.g. return HTTP 403 status code
6870
```
6971

70-
You may also override exception checking via an `options` dictionary. The default
71-
options are as follows:
72+
### Skipping Claim Verification
73+
You may also override claim verification via the `options` dictionary. The
74+
default options are:
7275

7376
```python
7477
options = {
@@ -77,24 +80,49 @@ options = {
7780
'verify_nbf': True,
7881
'verify_iat': True,
7982
'verify_aud': True
83+
'require_exp': False,
84+
'require_iat': False,
85+
'require_nbf': False
8086
}
8187
```
8288

83-
You can skip individual checks by passing an `options` dictionary with certain keys set to `False`.
84-
For example, if you want to verify the signature of a JWT that has already expired.
89+
You can skip validation of individual claims by passing an `options` dictionary
90+
with the "verify_<claim_name>" key set to `False` when you call `jwt.decode()`.
91+
For example, if you want to verify the signature of a JWT that has already
92+
expired, you could do so by setting `verify_exp` to `False`.
8593

8694
```python
8795
>>> options = {
88-
>>> 'verify_exp': True,
96+
>>> 'verify_exp': False,
8997
>>> }
9098

99+
>>> encoded = '...' # JWT with an expired exp claim
91100
>>> jwt.decode(encoded, 'secret', options=options)
92101
{u'some': u'payload'}
93102
```
94103

95-
**NOTE**: *Changing the default behavior is done at your own risk, and almost certainly will make your
96-
application less secure. Doing so should only be done with a very clear understanding of what you
97-
are doing.*
104+
**NOTE**: *Changing the default behavior is done at your own risk, and almost
105+
certainly will make your application less secure. Doing so should only be done
106+
with a very clear understanding of what you are doing.*
107+
108+
### Requiring Optional Claims
109+
In addition to skipping certain validations, you may also specify that certain
110+
optional claims are required by setting the appropriate `require_<claim_name>`
111+
option to True. If the claim is not present, PyJWT will raise a
112+
`jwt.exceptions.MissingRequiredClaimError`.
113+
114+
For instance, the following code would require that the token has a 'exp'
115+
claim and raise an error if it is not present:
116+
117+
```python
118+
>>> options = {
119+
>>> 'require_exp': True
120+
>>> }
121+
122+
>>> encoded = '...' # JWT without an exp claim
123+
>>> jwt.decode(encoded, 'secret', options=options)
124+
jwt.exceptions.MissingRequiredClaimError: Token is missing the "exp" claim
125+
```
98126

99127
## Tests
100128

jwt/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@
2424
from .exceptions import (
2525
InvalidTokenError, DecodeError, InvalidAudienceError,
2626
ExpiredSignatureError, ImmatureSignatureError, InvalidIssuedAtError,
27-
InvalidIssuerError, ExpiredSignature, InvalidAudience, InvalidIssuer
27+
InvalidIssuerError, ExpiredSignature, InvalidAudience, InvalidIssuer,
28+
MissingRequiredClaimError
2829
)

jwt/api_jwt.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .exceptions import (
1212
DecodeError, ExpiredSignatureError, ImmatureSignatureError,
1313
InvalidAudienceError, InvalidIssuedAtError,
14-
InvalidIssuerError
14+
InvalidIssuerError, MissingRequiredClaimError
1515
)
1616
from .utils import merge_dict
1717

@@ -27,7 +27,10 @@ def _get_default_options():
2727
'verify_nbf': True,
2828
'verify_iat': True,
2929
'verify_aud': True,
30-
'verify_iss': True
30+
'verify_iss': True,
31+
'require_exp': False,
32+
'require_iat': False,
33+
'require_nbf': False
3134
}
3235

3336
def encode(self, payload, key, algorithm='HS256', headers=None,
@@ -87,6 +90,8 @@ def _validate_claims(self, payload, options, audience=None, issuer=None,
8790
if not isinstance(audience, (string_types, type(None))):
8891
raise TypeError('audience must be a string or None')
8992

93+
self._validate_required_claims(payload, options)
94+
9095
now = timegm(datetime.utcnow().utctimetuple())
9196

9297
if 'iat' in payload and options.get('verify_iat'):
@@ -104,6 +109,16 @@ def _validate_claims(self, payload, options, audience=None, issuer=None,
104109
if options.get('verify_aud'):
105110
self._validate_aud(payload, audience)
106111

112+
def _validate_required_claims(self, payload, options):
113+
if options.get('require_exp') and payload.get('exp') is None:
114+
raise MissingRequiredClaimError('exp')
115+
116+
if options.get('require_iat') and payload.get('iat') is None:
117+
raise MissingRequiredClaimError('iat')
118+
119+
if options.get('require_nbf') and payload.get('nbf') is None:
120+
raise MissingRequiredClaimError('nbf')
121+
107122
def _validate_iat(self, payload, now, leeway):
108123
try:
109124
iat = int(payload['iat'])
@@ -140,7 +155,7 @@ def _validate_aud(self, payload, audience):
140155
if audience is not None and 'aud' not in payload:
141156
# Application specified an audience, but it could not be
142157
# verified since the token does not contain a claim.
143-
raise InvalidAudienceError('No audience claim in token')
158+
raise MissingRequiredClaimError('aud')
144159

145160
audience_claims = payload['aud']
146161

@@ -158,7 +173,7 @@ def _validate_iss(self, payload, issuer):
158173
return
159174

160175
if 'iss' not in payload:
161-
raise InvalidIssuerError('Token does not contain an iss claim')
176+
raise MissingRequiredClaimError('iss')
162177

163178
if payload['iss'] != issuer:
164179
raise InvalidIssuerError('Invalid issuer')

jwt/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ class InvalidAlgorithmError(InvalidTokenError):
3434
pass
3535

3636

37+
class MissingRequiredClaimError(InvalidTokenError):
38+
def __init__(self, claim):
39+
self.claim = claim
40+
41+
def __str__(self):
42+
return 'Token is missing the "%s" claim' % self.claim
43+
44+
3745
# Compatibility aliases (deprecated)
3846
ExpiredSignature = ExpiredSignatureError
3947
InvalidAudience = InvalidAudienceError

tests/test_api_jwt.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from jwt.api_jwt import PyJWT
1010
from jwt.exceptions import (
1111
DecodeError, ExpiredSignatureError, ImmatureSignatureError,
12-
InvalidAudienceError, InvalidIssuedAtError, InvalidIssuerError
12+
InvalidAudienceError, InvalidIssuedAtError, InvalidIssuerError,
13+
MissingRequiredClaimError
1314
)
1415

1516
import pytest
@@ -317,15 +318,31 @@ def test_raise_exception_invalid_audience_in_array(self, jwt):
317318
with pytest.raises(InvalidAudienceError):
318319
jwt.decode(token, 'secret', audience='urn:me')
319320

321+
def test_raise_exception_token_without_issuer(self, jwt):
322+
issuer = 'urn:wrong'
323+
324+
payload = {
325+
'some': 'payload'
326+
}
327+
328+
token = jwt.encode(payload, 'secret')
329+
330+
with pytest.raises(MissingRequiredClaimError) as exc:
331+
jwt.decode(token, 'secret', issuer=issuer)
332+
333+
assert exc.value.claim == 'iss'
334+
320335
def test_raise_exception_token_without_audience(self, jwt):
321336
payload = {
322337
'some': 'payload',
323338
}
324339
token = jwt.encode(payload, 'secret')
325340

326-
with pytest.raises(InvalidAudienceError):
341+
with pytest.raises(MissingRequiredClaimError) as exc:
327342
jwt.decode(token, 'secret', audience='urn:me')
328343

344+
assert exc.value.claim == 'aud'
345+
329346
def test_check_issuer_when_valid(self, jwt):
330347
issuer = 'urn:foo'
331348
payload = {
@@ -348,33 +365,57 @@ def test_raise_exception_invalid_issuer(self, jwt):
348365
with pytest.raises(InvalidIssuerError):
349366
jwt.decode(token, 'secret', issuer=issuer)
350367

351-
def test_raise_exception_token_without_issuer(self, jwt):
352-
issuer = 'urn:wrong'
368+
def test_skip_check_audience(self, jwt):
369+
payload = {
370+
'some': 'payload',
371+
'aud': 'urn:me',
372+
}
373+
token = jwt.encode(payload, 'secret')
374+
jwt.decode(token, 'secret', options={'verify_aud': False})
353375

376+
def test_skip_check_exp(self, jwt):
354377
payload = {
355378
'some': 'payload',
379+
'exp': datetime.utcnow() - timedelta(days=1)
356380
}
381+
token = jwt.encode(payload, 'secret')
382+
jwt.decode(token, 'secret', options={'verify_exp': False})
357383

384+
def test_decode_should_raise_error_if_exp_required_but_not_present(self, jwt):
385+
payload = {
386+
'some': 'payload',
387+
# exp not present
388+
}
358389
token = jwt.encode(payload, 'secret')
359390

360-
with pytest.raises(InvalidIssuerError):
361-
jwt.decode(token, 'secret', issuer=issuer)
391+
with pytest.raises(MissingRequiredClaimError) as exc:
392+
jwt.decode(token, 'secret', options={'require_exp': True})
362393

363-
def test_skip_check_audience(self, jwt):
394+
assert exc.value.claim == 'exp'
395+
396+
def test_decode_should_raise_error_if_iat_required_but_not_present(self, jwt):
364397
payload = {
365398
'some': 'payload',
366-
'aud': 'urn:me',
399+
# iat not present
367400
}
368401
token = jwt.encode(payload, 'secret')
369-
jwt.decode(token, 'secret', options={'verify_aud': False})
370402

371-
def test_skip_check_exp(self, jwt):
403+
with pytest.raises(MissingRequiredClaimError) as exc:
404+
jwt.decode(token, 'secret', options={'require_iat': True})
405+
406+
assert exc.value.claim == 'iat'
407+
408+
def test_decode_should_raise_error_if_nbf_required_but_not_present(self, jwt):
372409
payload = {
373410
'some': 'payload',
374-
'exp': datetime.utcnow() - timedelta(days=1)
411+
# nbf not present
375412
}
376413
token = jwt.encode(payload, 'secret')
377-
jwt.decode(token, 'secret', options={'verify_exp': False})
414+
415+
with pytest.raises(MissingRequiredClaimError) as exc:
416+
jwt.decode(token, 'secret', options={'require_nbf': True})
417+
418+
assert exc.value.claim == 'nbf'
378419

379420
def test_skip_check_signature(self, jwt):
380421
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"

tests/test_exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from jwt.exceptions import MissingRequiredClaimError
2+
3+
4+
def test_missing_required_claim_error_has_proper_str():
5+
exc = MissingRequiredClaimError('abc')
6+
7+
assert str(exc) == 'Token is missing the "abc" claim'

0 commit comments

Comments
 (0)