Hello community,

here is the log from the commit of package python-PyFxA for openSUSE:Factory 
checked in at 2020-07-14 07:56:32
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-PyFxA (Old)
 and      /work/SRC/openSUSE:Factory/.python-PyFxA.new.3060 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-PyFxA"

Tue Jul 14 07:56:32 2020 rev:9 rq:820420 version:0.7.6

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-PyFxA/python-PyFxA.changes        
2020-05-26 17:18:23.327854645 +0200
+++ /work/SRC/openSUSE:Factory/.python-PyFxA.new.3060/python-PyFxA.changes      
2020-07-14 07:58:54.901716865 +0200
@@ -1,0 +2,23 @@
+Sat Jul 11 13:33:33 UTC 2020 - Antoine Belvire <[email protected]>
+
+- Update to version 0.7.6:
+  * Add ability to configure a fixed list of JWT access token keys,
+    by passing them as an argument to `oauth.Client()` rather than
+    fetching them at runtime from the server.
+  * Fix verification of JWT access token `typ` header.
+  * Fix verification of `scope` list obtained from a JWT access
+    token.
+- Changes from version 0.7.5:
+  * Add support for `reason` and `verification_method` keyword
+    arguments to the `login` method.
+- Changes from version 0.7.4:
+  * Perform more complete checking of the `state` parameter when
+    authorizing an OAuth code.
+  * When verifying OAuth access tokens, try to verify them locally
+    as a JWT rather than passing them to the remote verification
+    endpoint.
+- Add new dependency: PyJWT.
+- Update existing dependency: six >= 1.14.
+- Update list of excluded tests.
+
+-------------------------------------------------------------------

Old:
----
  PyFxA-0.7.3.tar.gz

New:
----
  PyFxA-0.7.6.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-PyFxA.spec ++++++
--- /var/tmp/diff_new_pack.NupYfL/_old  2020-07-14 07:58:55.653719299 +0200
+++ /var/tmp/diff_new_pack.NupYfL/_new  2020-07-14 07:58:55.653719299 +0200
@@ -19,7 +19,7 @@
 
 %{?!python_module:%define python_module() python-%{**} python3-%{**}}
 Name:           python-PyFxA
-Version:        0.7.3
+Version:        0.7.6
 Release:        0
 Summary:        Firefox Accounts client library for Python
 License:        MPL-2.0
@@ -27,6 +27,7 @@
 URL:            https://github.com/mozilla/PyFxA
 Source:         
https://files.pythonhosted.org/packages/source/P/PyFxA/PyFxA-%{version}.tar.gz
 BuildRequires:  %{python_module PyBrowserID}
+BuildRequires:  %{python_module PyJWT}
 BuildRequires:  %{python_module cryptography}
 BuildRequires:  %{python_module hawkauthlib}
 BuildRequires:  %{python_module mock}
@@ -35,13 +36,13 @@
 BuildRequires:  %{python_module requests >= 2.4.2}
 BuildRequires:  %{python_module responses}
 BuildRequires:  %{python_module setuptools}
-BuildRequires:  %{python_module six}
+BuildRequires:  %{python_module six >= 1.14}
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros
 Requires:       python-PyBrowserID
 Requires:       python-cryptography
 Requires:       python-requests >= 2.4.2
-Requires:       python-six
+Requires:       python-six >= 1.14
 Requires(post): update-alternatives
 Requires(postun): update-alternatives
 BuildArch:      noarch
@@ -56,8 +57,6 @@
 %prep
 %setup -q -n PyFxA-%{version}
 sed -i -e '/^#!\/usr\/bin\/env python/d' fxa/__main__.py
-# Remove online tests
-rm -f fxa/tests/test_core.py
 find ./ -type f -exec chmod -x {} +
 
 %build
@@ -69,8 +68,17 @@
 %python_expand %fdupes %{buildroot}%{$python_sitelib}
 
 %check
-# test_monkey_patch_for_gevent gevent no longer packaged as it is deprecated
-%pytest -k 'not test_monkey_patch_for_gevent' fxa/tests/
+# Exclude tests which require network connection +
+# deprecated test_monkey_patch_for_gevent
+includedTests='\
+  not TestAuthClientAuthorizeToken and\
+  not TestAuthClientVerifyCode and\
+  not TestCachedClient and\
+  not TestCoreClient and\
+  not TestCoreClientSession and\
+  not TestJwtToken and\
+  not test_monkey_patch_for_gevent'
+%pytest -k "${includedTests}" fxa/tests/
 
 %post
 %python_install_alternative fxa-client

