mpdavis: It looks like there are builds failing on Python 2.6 and 3.3.

Python 2.6 is failing because pytest dropped support. It looks like we will need to pin pytest in `tox.ini` for 2.6 builds (or possibly just all builds if easier).

I am still looking into the 3.3 failure. I can take a look later if you don't want to worry about it. 
leplatrem: I also saw this:
```
$ tox

Matching undeclared envs is deprecated. Be sure all the envs that Tox should run are declared in the tox config.
```
diff --git a/jose/jwt.py b/jose/jwt.py
index 2da511f..3ba3250 100644
--- a/jose/jwt.py
+++ b/jose/jwt.py
@@ -408,24 +408,28 @@ def _validate_jti(claims):
 
 def _validate_at_hash(claims, access_token, algorithm):
     """
-    Validates that the 'at_hash' parameter included in the claims matches
-    with the access_token returned alongside the id token as part of
-    the authorization_code flow.
+    Validates that the 'at_hash' is valid.
+
+    Its value is the base64url encoding of the left-most half of the hash
+    of the octets of the ASCII representation of the access_token value,
+    where the hash algorithm used is the hash algorithm used in the alg
+    Header Parameter of the ID Token's JOSE Header. For instance, if the
+    alg is RS256, hash the access_token value with SHA-256, then take the
+    left-most 128 bits and base64url encode them. The at_hash value is a
+    case sensitive string.  Use of this claim is OPTIONAL.
 
     Args:
-        claims (dict): The claims dictionary to validate.
-        access_token (str): The access token returned by the OpenID Provider.
-        algorithm (str): The algorithm used to sign the JWT, as specified by
-            the token headers.
+      claims (dict): The claims dictionary to validate.
+      access_token (str): The access token returned by the OpenID Provider.
+      algorithm (str): The algorithm used to sign the JWT, as specified by
+          the token headers.
     """
-    if 'at_hash' not in claims and not access_token:
+    if 'at_hash' not in claims:
         return
-    elif 'at_hash' in claims and not access_token:
+
+    if not access_token:
         msg = 'No access_token provided to compare against at_hash claim.'
         raise JWTClaimsError(msg)
