Skip to content

Commit

Permalink
Asymmetric JWT support
Browse files Browse the repository at this point in the history
  • Loading branch information
nasthagiri committed Jul 25, 2018
1 parent 792c204 commit 31acd4e
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 81 deletions.
2 changes: 1 addition & 1 deletion edx_rest_framework_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
""" edx Django REST Framework extensions. """

__version__ = '1.5.3' # pragma: no cover
__version__ = '1.5.4' # pragma: no cover
119 changes: 71 additions & 48 deletions edx_rest_framework_extensions/jwt_decoder.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
""" JWT decoder utility functions. """
import logging
import sys

from jwkest.jwk import KEYS
from jwkest.jws import JWS
import jwt
from semantic_version import Version
from django.conf import settings
from rest_framework_jwt.settings import api_settings

from edx_rest_framework_extensions.settings import get_jwt_issuers
from edx_rest_framework_extensions.settings import get_jwt_issuers, get_first_jwt_issuer

logger = logging.getLogger(__name__)

Expand All @@ -26,8 +29,7 @@ def jwt_decode_handler(token):
Notes:
* Requires "exp" and "iat" claims to be present in the token's payload.
* Supports multiple issuer decoding via settings.JWT_AUTH['JWT_ISSUERS'] (see below)
* Aids debugging by logging DecodeError and InvalidTokenError log entries when decoding fails.
* Aids debugging by logging InvalidTokenError log entries when decoding fails.
Examples:
Use with `djangorestframework-jwt <https://getblimp.github.io/django-rest-framework-jwt/>`_, by changing
Expand All @@ -40,25 +42,9 @@ def jwt_decode_handler(token):
'JWT_ISSUER': 'https://the.jwt.issuer',
'JWT_SECRET_KEY': 'the-jwt-secret-key', (defaults to settings.SECRET_KEY)
'JWT_AUDIENCE': 'the-jwt-audience',
'JWT_PUBLIC_SIGNING_JWK_SET': 'the-jwk-set-of-public-signing-keys',
}
Enable multi-issuer support by specifying a list of dictionaries as settings.JWT_AUTH['JWT_ISSUERS']:
.. code-block:: python
JWT_ISSUERS = [
{
'ISSUER': 'test-issuer-1',
'SECRET_KEY': 'test-secret-key-1',
'AUDIENCE': 'test-audience-1',
},
{
'ISSUER': 'test-issuer-2',
'SECRET_KEY': 'test-secret-key-2',
'AUDIENCE': 'test-audience-2',
}
]
Args:
token (str): JWT to be decoded.
Expand All @@ -69,34 +55,10 @@ def jwt_decode_handler(token):
MissingRequiredClaimError: Either the exp or iat claims is missing from the JWT payload.
InvalidTokenError: Decoding fails.
"""

options = {
'verify_exp': api_settings.JWT_VERIFY_EXPIRATION,
'verify_aud': settings.JWT_AUTH.get('JWT_VERIFY_AUDIENCE', True),
'require_exp': True,
'require_iat': True,
}

for jwt_issuer in get_jwt_issuers():
try:
decoded_token = jwt.decode(
token,
jwt_issuer['SECRET_KEY'],
api_settings.JWT_VERIFY,
options=options,
leeway=api_settings.JWT_LEEWAY,
audience=jwt_issuer['AUDIENCE'],
issuer=jwt_issuer['ISSUER'],
algorithms=[api_settings.JWT_ALGORITHM]
)
return _set_token_defaults(decoded_token)
except jwt.InvalidTokenError:
msg = "Token decode failed for issuer '{issuer}'".format(issuer=jwt_issuer['ISSUER'])
logger.info(msg, exc_info=True)

msg = 'All combinations of JWT issuers and secret keys failed to validate the token.'
logger.error(msg)
raise jwt.InvalidTokenError(msg)
jwt_issuer = get_first_jwt_issuer()
_verify_jwt_signature(token, jwt_issuer)
decoded_token = _decode_and_verify_token(token, jwt_issuer)
return _set_token_defaults(decoded_token)


def decode_jwt_scopes(token):
Expand Down Expand Up @@ -169,3 +131,64 @@ def _set_filters(token, token_version):
_set_is_restricted(token, token_version)
_set_filters(token, token_version)
return token


def _verify_jwt_signature(token, jwt_issuer):
key_set = _get_signing_jwk_key_set(jwt_issuer)

try:
_ = JWS().verify_compact(token, key_set)
except Exception as exc:
logger.exception('Token verification failed.')
exc_info = sys.exc_info()
raise jwt.InvalidTokenError(exc_info[2])


def _decode_and_verify_token(token, jwt_issuer):
options = {
'require_exp': True,
'require_iat': True,

'verify_exp': api_settings.JWT_VERIFY_EXPIRATION,
'verify_aud': settings.JWT_AUTH.get('JWT_VERIFY_AUDIENCE', True),
'verify_iss': False, # TODO (ARCH-204): manually verify until issuer is configured correctly.
'verify_signature': False, # Verified with JWS already
}

decoded_token = jwt.decode(
token,
jwt_issuer['SECRET_KEY'],
api_settings.JWT_VERIFY,
options=options,
leeway=api_settings.JWT_LEEWAY,
audience=jwt_issuer['AUDIENCE'],
issuer=jwt_issuer['ISSUER'],
algorithms=[api_settings.JWT_ALGORITHM],
)

# TODO (ARCH-204): verify issuer manually until it is properly configured.
token_issuer = decoded_token.get('iss')
issuer_matched = any(issuer['ISSUER'] == token_issuer for issuer in get_jwt_issuers())
if not issuer_matched:
logger.info('Token decode failed due to mismatched issuer [%s]', token_issuer)
raise jwt.InvalidTokenError('%s is not a valid issuer.', token_issuer)

return decoded_token


def _get_signing_jwk_key_set(jwt_issuer):
"""
Returns a JWK Keyset containing all active keys that are configured
for verifying signatures.
"""
key_set = KEYS()

# asymmetric keys
signing_jwk_set = settings.JWT_AUTH.get('JWT_PUBLIC_SIGNING_JWK_SET')
if signing_jwk_set:
key_set.load_jwks(signing_jwk_set)

# symmetric key
key_set.add({'key': jwt_issuer['SECRET_KEY'], 'kty': 'oct'})

return key_set
14 changes: 14 additions & 0 deletions edx_rest_framework_extensions/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""
NOTE: Support for multiple JWT_ISSUERS is deprecated. Instead, Asymmetric JWTs
make this simpler by using JWK keysets to list all available public keys.
Settings for edx-drf-extensions are all namespaced in the EDX_DRF_EXTENSIONS setting.
For example your project's `settings.py` file might look like this:
Expand Down Expand Up @@ -81,3 +84,14 @@ def get_jwt_issuers():
return jwt_issuers
# If we do not, return the deprecated configuration
return _get_deprecated_jwt_issuers()


def get_first_jwt_issuer():
"""
Retrieves the first issuer in the JWT_ISSUERS list.
As mentioned above, support for multiple JWT_ISSUERS is deprecated. They
are currently used only to distinguish the "ISSUER" field across sites.
So in many cases, we just need the first issuer value.
"""
return get_jwt_issuers()[0]
30 changes: 2 additions & 28 deletions edx_rest_framework_extensions/tests/test_jwt_decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,7 @@ def test_failure_invalid_issuer(self):
# which will fail with an InvalidTokenError
jwt_decode_handler(token)

# Verify that the proper entries were written to the log file
msg = "Token decode failed for issuer 'test-issuer-1'"
patched_log.info.assert_any_call(msg, exc_info=True)

msg = "Token decode failed for issuer 'test-issuer-2'"
patched_log.info.assert_any_call(msg, exc_info=True)

msg = "All combinations of JWT issuers and secret keys failed to validate the token."
patched_log.error.assert_any_call(msg)
patched_log.exception.assert_any_call("Token verification failed.")

def test_failure_invalid_token(self):
"""
Expand All @@ -150,15 +142,7 @@ def test_failure_invalid_token(self):
# Attempt to decode an invalid token, which will fail with an InvalidTokenError
jwt_decode_handler("invalid.token")