++++++ PyFxA-0.7.3.tar.gz -> PyFxA-0.7.6.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/CHANGES.txt new/PyFxA-0.7.6/CHANGES.txt
--- old/PyFxA-0.7.3/CHANGES.txt 2019-07-26 01:01:01.000000000 +0200
+++ new/PyFxA-0.7.6/CHANGES.txt 2020-07-10 01:59:18.000000000 +0200
@@ -3,6 +3,34 @@
 
 This document describes changes between each past release.
 
+0.7.6 (2020-07-10)
+==================
+
+- Add ability to configure a fixed list of JWT access token keys,
+  by passing them as an argument to `oauth.Client()` rather than
+  fetching them at runtime from the server.
+- Fix verification of JWT access token `typ` header.
+  (Thankfully it was failing closed rather than failing open).
+- Fix verification of `scope` list obtained from a JWT access token.
+  (Thankfully it was failing closed rather than failing open).
+
+
+0.7.5 (2020-07-06)
+==================
+
+- Add support for `reason` and `verification_method` keyword arguments
+  to the `login` method.
+
+
+0.7.4 (2020-06-10)
+==================
+
+- Perform more complete checking of the `state` parameter when authorizing
+  an OAuth code.
+- When verifying OAuth access tokens, try to verify them locally as a JWT
+  rather than passing them to the remote verification endpoint.
+
+
 0.7.3 (2019-07-26)
 ==================
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/PKG-INFO new/PyFxA-0.7.6/PKG-INFO
--- old/PyFxA-0.7.3/PKG-INFO    2019-07-26 01:03:29.000000000 +0200
+++ new/PyFxA-0.7.6/PKG-INFO    2020-07-10 02:00:54.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: PyFxA
-Version: 0.7.3
+Version: 0.7.6
 Summary: Firefox Accounts client library for Python
 Home-page: https://github.com/mozilla/PyFxA
 Author: Mozilla Services
@@ -316,6 +316,34 @@
         
         This document describes changes between each past release.
         
+        0.7.6 (2020-07-10)
+        ==================
+        
+        - Add ability to configure a fixed list of JWT access token keys,
+          by passing them as an argument to `oauth.Client()` rather than
+          fetching them at runtime from the server.
+        - Fix verification of JWT access token `typ` header.
+          (Thankfully it was failing closed rather than failing open).
+        - Fix verification of `scope` list obtained from a JWT access token.
+          (Thankfully it was failing closed rather than failing open).
+        
+        
+        0.7.5 (2020-07-06)
+        ==================
+        
+        - Add support for `reason` and `verification_method` keyword arguments
+          to the `login` method.
+        
+        
+        0.7.4 (2020-06-10)
+        ==================
+        
+        - Perform more complete checking of the `state` parameter when 
authorizing
+          an OAuth code.
+        - When verifying OAuth access tokens, try to verify them locally as a 
JWT
+          rather than passing them to the remote verification endpoint.
+        
+        
         0.7.3 (2019-07-26)
         ==================
         
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/PyFxA.egg-info/PKG-INFO 
new/PyFxA-0.7.6/PyFxA.egg-info/PKG-INFO
--- old/PyFxA-0.7.3/PyFxA.egg-info/PKG-INFO     2019-07-26 01:03:29.000000000 
+0200
+++ new/PyFxA-0.7.6/PyFxA.egg-info/PKG-INFO     2020-07-10 02:00:54.000000000 
+0200
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: PyFxA
-Version: 0.7.3
+Version: 0.7.6
 Summary: Firefox Accounts client library for Python
 Home-page: https://github.com/mozilla/PyFxA
 Author: Mozilla Services
@@ -316,6 +316,34 @@
         
         This document describes changes between each past release.
         
+        0.7.6 (2020-07-10)
+        ==================
+        
+        - Add ability to configure a fixed list of JWT access token keys,
+          by passing them as an argument to `oauth.Client()` rather than
+          fetching them at runtime from the server.
+        - Fix verification of JWT access token `typ` header.
+          (Thankfully it was failing closed rather than failing open).
+        - Fix verification of `scope` list obtained from a JWT access token.
+          (Thankfully it was failing closed rather than failing open).
+        
+        
+        0.7.5 (2020-07-06)
+        ==================
+        
+        - Add support for `reason` and `verification_method` keyword arguments
+          to the `login` method.
+        
+        
+        0.7.4 (2020-06-10)
+        ==================
+        
+        - Perform more complete checking of the `state` parameter when 
authorizing
+          an OAuth code.
+        - When verifying OAuth access tokens, try to verify them locally as a 
JWT
+          rather than passing them to the remote verification endpoint.
+        
+        
         0.7.3 (2019-07-26)
         ==================
         
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/PyFxA.egg-info/requires.txt 
new/PyFxA-0.7.6/PyFxA.egg-info/requires.txt
--- old/PyFxA-0.7.3/PyFxA.egg-info/requires.txt 2019-07-26 01:03:29.000000000 
+0200
+++ new/PyFxA-0.7.6/PyFxA.egg-info/requires.txt 2020-07-10 02:00:54.000000000 
+0200
@@ -1,5 +1,6 @@
 requests>=2.4.2
 cryptography
 PyBrowserID
