Repository: mozilla-services/requests-hawk

Issue:
Make instantiation with credentials simpler/cleaner
We've just started to use Hawk for the Treeherder API and so are having to add support to our Python client that we make available for people to interact with the API, as well as update documentation for it.

The current WIP docs for how people should use our Python client, has examples like:
```python
from requests_hawk import HawkAuth
from thclient import TreeherderClient
...
auth = HawkAuth(credentials={
    'id': 'your-treeherder-client-id',
    'key': 'your-secret',
    'algorithm': 'sha256'
})
client = TreeherderClient(protocol='https', host='treeherder.mozilla.org', auth=auth)
client.post_collection('mozilla-central', tjc)
```
This is more verbose than I'd like (eg: users of our client shouldn't have to know we're using sha256) and passing the credentials as a dict doesn't seem very clean, so I was contemplating wrapping HawkAuth like so in our client code:
```python
class TreeherderHawkAuth(HawkAuth):
    """Thin wrapper around HawkAuth to reduce boilerplate."""
    def __init__(self, client_id, secret):
        credentials = {
            'id': client_id,
            'key': secret,
            'algorithm': 'sha256'
        }
        return super(TreeherderHawkAuth, self).__init__(credentials=credentials)
```
So instead the example would become:
```python
from thclient import TreeherderClient, TreeherderHawkAuth
...
auth = TreeherderHawkAuth('your-treeherder-client-id', 'your-secret')
client = TreeherderClient(protocol='https', host='treeherder.mozilla.org', auth=auth)
client.post_collection('mozilla-central', tjc)
```

However we then have a wrapper around a wrapper whose purpose was to make using Hawk with requests simpler, which seems to defeat the point a bit.

Would you be open to making changes to make this unnecessary? (eg defaulting to 'sha256' if no algorithm specified, being able to pass the credentials in a nicer form than a dict etc. eg a `HawkAuth(client_id='foo', secret='bar')` or `HawkAuth.from_credentials('foo', 'bar')`).

Thanks :-)

Discussion:

Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index 0cce5ce..f312487 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,10 +1,14 @@
 CHANGELOG
 =========
 
-0.3.0 (unreleased)
+1.0.0 (unreleased)
 ------------------
 