-    elif access_token and 'at_hash' not in claims:
-        msg = 'at_hash claim missing from token.'
-        raise JWTClaimsError(msg)
 
     try:
         expected_hash = calculate_at_hash(access_token,
@@ -433,7 +437,7 @@ def _validate_at_hash(claims, access_token, algorithm):
     except (TypeError, ValueError):
         msg = 'Unable to calculate at_hash to verify against token claims.'
         raise JWTClaimsError(msg)
-        
+
     if claims['at_hash'] != expected_hash:
         raise JWTClaimsError('at_hash claim does not match access_token.')
 

Should at_hash claim verification fail when missing from JWT?
It looks like `at_hash` in JWT payload is optional (see http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken).

However, in python-jose, when both `id_token` and `access_token` parameters are specified, decoding a JWT that has no `at_hash` claim raises an error (*at_hash claim missing from token*)
https://github.com/mpdavis/python-jose/pull/30/files#diff-b106d01229785c64375df96ca4b3f58cR422

Shouldn't it be acceptable since the spec says it's optional?

Obviously we can disable at_hash verification with the appropriate decode option, but we find it useful to perform claims verification on JWT that have it or not with the same code. Maybe with a `allow_missing_at_hash` option or something?

Huge thanks for this lib 😻 
diff --git a/tests/test_jwt.py b/tests/test_jwt.py
index 485fff5..beb6789 100644
--- a/tests/test_jwt.py
+++ b/tests/test_jwt.py
@@ -468,8 +468,8 @@ class TestJWT:
 
     def test_at_hash_missing_claim(self, claims, key):
         token = jwt.encode(claims, key)
-        with pytest.raises(JWTError):
-            jwt.decode(token, key, access_token='<ACCESS_TOKEN>')
+        payload = jwt.decode(token, key, access_token='<ACCESS_TOKEN>')
+        assert 'at_hash' not in payload
 
     def test_at_hash_unable_to_calculate(self, claims, key):
         token = jwt.encode(claims, key, access_token='<ACCESS_TOKEN>')

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov
apt-get update apt-get install -y gcc
3.6
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==22.2.0
certifi==2021.5.30
coverage==6.2
ecdsa==0.19.1
future==0.18.3
importlib-metadata==4.8.3
iniconfig==1.1.1
packaging==21.3
pluggy==1.0.0
py==1.11.0
pycrypto==2.6.1
pycryptodome==3.21.0
pyparsing==3.1.4
pytest==7.0.1
pytest-cov==4.0.0
-e git+https://github.com/mpdavis/python-jose.git@28cc6719eceb89129eed59c25f7bdac015665bdd#egg=python_jose
six==1.17.0
tomli==1.2.3
typing_extensions==4.1.1
zipp==3.6.0

name: python-jose
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==22.2.0
      - coverage==6.2
      - ecdsa==0.19.1
      - future==0.18.3
      - importlib-metadata==4.8.3
      - iniconfig==1.1.1
      - packaging==21.3
      - pluggy==1.0.0
      - py==1.11.0
      - pycrypto==2.6.1
      - pycryptodome==3.21.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytest-cov==4.0.0
      - six==1.17.0
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - zipp==3.6.0
prefix: /opt/conda/envs/python-jose

tests/test_jwt.py::TestJWT::test_at_hash_missing_claim
tests/test_jwt.py::TestJWT::test_non_default_alg tests/test_jwt.py::TestJWT::test_non_default_alg_positional_bwcompat tests/test_jwt.py::TestJWT::test_non_default_headers tests/test_jwt.py::TestJWT::test_encode tests/test_jwt.py::TestJWT::test_decode tests/test_jwt.py::TestJWT::test_leeway_is_int tests/test_jwt.py::TestJWT::test_leeway_is_timedelta tests/test_jwt.py::TestJWT::test_iat_not_int tests/test_jwt.py::TestJWT::test_nbf_not_int tests/test_jwt.py::TestJWT::test_nbf_datetime tests/test_jwt.py::TestJWT::test_nbf_with_leeway tests/test_jwt.py::TestJWT::test_nbf_in_future tests/test_jwt.py::TestJWT::test_nbf_skip tests/test_jwt.py::TestJWT::test_exp_not_int tests/test_jwt.py::TestJWT::test_exp_datetime tests/test_jwt.py::TestJWT::test_exp_with_leeway tests/test_jwt.py::TestJWT::test_exp_in_past tests/test_jwt.py::TestJWT::test_exp_skip tests/test_jwt.py::TestJWT::test_aud_string tests/test_jwt.py::TestJWT::test_aud_list tests/test_jwt.py::TestJWT::test_aud_list_multiple tests/test_jwt.py::TestJWT::test_aud_list_is_strings tests/test_jwt.py::TestJWT::test_aud_case_sensitive tests/test_jwt.py::TestJWT::test_aud_empty_claim tests/test_jwt.py::TestJWT::test_aud_not_string_or_list tests/test_jwt.py::TestJWT::test_aud_given_number tests/test_jwt.py::TestJWT::test_iss_string tests/test_jwt.py::TestJWT::test_iss_list tests/test_jwt.py::TestJWT::test_iss_tuple tests/test_jwt.py::TestJWT::test_iss_invalid tests/test_jwt.py::TestJWT::test_sub_string tests/test_jwt.py::TestJWT::test_sub_invalid tests/test_jwt.py::TestJWT::test_sub_correct tests/test_jwt.py::TestJWT::test_sub_incorrect tests/test_jwt.py::TestJWT::test_jti_string tests/test_jwt.py::TestJWT::test_jti_invalid tests/test_jwt.py::TestJWT::test_at_hash tests/test_jwt.py::TestJWT::test_at_hash_invalid tests/test_jwt.py::TestJWT::test_at_hash_missing_access_token tests/test_jwt.py::TestJWT::test_at_hash_unable_to_calculate tests/test_jwt.py::TestJWT::test_unverified_claims_string tests/test_jwt.py::TestJWT::test_unverified_claims_list tests/test_jwt.py::TestJWT::test_unverified_claims_object
jose/jwt.py
jose/jwt.py