+PyJWT
 hawkauthlib
-six
+six>=1.14
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/fxa/_utils.py 
new/PyFxA-0.7.6/fxa/_utils.py
--- old/PyFxA-0.7.3/fxa/_utils.py       2019-06-03 05:12:31.000000000 +0200
+++ new/PyFxA-0.7.6/fxa/_utils.py       2020-07-10 01:46:43.000000000 +0200
@@ -78,6 +78,9 @@
     :param required: the scope required (e.g. by the application).
     :returns: ``True`` if all required scopes are provided, ``False`` if not.
     """
+    if isinstance(provided, six.string_types):
+        raise ValueError("Provided scopes must be a list, not a single string")
+
     if not isinstance(required, (list, tuple)):
         required = [required]
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/fxa/core.py new/PyFxA-0.7.6/fxa/core.py
--- old/PyFxA-0.7.3/fxa/core.py 2019-05-03 03:18:51.000000000 +0200
+++ new/PyFxA-0.7.6/fxa/core.py 2020-07-06 06:00:40.000000000 +0200
@@ -74,11 +74,13 @@
             auth_timestamp=resp["authAt"],
         )
 
-    def login(self, email, password=None, stretchpwd=None, keys=False, 
unblock_code=None):
+    def login(self, email, password=None, stretchpwd=None, keys=False, 
unblock_code=None,
+              verification_method=None, reason="login"):
         stretchpwd = self._get_stretched_password(email, password, stretchpwd)
         body = {
             "email": email,
             "authPW": hexstr(derive_key(stretchpwd, "authPW")),
+            "reason": reason,
         }
         url = "/account/login"
         if keys:
@@ -86,6 +88,8 @@
 
         if unblock_code:
             body["unblockCode"] = unblock_code
+        if verification_method:
+            body["verificationMethod"] = verification_method
 
         resp = self.apiclient.post(url, body)
         # XXX TODO: somehow sanity-check the schema on this endpoint
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/fxa/oauth.py new/PyFxA-0.7.6/fxa/oauth.py
--- old/PyFxA-0.7.3/fxa/oauth.py        2019-07-26 00:58:59.000000000 +0200
+++ new/PyFxA-0.7.6/fxa/oauth.py        2020-07-10 01:57:17.000000000 +0200
@@ -9,9 +9,10 @@
 from six import string_types
 from six.moves.urllib.parse import urlparse, urlunparse, urlencode, parse_qs
 
+import jwt
 from fxa.cache import MemoryCache, DEFAULT_CACHE_EXPIRY
 from fxa.constants import PRODUCTION_URLS
-from fxa.errors import OutOfProtocolError, ScopeMismatchError
+from fxa.errors import OutOfProtocolError, ScopeMismatchError, TrustError
 from fxa._utils import APIClient, scope_matches, get_hmac
 
 
@@ -24,7 +25,7 @@
     """Client for talking to the Firefox Accounts OAuth server"""
 
     def __init__(self, client_id=None, client_secret=None, server_url=None,
-                 cache=True, ttl=DEFAULT_CACHE_EXPIRY):
+                 cache=True, ttl=DEFAULT_CACHE_EXPIRY, jwks=None):
         self.client_id = client_id
         self.client_secret = client_secret
         if server_url is None:
@@ -41,6 +42,12 @@
         if self.cache is True:
             self.cache = MemoryCache(ttl)
 
+        if jwks is not None:
+            # Fail early if bad JWKs were provided.
+            for key in jwks:
+                jwt.algorithms.RSAAlgorithm.from_jwk(key)
+        self.jwks = jwks
+
     @property
     def server_url(self):
         return self.apiclient.server_url
@@ -148,10 +155,16 @@
             client_id = self.client_id
         assertion = self._get_identity_assertion(sessionOrAssertion, client_id)
         url = "/authorization"
+
+        # Although not relevant in this scenario from a security perspective,
+        # we generate a random 'state' and check the returned redirect URL
+        # for completeness.
+        state = base64.urlsafe_b64encode(os.urandom(24)).decode('utf-8')
+
         body = {
             "client_id": client_id,
             "assertion": assertion,
-            "state": "x",  # state is required, but we don't use it
+            "state": state
         }
         if scope is not None:
             body["scope"] = scope
@@ -167,6 +180,17 @@
         # This flow is designed for web-based redirects.
         # In order to get the code we must parse it from the redirect url.
         query_params = parse_qs(urlparse(resp["redirect"]).query)
+
+        # Check that the 'state' parameter is present and the same we provided
+        if "state" not in query_params:
+            error_msg = "state missing in OAuth response"
+            raise OutOfProtocolError(error_msg)
+
+        if state != query_params["state"][0]:
+            error_msg = "state mismatch in OAuth response (wanted: '{}', got: 
'{}')".format(
+                state, query_params["state"][0])
+            raise OutOfProtocolError(error_msg)
+
         try:
             return query_params["code"][0]
         except (KeyError, IndexError, ValueError):
@@ -205,6 +229,31 @@
 
         return resp['access_token']
 
+    def _verify_jwt_token(self, key, token):
+        pubkey = jwt.algorithms.RSAAlgorithm.from_jwk(key)
+        # The FxA OAuth ecosystem currently doesn't make good use of aud, and
+        # instead relies on scope for restricting which services can accept
+        # which tokens. So there's no value in checking it here, and in fact if
+        # we check it here, it fails because the right audience isn't being
+        # requested.
+        decoded = jwt.decode(
+            token, pubkey, algorithms=['RS256'], options={'verify_aud': False}
+        )
+        # Ref https://tools.ietf.org/html/rfc7515#section-4.1.9 the `typ` 
header
+        # is lowercase and has an implicit default `application/` prefix.
+        typ = jwt.get_unverified_header(token).get('typ', '')
+        if '/' not in typ:
+            typ = 'application/' + typ
+        if typ.lower() != 'application/at+jwt':
+            raise TrustError
+        return {
+            'user': decoded.get('sub'),
+            'client_id': decoded.get('client_id'),
+            'scope': decoded.get('scope', '').split(),
+            'generation': decoded.get('fxa-generation'),
+            'profile_changed_at': decoded.get('fxa-profileChangedAt')
+        }
+
     def verify_token(self, token, scope=None):
         """Verify an OAuth token, and retrieve user id and scopes.
 