-- Nothing changed yet.
+- Simplified API for using HawkAuth when the id and key are known. (#8)
+- Added support for overriding the default algorithm (sha256) when deriving
+  credentials from the hawk session token, via a new ``algorithm`` parameter.
+
+See the README for migration advice if you use the ``credentials`` parameter.
 
 
 0.2.1 (2015-10-14)
diff --git a/README.rst b/README.rst
index 74ce1c9..8bb3930 100644
--- a/README.rst
+++ b/README.rst
@@ -11,16 +11,30 @@ This project allows you to use `the python requests library
 
 Hawk itself does not provide any mechanism for obtaining or transmitting the
 set of shared credentials required, but this project proposes a scheme we use
-accross mozilla services projects.
+across mozilla services projects.
 
 Great, how can I use it?
 ========================
 
 First, you'll need to install it::
 
+  .. code-block:: bash
+
     pip install requests-hawk
 
-Then, in your project, you can use it like that::
+Then, in your project, if you know the `id` and `key`, you can use::
+
+  .. code-block:: python
+
+    import requests
+    from requests_hawk import HawkAuth
+
+    hawk_auth = HawkAuth(id='my-hawk-id', key='my-hawk-secret-key')
+    requests.post("https://example.com/url", auth=hawk_auth)
+
+Or if you need to derive them from the hawk session token, instead use::
+
+  .. code-block:: python
 
     import requests
     from requests_hawk import HawkAuth
@@ -31,6 +45,15 @@ Then, in your project, you can use it like that::
     )
     requests.post("/url", auth=hawk_auth)
 
+In the second example, the ``server_url`` parameter to ``HawkAuth`` was used to
+provide a default host name, to avoid having to repeat it for each request.
+
+If you wish to override the default algorithm of ``sha256``, pass the desired
+algorithm name using the optional ``algorithm`` parameter.
+
+Note: The ``credentials`` parameter has been removed. Instead pass ``id`` and
+``key`` separately (as above), or pass the existing dict as ``**credentials``.
+
 Integration with httpie
 =======================
 
@@ -40,11 +63,15 @@ uses the requests library. We've made it simple for you to plug hawk with it.
 
 If you know the id and key, use it like that::
 
+  .. code-block:: bash
+
    http POST localhost:5000/registration\
    --auth-type=hawk --auth='id:key'
 
 Or, if you want to use the hawk session token, you can do as follows::
 
+  .. code-block:: bash
+
    http POST localhost:5000/registration\
    --auth-type=hawk --auth='c0d8cd2ec579a3599bef60f060412f01f5dc46f90465f42b5c47467481315f51:'
 
@@ -59,15 +86,19 @@ Okay, on to the actual details.
 The server gives you a session token, that you'll need to derive to get the
 hawk credentials.
 
-Do an HKDF derivation on the given session token. You’ll need to use the
+Do an HKDF derivation on the given session token. You'll need to use the
 following parameters::
 
+  .. code-block:: python
+
     key_material = HKDF(hawk_session, '', 'identity.mozilla.com/picl/v1/sessionToken', 32*2)
 
-The key material you’ll get out of the HKDF needs to be separated into two
+The key material you'll get out of the HKDF needs to be separated into two
 parts, the first 32 hex characters are the ``hawk id``, and the next 32 ones are the
 ``hawk key``::
 
+  .. code-block:: python
+
     credentials = {
         'id': keyMaterial[0:32]
         'key': keyMaterial[32:64]
@@ -79,4 +110,6 @@ Run tests
 
 To run test, you can use tox::
 
+  .. code-block:: bash
+
     tox
diff --git a/requests_hawk/__init__.py b/requests_hawk/__init__.py
index 0827744..6bd4f10 100644
--- a/requests_hawk/__init__.py
+++ b/requests_hawk/__init__.py
@@ -19,46 +19,52 @@ class HawkAuth(AuthBase):
       You don't need to set this parameter if you already know the hawk
       credentials (Optional).
 
-    :param credentials:
-      Python dict containing credentials information, with keys for "id",
-      "key" and "algorithm" (Optional).
+    :param id:
+      The hawk id string to use for authentication (Optional).
+
+    :param key:
+      A string containing the hawk secret key (Optional).
+
+    :param algorithm:
+      A string containing the name of the algorithm to be used.
+      (Optional, defaults to 'sha256').
 
     :param server_url:
       The url of the server, this is useful for hawk when signing the requests.
-      In case this is omited, fallbacks to the value of the "Host" header of
+      In case this is omitted, fallbacks to the value of the "Host" header of
       the request (Optional).
 
-
-    Note that the `hawk_session` and `credentials` parameters are mutually
-    exclusive.  You should set one or the other.
-
+    Note that the `hawk_session` and `id` parameters are mutually exclusive.
+    You should use either `hawk_session` or both `id` and 'key'.
     """
-    def __init__(self, hawk_session=None, credentials=None, server_url=None,
-                 _timestamp=None):
-        if ((credentials, hawk_session) == (None, None)
-                or (credentials is not None and hawk_session is not None)):
+    def __init__(self, hawk_session=None, id=None, key=None, algorithm='sha256',
+                 credentials=None, server_url=None, _timestamp=None):
+        if credentials is not None:
+            raise AttributeError("The 'credentials' param has been removed. "
+                                 "Pass 'id' and 'key' instead, or '**credentials_dict'.")
+
+        if (hawk_session and (id or key)
+                or not hawk_session and not (id and key)):
             raise AttributeError("You should pass either 'hawk_session' "
-                                 "or 'credentials'.")
+                                 "or both 'id' and 'key'.")
 
-        elif hawk_session is not None:
+        if hawk_session:
             try:
                 hawk_session = codecs.decode(hawk_session, 'hex_codec')
             except binascii.Error as e:
                 raise TypeError(e)
             keyInfo = 'identity.mozilla.com/picl/v1/sessionToken'
             keyMaterial = HKDF(hawk_session, "", keyInfo, 32*2)
-            credentials = {
-                'id': codecs.encode(keyMaterial[:32], "hex_codec"),
-                'key': codecs.encode(keyMaterial[32:64], "hex_codec"),
-                'algorithm': 'sha256'
-            }
-        self.credentials = credentials
+            id = codecs.encode(keyMaterial[:32], "hex_codec")
+            key = codecs.encode(keyMaterial[32:64], "hex_codec")
+
+        self.credentials = {
+            'id': id,
+            'key': key,
+            'algorithm': algorithm
+        }
         self._timestamp = _timestamp
-
-        if server_url is not None:
-            self.host = urlparse(server_url).netloc
-        else:
-            self.host = None
+        self.host = urlparse(server_url).netloc if server_url else None
 
     def __call__(self, r):
         if self.host is not None:
@@ -119,17 +125,10 @@ try:
         auth_type = 'hawk'
         description = ''
 
-        def get_auth(self, id, key):
-            kwargs = {}
-            if key == '':
-                kwargs['hawk_session'] = id
-            else:
-                kwargs['credentials'] = {
-                    'id': id,
-                    'key': key,
-                    'algorithm': 'sha256'
-                }
-            return HawkAuth(**kwargs)
+        def get_auth(self, username, password):
+            if password == '':
+                return HawkAuth(hawk_session=username)
+            return HawkAuth(id=username, key=password)
 
 except ImportError:
     pass
diff --git a/setup.cfg b/setup.cfg
index d08cbd5..a94ac3b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,3 +3,7 @@ create-wheel = yes
 
 [bdist_wheel]
 universal = 1
+
+[flake8]
+# Override the default of 80 characters
+max-line-length = 90
diff --git a/setup.py b/setup.py
index 7cd658a..4831ee1 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@ with codecs.open(os.path.join(here, 'CHANGES.txt'), encoding='utf-8') as f:
 requires = ['requests!=2.8.0', 'mohawk']
 
 setup(name='requests-hawk',
-      version='0.3.0.dev0',
+      version='1.0.0.dev0',
       description='requests-hawk',
       long_description=README + '\n\n' + CHANGES,
       classifiers=[


Test patch:
diff --git a/requests_hawk/tests/test_hawkauth.py b/requests_hawk/tests/test_hawkauth.py
index d91363c..ed0aa21 100644
--- a/requests_hawk/tests/test_hawkauth.py
+++ b/requests_hawk/tests/test_hawkauth.py
@@ -6,20 +6,32 @@ from requests_hawk import HawkAuth
 
 class TestHawkAuth(unittest.TestCase):
 
-    def test_hawkauth_errors_when_credentials_and_hawk_session_passed(self):
+    def test_hawkauth_errors_when_id_and_key_and_hawk_session_passed(self):
         self.assertRaises(AttributeError, HawkAuth,
-                          credentials={}, hawk_session="test")
+                          id='test', key='test', hawk_session="test")
+
+    def test_hawkauth_errors_when_id_and_hawk_session_passed(self):
+        self.assertRaises(AttributeError, HawkAuth,
+                          id='test', hawk_session="test")
+
+    def test_hawkauth_errors_when_key_and_hawk_session_passed(self):
+        self.assertRaises(AttributeError, HawkAuth,
+                          key='test', hawk_session="test")
+
+    def test_hawkauth_errors_when_only_id_passed(self):
+        self.assertRaises(AttributeError, HawkAuth, id='test')
+
+    def test_hawkauth_errors_when_only_key_passed(self):
+        self.assertRaises(AttributeError, HawkAuth, key='test')
+
+    def test_hawkauth_errors_when_credentials_passed(self):
+        self.assertRaises(AttributeError, HawkAuth, credentials={})
 
     def test_hawkauth_errors_when_no_auth_is_set(self):
         self.assertRaises(AttributeError, HawkAuth)
 
-    def test_hawk_auth_supports_credentials_as_dict(self):
-        credentials = {
-            'id': 'test_id',
-            'key': 'test_key',
-            'algorithm': 'sha256'
-        }
-        auth = HawkAuth(credentials=credentials, _timestamp=1431698426)
+    def test_hawk_auth_supports_credentials_as_parameters(self):
+        auth = HawkAuth(id='test_id', key='test_key', _timestamp=1431698426)
         request = Request('PUT', 'http://www.example.com',
                           json={"foo": "bar"}, auth=auth)
         r = request.prepare()
@@ -32,6 +44,10 @@ class TestHawkAuth(unittest.TestCase):
                         "Timestamp doesn't match")
         self.assertEqual(r.body, '{"foo": "bar"}')
 
+    def test_overriding_credentials_algorithm(self):
+        auth = HawkAuth(id='test_id', key='test_key', algorithm='sha1')
+        self.assertEqual(auth.credentials['algorithm'], 'sha1')
+
     def test_key_non_hex_values_throws(self):
         self.assertRaises(TypeError, HawkAuth, hawk_session="test")
 
@@ -45,6 +61,11 @@ class TestHawkAuth(unittest.TestCase):
             'algorithm': 'sha256'
         })
 
+    def test_overriding_session_algorithm(self):
+        auth = HawkAuth(hawk_session=codecs.encode(b"hello", "hex_codec"),
+                        algorithm='sha1')
+        self.assertEqual(auth.credentials['algorithm'], 'sha1')
+
     def test_server_url_is_parsed(self):
         auth = HawkAuth(hawk_session=codecs.encode(b"hello", "hex_codec"),
                         server_url="http://localhost:5000")