Skip to content

Commit

Permalink
Add multi key and key id support. (jpadilla#33)
Browse files Browse the repository at this point in the history
* Support multiple algorithms and keys

Existing code made key rollovers or algorithm changes hard and basically
required a breaking change: Once any of JWT_ALGORITHM, JWT_SECRET_KEY,
or JWT_PRIVATE_KEY/JWT_PUBLIC_KEY were changed, existing tokens were
rendered invalid.

We now support JWT_ALGORITHM, JWT_SECRET_KEY, and JWT_PUBLIC_KEY
optionally being a list, where all members are accepted as valid.

When JWT_SECRET_KEY is a list, the first member is used for signing and
all others are accepted for verification.

* Support multiple keys with key ids

The previous commit added support for multiple keys, which (despite
being useful as a fallback, if anything) implies multiple verification
attempts and thus is not computationally efficient.

We now support identifing keys by key id ("kid" header): When a JWT
carries a key id, we can identify immediately if it is known and only
need to make at most one verification attempt.

To configure keys with ids, JWT_SECRET_KEY, JWT_PRIVATE_KEY and
JWT_PUBLIC_KEY can now also be a dict in the form

	{ "kid1": key1, "kid2": key2, ... }

When a JWT does not carry a key id ("kid" header), the default is to
fall back to trying all keys if keys are named (defined as a dict).
Setting JWT_INSIST_ON_KID: True avoids this fallback and requires any
JWT to be validated to carry a key id _if_ key IDs are used

Closes jpadilla#31

* add changelog

* Ensure we always have RSA available

as suggested by fitodic here:
Styria-Digital#33 (comment)

* doc/changelog polishing suggested by Filip

* more docs polishing

* fix rsa key generation

* rename variables for clarity
  • Loading branch information
nigoroll committed Apr 23, 2020
1 parent 612ba1e commit b90368d
Show file tree
Hide file tree
Showing 6 changed files with 375 additions and 17 deletions.
42 changes: 42 additions & 0 deletions changelog.d/33.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
* Support multiple algorithms and keys

Existing code made key rollovers or algorithm changes hard and
basically required a breaking change: Once any of `JWT_ALGORITHM`,
`JWT_SECRET_KEY`, or `JWT_PRIVATE_KEY`/`JWT_PUBLIC_KEY` were
changed, existing tokens were rendered invalid.

We now support `JWT_ALGORITHM`, `JWT_SECRET_KEY`, and
`JWT_PUBLIC_KEY` optionally being a list, where all members are
accepted as valid.

When `JWT_SECRET_KEY` is a list, the first member is used for
signing and all others are accepted for verification.

* Support multiple keys with key ids

We also support identifing keys by key id (`kid` header): When a JWT
carries a key id, we can identify immediately if it is known and
only need to make at most one verification attempt.

To configure keys with ids, `JWT_SECRET_KEY`, `JWT_PRIVATE_KEY` and
`JWT_PUBLIC_KEY` can now also be a dict in the form

```
{ "kid1": key1, "kid2": key2, ... }
```

When a JWT does not carry a key id (`kid` header), the default is to
fall back to trying all keys if keys are named (defined as a dict).
Setting `JWT_INSIST_ON_KID: True` avoids this fallback and requires
any JWT to be validated to carry a key id _if_ key IDs are used

*NOTE: For python < 3.7, use a `collections.OrderedDict` object
instead of a dict*

* Require cryptographic dependencies of PyJWT

We changed the PyJWT requirement to include support for RSA by
default. This was done to improve the user experience, but will lead
to cryptography support be installed where not already present.

See: https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional
84 changes: 81 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,39 @@ This package uses the JSON Web Token Python implementation, [PyJWT](https://gith

This is the secret key used to sign the JWT. Make sure this is safe and not shared or public.

Can be a dict, a list or a scalar.

* When a dict, the dict keys are taken as the JWT key ids and the values as
keys, e.g.:

```python
{ "kid1": key1, "kid2": key2, ... }
```

The first element is used for signing.

If a JWT to be verified contains a key id (`kid` header), only the
key with that id is tried (if any).

*NOTE: For python < 3.7, use a `collections.OrderedDict` object*, e.g.:

```python
from collections import OrderedDict

JWT_AUTH["JWT_SECRET_KEY"] = OrderedDict(kid1=key1, kid2=key2, ...)
```

* When a list, all elements are accepted for verification and the
first element is used for signing.

* When a scalar, this secret is used for signing and verification.

(The first) `JWT_SECRET_KEY` is only used for signing if (the first)
`JWT_ALGORITHM` is `HS*`, otherwise `JWT_PRIVATE_KEY` is used.

`JWT_SECRET_KEY`(s) is/are only used for verification of JWTs with
`alg` matching `HS*`

Default is your project's `settings.SECRET_KEY`.

### JWT_GET_USER_SECRET_KEY
Expand All @@ -200,22 +233,67 @@ Default is `None`.

### JWT_PRIVATE_KEY

This is an object of type `cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey`. It will be used to sign the signature component of the JWT. It will override `JWT_SECRET_KEY` when set. Read the [documentation](https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey) for more details. Please note that `JWT_ALGORITHM` must be set to one of `RS256`, `RS384`, or `RS512`.
Can be a scalar or a dict.

When a dict, the dict key is taken as the JWT key id and the values as
the key, e.g.:

```python
{ "kid": key }
```

The scalar or the dict value must be in any [private key format supported by PyJWT](https://pyjwt.readthedocs.io/en/latest/algorithms.html), for example of the types

* `cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey`
* `cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey`

And will be used to sign the signature component of the JWT if `JWT_ALGORITHM` is set to any of the [supported algorithms](https://pyjwt.readthedocs.io/en/latest/algorithms.html) other than the hash types `HS*`.

Default is `None`.

### JWT_PUBLIC_KEY

This is an object of type `cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`. It will be used to verify the signature of the incoming JWT. It will override `JWT_SECRET_KEY` when set. Read the [documentation](https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey) for more details. Please note that `JWT_ALGORITHM` must be set to one of `RS256`, `RS384`, or `RS512`.
Can be a scalar, a list or a dict.

* When a dict, the dict keys are taken as the JWT key ids and the values as
keys, e.g.:

```python
{ "kid1": key1, "kid2": key2, ... }
```

If a JWT that contains a key id (kid header) is to be verified, only
the associated key is tried. Otherwise, or

* when a list, all of the elements will be accepted for verification of JWTs with `alg` being (any of) `JWT_ALGORITHM` not matching `HS*`.

The scalar or elements/values of the list/dict must be in any [public key format supported by PyJWT](https://pyjwt.readthedocs.io/en/latest/algorithms.html), for example of the types

* `cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
* `cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`

Default is `None`.

### JWT_ALGORITHM

Possible values are any of the [supported algorithms](https://github.com/jpadilla/pyjwt/blob/master/docs/algorithms.rst) for cryptographic signing in `PyJWT`.
Possible values are any of the [supported algorithms](https://pyjwt.readthedocs.io/en/latest/algorithms.html) for cryptographic signing in `PyJWT`.

Can be a scalar or a list.

* For a scalar, this algorithm is used for signing and verification.

* For a list, the first element is used for signing and all elements are accepted for verification.

Default is `"HS256"`.

### JWT_INSIST_ON_KID

When key IDs are used (`JWT_SECRET_KEY` and/or `JWT_PUBLIC_KEY` given
as a dict assigning key IDs to keys), insist that JWTs to be validated
have a `kid` header with a defined key.

Default is `False`.

### JWT_AUDIENCE

This is a string that will be checked against the `aud` field of the token, if present.
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ python_requires = >= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*
zip_safe = False
include_package_data = True
install_requires =
PyJWT>=1.5.2,<2.0.0
PyJWT[crypto]>=1.5.2,<2.0.0
Django>=1.11
djangorestframework>=3.7

Expand Down
1 change: 1 addition & 0 deletions src/rest_framework_jwt/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
'JWT_PRIVATE_KEY': None,
'JWT_PUBLIC_KEY': None,
'JWT_ALGORITHM': 'HS256',
'JWT_INSIST_ON_KID': False,
'JWT_AUDIENCE': None,
'JWT_ISSUER': None,
'JWT_ENCODE_HANDLER':
Expand Down
78 changes: 65 additions & 13 deletions src/rest_framework_jwt/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,23 @@ def jwt_get_username_from_payload_handler(payload):
def jwt_encode_payload(payload):
"""Encode JWT token claims."""

key = api_settings.JWT_PRIVATE_KEY or jwt_get_secret_key(payload)
return force_str(jwt.encode(payload, key, api_settings.JWT_ALGORITHM))
headers=None

signing_algorithm = api_settings.JWT_ALGORITHM
if isinstance(signing_algorithm,list):
signing_algorithm = signing_algorithm[0]
if signing_algorithm.startswith("HS"):
key = jwt_get_secret_key(payload)
else:
key = api_settings.JWT_PRIVATE_KEY

if isinstance(key, dict):
kid, key = next(iter(key.items()))
headers = {"kid": kid}
elif isinstance(key,list):
key = key[0]

return jwt.encode(payload, key, signing_algorithm, headers=headers).decode()


def jwt_decode_token(token):
Expand All @@ -117,17 +132,54 @@ def jwt_decode_token(token):
options = {
'verify_exp': api_settings.JWT_VERIFY_EXPIRATION,
}
# get user from token, BEFORE verification, to get user secret key
unverified_payload = jwt.decode(token, None, False)
secret_key = jwt_get_secret_key(unverified_payload)
return jwt.decode(
token, api_settings.JWT_PUBLIC_KEY or secret_key,
api_settings.JWT_VERIFY, options=options,
leeway=api_settings.JWT_LEEWAY, audience=api_settings.JWT_AUDIENCE,
issuer=api_settings.JWT_ISSUER, algorithms=[
api_settings.JWT_ALGORITHM
]
)

algorithms = api_settings.JWT_ALGORITHM
if not isinstance(algorithms, list):
algorithms = [algorithms]

hdr = jwt.get_unverified_header(token)
alg_hdr = hdr["alg"]
if alg_hdr not in algorithms:
raise jwt.exceptions.InvalidAlgorithmError

kid = hdr["kid"] if "kid" in hdr else None

keys = None
if alg_hdr.startswith("HS"):
unverified_payload = jwt.decode(token, None, False)
keys = jwt_get_secret_key(unverified_payload)
else:
keys = api_settings.JWT_PUBLIC_KEY

# if keys are named and the jwt has a kid, only consider exactly that key
# otherwise if the JWT has no kid, JWT_INSIST_ON_KID selects if we fail
# or try all defined keys
if isinstance(keys, dict):
if kid:
try:
keys = keys[kid]
except KeyError:
raise jwt.exceptions.InvalidKeyError
elif api_settings.JWT_INSIST_ON_KID:
raise jwt.exceptions.InvalidKeyError
else:
keys = list(keys.values())

if not isinstance(keys, list):
keys = [keys]

ex = None
for key in keys:
try:
return jwt.decode(
token, key, api_settings.JWT_VERIFY, options=options,
leeway=api_settings.JWT_LEEWAY,
audience=api_settings.JWT_AUDIENCE,
issuer=api_settings.JWT_ISSUER, algorithms=[alg_hdr]
)
except (jwt.exceptions.InvalidSignatureError) as e:
ex = e
raise ex


def jwt_create_response_payload(
Expand Down

0 comments on commit b90368d

Please sign in to comment.