@@ -222,14 +271,48 @@
             resp = None
 
         if resp is None:
-            url = '/verify'
-            body = {
-                'token': token
-            }
-            resp = self.apiclient.post(url, body)
-
+            # We want to fetch
+            # 
https://oauth.accounts.firefox.com/.well-known/openid-configuration
+            # and then get the jwks_uri key to get the /jwks url, but we'll
+            # just hardcodes it like this for now; our /jwks url will never
+            # change.
+            # https://github.com/mozilla/PyFxA/issues/81 is an issue about
+            # getting the jwks url out of the openid-configuration.
+            keys = []
+            if self.jwks is not None:
+                keys.extend(self.jwks)
+            else:
+                keys.extend(self.apiclient.get('/jwks').get('keys', []))
+            resp = None
+            try:
+                for k in keys:
+                    try:
+                        resp = self._verify_jwt_token(json.dumps(k), token)
+                        break
+                    except jwt.exceptions.InvalidSignatureError:
+                        # It's only worth trying other keys in the event of
+                        # `InvalidSignature`; if it was invalid for other 
reasons
+                        # (e.g. it's expired) then using a different key won't
+                        # help.
+                        continue
+                else:
+                    # It's a well-formed JWT, but not signed by any of the 
advertized keys.
+                    # We can immediately surface this as an error.
+                    if len(keys) > 0:
+                        raise TrustError({"error": "invalid signature"})
+            except (jwt.exceptions.DecodeError, 
jwt.exceptions.InvalidKeyError):
+                # It wasn't a JWT at all, or it was signed using a key type we
+                # don't support. Fall back to asking the FxA server to verify.
+                pass
+            except jwt.exceptions.PyJWTError as e:
+                # Any other JWT-related failure (e.g. expired token) can
+                # immediately surface as a trust error.
+                raise TrustError({"error": str(e)})
+            if resp is None:
+                resp = self.apiclient.post('/verify', {'token': token})
             missing_attrs = ", ".join([
-                k for k in ('user', 'scope', 'client_id') if k not in resp
+                k for k in ('user', 'scope', 'client_id')
+                if resp.get(k) is None
             ])
             if missing_attrs:
                 error_msg = '{0} missing in OAuth response'.format(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/fxa/tests/test_core.py 
new/PyFxA-0.7.6/fxa/tests/test_core.py
--- old/PyFxA-0.7.3/fxa/tests/test_core.py      2019-03-18 04:54:32.000000000 
+0100
+++ new/PyFxA-0.7.6/fxa/tests/test_core.py      2020-07-03 06:34:03.000000000 
+0200
@@ -176,7 +176,7 @@
         # If everything went well, verify_email_code should return an empty 
json object
         response = self.client.verify_email_code(m["headers"]["x-uid"],
                                                  m["headers"]["x-verify-code"])
-        self.assertEquals(response, {})
+        self.assertEqual(response, {})
 
     def test_send_unblock_code(self):
         acct = TestEmailAccount(email="block-{uniq}@{hostname}")
@@ -188,7 +188,7 @@
 
         # Initiate sending unblock code
         response = self.client.send_unblock_code(acct.email)
-        self.assertEquals(response, {})
+        self.assertEqual(response, {})
 
         m = acct.wait_for_email(lambda m: "x-unblock-code" in m["headers"])
         if not m:
@@ -302,16 +302,16 @@
 
         # Check that encryption keys have been preserved.
         session2.fetch_keys()
-        self.assertEquals(self.session.keys, session2.keys)
+        self.assertEqual(self.session.keys, session2.keys)
 
     def test_get_identity_assertion(self):
         assertion = self.session.get_identity_assertion("http://example.com";)
         data = browserid.verify(assertion, audience="http://example.com";)
-        self.assertEquals(data["status"], "okay")
+        self.assertEqual(data["status"], "okay")
         expected_issuer = urlparse(self.session.server_url).hostname
-        self.assertEquals(data["issuer"], expected_issuer)
+        self.assertEqual(data["issuer"], expected_issuer)
         expected_email = "{0}@{1}".format(self.session.uid, expected_issuer)
-        self.assertEquals(data["email"], expected_email)
+        self.assertEqual(data["email"], expected_email)
 
     def test_get_identity_assertion_handles_duration(self):
         millis = int(round(time.time() * 1000))
@@ -338,29 +338,32 @@
         assertion = self.session.get_identity_assertion("http://example.com";,
                                                         service="test-me")
         data = browserid.verify(assertion, audience="http://example.com";)
-        self.assertEquals(data["status"], "okay")
+        self.assertEqual(data["status"], "okay")
 
     def test_totp(self):
         resp = self.session.totp_create()
 
-        # Double create causes a client error
-        with self.assertRaises(fxa.errors.ClientError):
-            self.session.totp_create()
-
-        # Created but not verified returns false (and deletes the token)
-        self.assertFalse(self.session.totp_exists())
+        # Should exist even if not verified
+        self.assertTrue(self.session.totp_exists())
 
-        # Creating again should work this time
+        # Creating again should work unless verified
         resp = self.session.totp_create()
 
+        # Set session unverified to test next call
+        self.session.verified = False
+
         # Verify the code
         code = pyotp.TOTP(resp["secret"]).now()
         self.assertTrue(self.session.totp_verify(code))
         self.assertTrue(self.session.verified)
 
-        # Should exist now
+        # Should exist
         self.assertTrue(self.session.totp_exists())
 
+        # Double create causes a client error
+        with self.assertRaises(fxa.errors.ClientError):
+            self.session.totp_create()
+
         # Remove the code
         resp = self.session.totp_delete()
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/fxa/tests/test_crypto.py 
new/PyFxA-0.7.6/fxa/tests/test_crypto.py
--- old/PyFxA-0.7.3/fxa/tests/test_crypto.py    2018-12-13 09:20:11.000000000 
+0100
+++ new/PyFxA-0.7.6/fxa/tests/test_crypto.py    2020-05-22 03:28:18.000000000 
+0200
@@ -85,8 +85,8 @@
 
     def test_hkdf_namespace_handle_unicode_strings(self):
         kw = hkdf_namespace(text_type("foobar"))
-        self.assertEquals(kw, b"identity.mozilla.com/picl/v1/foobar")
+        self.assertEqual(kw, b"identity.mozilla.com/picl/v1/foobar")
 
     def test_hkdf_namespace_handle_bytes_strings(self):
         kw = hkdf_namespace("foobar".encode('utf-8'))
-        self.assertEquals(kw, b"identity.mozilla.com/picl/v1/foobar")
+        self.assertEqual(kw, b"identity.mozilla.com/picl/v1/foobar")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/fxa/tests/test_oauth.py 
new/PyFxA-0.7.6/fxa/tests/test_oauth.py
--- old/PyFxA-0.7.3/fxa/tests/test_oauth.py     2019-07-26 00:58:59.000000000 
+0200
+++ new/PyFxA-0.7.6/fxa/tests/test_oauth.py     2020-07-10 01:46:43.000000000 
+0200
@@ -2,7 +2,9 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this file,
 # You can obtain one at http://mozilla.org/MPL/2.0/.
 
+import os
 import json
+import jwt
 import responses
 import six
 try:
@@ -22,6 +24,13 @@
 TEST_SERVER_URL = "https://server/v1";
 
 
+def add_jwks_response():
+    responses.add(responses.GET,
+                  'https://server/v1/jwks',
+                  body=open(os.path.join(os.path.dirname(__file__), 
"jwks.json")).read(),
+                  content_type='application/json')
+
+
 class TestClientServerUrl(unittest.TestCase):
     def test_trailing_slash_without_prefix_added_prefix(self):
         client = Client('abc', 'cake', "https://server/";)
@@ -165,9 +174,9 @@
                       'https://server/v1/verify',
                       body=body,
                       content_type='application/json')