# Verify that the proper entries were written to the log file
msg = "Token decode failed for issuer 'test-issuer-1'"
patched_log.info.assert_any_call(msg, exc_info=True)

msg = "Token decode failed for issuer 'test-issuer-2'"
patched_log.info.assert_any_call(msg, exc_info=True)

msg = "All combinations of JWT issuers and secret keys failed to validate the token."
patched_log.error.assert_any_call(msg)
patched_log.exception.assert_any_call("Token verification failed.")

@override_settings(JWT_AUTH=exclude_from_jwt_auth_setting('JWT_SUPPORTED_VERSION'))
def test_supported_jwt_version_not_specified(self):
Expand Down Expand Up @@ -188,19 +172,9 @@ def test_unsupported_jwt_version(self):
token = _generate_jwt_token(self.payload)
jwt_decode_handler(token)

# Verify that the proper entries were written to the log file
msg = "Token decode failed due to unsupported JWT version number [%s]"
patched_log.info.assert_any_call(msg, '1.1.0')

msg = "Token decode failed for issuer 'test-issuer-1'"
patched_log.info.assert_any_call(msg, exc_info=True)

msg = "Token decode failed for issuer 'test-issuer-2'"
patched_log.info.assert_any_call(msg, exc_info=True)

msg = "All combinations of JWT issuers and secret keys failed to validate the token."
patched_log.error.assert_any_call(msg)

def test_upgrade(self):
"""
Verifies the JWT is upgraded when an old (starting) version is provided.
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
'django-waffle',
'edx-opaque-keys',
'semantic_version',
'pyjwkest==1.3.2',
'python-dateutil>=2.0',
'requests>=2.7.0,<3.0.0',
'rest-condition>=1.0.3,<2.0',
Expand Down
1 change: 1 addition & 0 deletions test_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ mock>=1.3.0,<2.0.0
pep8>=1.7.0,<2.0.0
tox>=2.3.1,<3.0.0
git+https://github.com/edx/django-rest-framework.git@1ceda7c086fddffd1c440cc86856441bbf0bd9cb#egg=djangorestframework==3.6.3
pyjwkest==1.3.2
10 changes: 6 additions & 4 deletions test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,18 @@
'JWT_VERIFY_EXPIRATION': True,

# JWT_ISSUERS enables token decoding for multiple issuers (Note: This is not a native DRF-JWT field)
# We use it to allow different values for the 'ISSUER' field, but keep the same SECRET_KEY and
# AUDIENCE values across all issuers.
'JWT_ISSUERS': [
{
'ISSUER': 'test-issuer-1',
'SECRET_KEY': 'test-secret-key-1',
'AUDIENCE': 'test-audience-1',
'SECRET_KEY': 'test-secret-key',
'AUDIENCE': 'test-audience',
},
{
'ISSUER': 'test-issuer-2',
'SECRET_KEY': 'test-secret-key-2',
'AUDIENCE': 'test-audience-2',
'SECRET_KEY': 'test-secret-key',
'AUDIENCE': 'test-audience',
}
],
}

0 comments on commit 31acd4e

Please sign in to comment.