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=[