-
+        add_jwks_response()
         self.verification = self.client.verify_token(token='abc')
-        self.response = responses.calls[0]
+        self.response = responses.calls[1]
 
     def test_reaches_server_on_verify_url(self):
         self.assertEqual(self.response.request.url,
@@ -194,6 +203,7 @@
                       'https://server/v1/verify',
                       body='{"missing": "attributes"}',
                       content_type='application/json')
+        add_jwks_response()
         self.assertRaises(fxa.errors.OutOfProtocolError,
                           self.client.verify_token,
                           token='1234')
@@ -205,6 +215,7 @@
                       'https://server/v1/verify',
                       body=body,
                       content_type='application/json')
+        add_jwks_response()
         self.assertRaises(fxa.errors.ScopeMismatchError,
                           self.client.verify_token,
                           token='1234',
@@ -272,35 +283,44 @@
     server_url = TEST_SERVER_URL
 
     def setUp(self):
+        def authorization_callback(request):
+            data = json.loads(_decoded(request.body))
+            headers = {
+                'Content-Type': 'application/json'
+            }
+            body = {
+                'redirect': 
'https://relier/page?code=qed&state={}'.format(data["state"])
+            }
+            return (200, headers, json.dumps(body))
+
         self.client = Client("abc", "xyz", server_url=self.server_url)
-        body = '{"redirect": "https://relier/page?code=qed&state=blah"}'
-        responses.add(responses.POST,
-                      'https://server/v1/authorization',
-                      body=body,
-                      content_type='application/json')
+        responses.add_callback(responses.POST,
+                               'https://server/v1/authorization',
+                               callback=authorization_callback,
+                               content_type='application/json')
 
     @responses.activate
     def test_authorize_code_with_default_arguments(self):
         assertion = "A_FAKE_ASSERTION"
         code = self.client.authorize_code(assertion)
-        self.assertEquals(code, "qed")
+        self.assertEqual(code, "qed")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": assertion,
             "client_id": self.client.client_id,
-            "state": "x",
+            "state": AnyStringValue(),
         })
 
     @responses.activate
     def test_authorize_code_with_explicit_scope(self):
         assertion = "A_FAKE_ASSERTION"
         code = self.client.authorize_code(assertion, scope="profile:email")
-        self.assertEquals(code, "qed")
+        self.assertEqual(code, "qed")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": assertion,
             "client_id": self.client.client_id,
-            "state": "x",
+            "state": AnyStringValue(),
             "scope": "profile:email",
         })
 
@@ -308,12 +328,12 @@
     def test_authorize_code_with_explicit_client_id(self):
         assertion = "A_FAKE_ASSERTION"
         code = self.client.authorize_code(assertion, client_id="cba")
-        self.assertEquals(code, "qed")
+        self.assertEqual(code, "qed")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": assertion,
             "client_id": "cba",
-            "state": "x",
+            "state": AnyStringValue(),
         })
 
     @responses.activate
@@ -325,12 +345,12 @@
         self.assertEqual(sorted(verifier),
                          ["code_verifier"])
         code = self.client.authorize_code(assertion, **challenge)
-        self.assertEquals(code, "qed")
+        self.assertEqual(code, "qed")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": assertion,
             "client_id": self.client.client_id,
-            "state": "x",
+            "state": AnyStringValue(),
             "code_challenge": challenge["code_challenge"],
             "code_challenge_method": challenge["code_challenge_method"],
         })
@@ -344,12 +364,12 @@
             audience=TEST_SERVER_URL,
             service=self.client.client_id
         )
-        self.assertEquals(code, "qed")
+        self.assertEqual(code, "qed")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": "IDENTITY",
             "client_id": self.client.client_id,
-            "state": "x",
+            "state": AnyStringValue(),
         })
 
 
@@ -363,17 +383,18 @@
                       'https://server/v1/authorization',
                       body='{"access_token": "izatoken"}',
                       content_type='application/json')
+        add_jwks_response()
 
     @responses.activate
     def test_authorize_token_with_default_arguments(self):
         assertion = "A_FAKE_ASSERTION"
         token = self.client.authorize_token(assertion)
-        self.assertEquals(token, "izatoken")
+        self.assertEqual(token, "izatoken")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": assertion,
             "client_id": self.client.client_id,
-            "state": "x",
+            "state": AnyStringValue(),
             "response_type": "token",
         })
 
@@ -381,12 +402,12 @@
     def test_authorize_token_with_explicit_scope(self):
         assertion = "A_FAKE_ASSERTION"
         token = self.client.authorize_token(assertion, scope="storage")
-        self.assertEquals(token, "izatoken")
+        self.assertEqual(token, "izatoken")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": assertion,
             "client_id": self.client.client_id,
-            "state": "x",
+            "state": AnyStringValue(),
             "response_type": "token",
             "scope": "storage",
         })
@@ -395,12 +416,12 @@
     def test_authorize_token_with_explicit_client_id(self):
         assertion = "A_FAKE_ASSERTION"
         token = self.client.authorize_token(assertion, client_id="cba")
-        self.assertEquals(token, "izatoken")
+        self.assertEqual(token, "izatoken")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": assertion,
             "client_id": "cba",
-            "state": "x",
+            "state": AnyStringValue(),
             "response_type": "token",
         })
 
@@ -413,12 +434,12 @@
             audience=TEST_SERVER_URL,
             service=self.client.client_id
         )
-        self.assertEquals(token, "izatoken")
+        self.assertEqual(token, "izatoken")
         req_body = json.loads(_decoded(responses.calls[0].request.body))
-        self.assertEquals(req_body, {
+        self.assertEqual(req_body, {
             "assertion": "IDENTITY",
             "client_id": self.client.client_id,
-            "state": "x",
+            "state": AnyStringValue(),
             "response_type": "token",
         })
 
@@ -530,6 +551,7 @@
                       'https://server/v1/verify',
                       body=self.body,
                       content_type='application/json')
+        add_jwks_response()
 
     def test_has_default_cache(self):
         self.assertIsNotNone(self.client.cache)
@@ -591,3 +613,136 @@
         self.assertEqual(fxa._utils.requests, grequests)
 
         fxa._utils.requests = old_requests
+
+
+class TestJwtToken(unittest.TestCase):
+
+    server_url = TEST_SERVER_URL
+
+    def callback(self, request):
+        if self.verify_will_succeed:
+            return (200, {}, self.body)
+        return (500, {}, '{}')
+
+    def _make_jwt(self, payload, key, alg="RS256", header={"typ": "at+jwt"}):
+        header = header.copy()  # So we don't accidentally mutate argument 
in-place
+        return six.ensure_text(jwt.encode(payload, key, alg, header))
+
+    def setUp(self):
+        self.client = Client(server_url=self.server_url)
+        self.body = ('{"user": "alice", "scope": ["profile"],'
+                     '"client_id": "abc"}')
+        responses.add_callback(responses.POST,
+                               'https://server/v1/verify',
+                               callback=self.callback,
+                               content_type='application/json')
+        add_jwks_response()
+        self.verify_will_succeed = True
+
+    def get_file_contents(self, filename):
+        return jwt.algorithms.RSAAlgorithm.from_jwk(open(
+            os.path.join(
+                os.path.dirname(__file__),
+                filename
+            )
+        ).read())
+
+    @responses.activate
+    def test_good_jwt_token(self):
+        private_key = self.get_file_contents("private-key.json")
+        token = self._make_jwt({
+            "sub": "asdf",
+            "scope": "qwer",
+            "client_id": "foo"
+        }, private_key)
+        self.client.verify_token(token)
+        for c in responses.calls:
+            if c.request.url == 'https://server/v1/verify':
+                raise Exception("testing with a good token should not have \
+                                 resulted in a call to /verify, but it did.")
+
+    @responses.activate
+    def test_good_jwt_token_with_long_form_typ_header(self):
+        private_key = self.get_file_contents("private-key.json")
+        token = self._make_jwt({
+            "sub": "asdf",
+            "scope": "qwer",
+            "client_id": "foo"
+        }, private_key, header={
+            "typ": "application/at+JWT",
+        })
+        self.client.verify_token(token)
+        for c in responses.calls:
+            if c.request.url == 'https://server/v1/verify':
+                raise Exception("testing with a good token should not have \
+                                 resulted in a call to /verify, but it did.")
+
+    @responses.activate
+    def test_good_jwt_token_with_correct_scope(self):
+        private_key = self.get_file_contents("private-key.json")
+        token = self._make_jwt({
+            "sub": "asdf",
+            "scope": "qwer tee",
+            "client_id": "foo"
+        }, private_key)
+        self.client.verify_token(token, scope="tee")
+
+    @responses.activate
+    def test_good_jwt_token_with_incorrect_scope(self):
+        private_key = self.get_file_contents("private-key.json")
+        token = self._make_jwt({
+            "sub": "asdf",
+            "scope": "qwer",
+            "client_id": "foo"
+        }, private_key)
+        with self.assertRaises(fxa.errors.TrustError):
+            self.client.verify_token(token, scope="tee")
+
+    @responses.activate
+    def test_wrong_key_jwt_token(self):
+        self.verify_will_succeed = False
+        bad_key = self.get_file_contents("bad-key.json")
+        token = self._make_jwt({}, bad_key)
+        with self.assertRaises(fxa.errors.TrustError):
+            self.client.verify_token(token)
+        for c in responses.calls:
+            if c.request.url == 'https://server/v1/verify':
+                raise Exception("testing with a well-formed token with invalid 
signature \
+                                 should not have resulted in a call to 
/verify, but it did.")
+
+    @responses.activate
+    def test_expired_jwt_token(self):
+        private_key = self.get_file_contents("private-key.json")
+        token = self._make_jwt({"qwer": "asdf", "exp": 0}, private_key)
+        with self.assertRaises(fxa.errors.TrustError):
+            self.client.verify_token(token)
+
+    @responses.activate
+    def test_jwt_token_with_wrong_typ(self):
+        private_key = self.get_file_contents("private-key.json")
+        token = self._make_jwt({"qwer": "asdf", "exp": 0}, private_key, 
header={
+          "typ": "rt+jwt"
+        })
+        with self.assertRaises(fxa.errors.TrustError):
+            self.client.verify_token(token)
+
+    @responses.activate
+    def test_garbage_jwt_token(self):
+        self.verify_will_succeed = False
+        with self.assertRaises(fxa.errors.ServerError):
+            self.client.verify_token("garbage")
+        for c in responses.calls:
+            if c.request.url == 'https://server/v1/verify':
+                break
+        else:
+            raise Exception("testing with a garbage token should have \
+                             called /verify, but it did not.")
+
+
+class AnyStringValue:
+
+    def __eq__(self, other):
+        return isinstance(other, six.string_types)
+
+    def __repr__(self):
+        return 'any string'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/fxa/tests/test_requests_auth_plugin.py 
new/PyFxA-0.7.6/fxa/tests/test_requests_auth_plugin.py
--- old/PyFxA-0.7.3/fxa/tests/test_requests_auth_plugin.py      2018-12-13 
09:20:11.000000000 +0100
+++ new/PyFxA-0.7.6/fxa/tests/test_requests_auth_plugin.py      2020-05-22 
03:28:18.000000000 +0200
@@ -27,7 +27,7 @@
                 return_value=mocked_core_client())
     def test_audience_is_parsed(self, client_patch):
         self.auth(Request())
-        self.assertEquals(self.auth.audience, "http://www.example.com/";)
+        self.assertEqual(self.auth.audience, "http://www.example.com/";)
 
     @mock.patch('fxa.core.Client',
                 return_value=mocked_core_client())
@@ -86,12 +86,12 @@
 
         # First call should set the cache value
         auth(Request())
-        self.assertEquals(client_patch.return_value.login.return_value.
-                          get_identity_assertion.call_count, 1)
+        self.assertEqual(client_patch.return_value.login.return_value.
+                         get_identity_assertion.call_count, 1)
         # Second call should use the cache value
         auth(Request())
-        self.assertEquals(client_patch.return_value.login.return_value.
-                          get_identity_assertion.call_count, 1)
+        self.assertEqual(client_patch.return_value.login.return_value.
+                         get_identity_assertion.call_count, 1)
 
     @mock.patch('fxa.core.Client',
                 return_value=mocked_core_client())
@@ -157,13 +157,13 @@
                                   core_client_patch):
         # First call should set the cache value
         self.auth(Request())
-        self.assertEquals(core_client_patch.call_count, 1)
-        self.assertEquals(oauth_client_patch.call_count, 1)
+        self.assertEqual(core_client_patch.call_count, 1)
+        self.assertEqual(oauth_client_patch.call_count, 1)
 
         # Second call should use the cache value
         self.auth(Request())
-        self.assertEquals(core_client_patch.call_count, 1)
-        self.assertEquals(oauth_client_patch.call_count, 1)
+        self.assertEqual(core_client_patch.call_count, 1)
+        self.assertEqual(oauth_client_patch.call_count, 1)
 
     @mock.patch('fxa.core.Client',
                 return_value=mocked_core_client())
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyFxA-0.7.3/setup.py new/PyFxA-0.7.6/setup.py
--- old/PyFxA-0.7.3/setup.py    2019-07-26 01:01:34.000000000 +0200
+++ new/PyFxA-0.7.6/setup.py    2020-07-10 01:59:53.000000000 +0200
@@ -28,8 +28,9 @@
     "requests>=2.4.2",
     "cryptography",
     "PyBrowserID",
+    "PyJWT",
     "hawkauthlib",
-    "six"
+    "six>=1.14"
 ]
 
 if sys.version_info < (2, 7, 9):
@@ -40,7 +41,7 @@
 
 
 setup(name="PyFxA",
-      version='0.7.3',
+      version='0.7.6',
       description="Firefox Accounts client library for Python",
       long_description=README + "\n\n" + CHANGES,
       classifiers=[


Reply via email to