Repository: brettcannon/gidgethub
Pull request #62 (2018-06-19 23:42:53)

Allow authenticating as a GitHub App
- Add `jwt` and `oauth_token` parameters to:
  * `abc.GitHubAPI._make_request`
  * `abc.GitHubAPI.get_item`
  * `abc.GitHubAPI.get_iter`
  * `abc.GitHubAPI.post`
  * `abc.GitHubAPI.patch`
  * `abc.GitHubAPI.put`
  * `abc.GitHubAPI.delete`
- Add `jwt` argument to `sansio.create_headers`
- Raise `ValueError` if both `jwt` and `oauth_token` are passed in above methods
- Update the documentation
- Update the changelog

Closes https://github.com/brettcannon/gidgethub/issues/23

codecov[bot]: # [Codecov](https://codecov.io/gh/brettcannon/gidgethub/pull/62?src=pr&el=h1) Report
> Merging [#62](https://codecov.io/gh/brettcannon/gidgethub/pull/62?src=pr&el=desc) into [master](https://codecov.io/gh/brettcannon/gidgethub/commit/b5c3c2f14b61c65c99fd523ae6e9dbf45876728a?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/brettcannon/gidgethub/pull/62/graphs/tree.svg?token=t1zJsj2sZH&src=pr&width=650&height=150)](https://codecov.io/gh/brettcannon/gidgethub/pull/62?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master    #62    +/-   ##
======================================
  Coverage     100%   100%            
======================================
  Files          14     14            
  Lines        1046   1213   +167     
  Branches       58     65     +7     
======================================
+ Hits         1046   1213   +167
```


| [Impacted Files](https://codecov.io/gh/brettcannon/gidgethub/pull/62?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [gidgethub/abc.py](https://codecov.io/gh/brettcannon/gidgethub/pull/62/diff?src=pr&el=tree#diff-Z2lkZ2V0aHViL2FiYy5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [gidgethub/sansio.py](https://codecov.io/gh/brettcannon/gidgethub/pull/62/diff?src=pr&el=tree#diff-Z2lkZ2V0aHViL3NhbnNpby5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [gidgethub/test/test\_abc.py](https://codecov.io/gh/brettcannon/gidgethub/pull/62/diff?src=pr&el=tree#diff-Z2lkZ2V0aHViL3Rlc3QvdGVzdF9hYmMucHk=) | `100% <100%> (ø)` | :arrow_up: |
| [gidgethub/test/test\_sansio.py](https://codecov.io/gh/brettcannon/gidgethub/pull/62/diff?src=pr&el=tree#diff-Z2lkZ2V0aHViL3Rlc3QvdGVzdF9zYW5zaW8ucHk=) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/brettcannon/gidgethub/pull/62?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/brettcannon/gidgethub/pull/62?src=pr&el=footer). Last update [b5c3c2f...34a8627](https://codecov.io/gh/brettcannon/gidgethub/pull/62?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

Mariatta: Ready for review now!
brettcannon: Seeing it written out I think I prefer the separate `auth_token`/`jwt` arguments like you have with `create_headers`. It eliminates the need for all the `if`/`elif`/`else` lines because you can just blindly pass `auth_token` and `jwt` blindly down the call stack and the name still makes it obvious what kind of authorization you're doing.

Or am I over looking something? I'm not worried about future flexibility if that's why you went with this initial design as I'm sure GitHub is going to push GraphQL more anyway so I think this might be the end of the line for major changes to their v3 API. 😄 
Mariatta: Alright no prob :) I've changed the signature, and made `oauth_token` and `jwt` as keyword arguments to the other methods. ValueError is raised when both are passed.

I'll look forward to gidgethub v4: Add GraphQL support 😉 
brettcannon: Just got back from vacation; I will try to review this week.

As for GraphQL support, I have some ideas on a generalized GraphQL client library for Python if you're at all curious.

Patch:
diff --git a/README.rst b/README.rst
index 08aca4f..5f0fd6f 100644
--- a/README.rst
+++ b/README.rst
@@ -63,6 +63,13 @@ Changelog
 
 - ``gidgethub.sansio.RateLimit.from_http`` returns ``None`` if ratelimit is
   not found in the headers.
+- Allow authenticating as a GitHub App by using JSON web token.
+  ``gidgethub.sansio.create_headers`` now accepts
+  ``jwt`` argument. ``gidgethub.abc.GitHubAPI._make_request``,
+  ``gidgethub.abc.GitHubAPI.getitem``, ``gidgethub.abc.GitHubAPI.getiter``,
+  ``gidgethub.abc.GitHubAPI.post``, ``gidgethub.abc.GitHubAPI.patch``,
+  ``gidgethub.abc.GitHubAPI.put``, and ``gidgethub.abc.GitHubAPI.delete`` now
+  accept``jwt`` and ``oauth_token`` arguments.
 
 2.5.0
 '''''
diff --git a/docs/abc.rst b/docs/abc.rst
index 42aa3f3..21fd02d 100644
--- a/docs/abc.rst
+++ b/docs/abc.rst
@@ -85,7 +85,6 @@ experimental APIs without issue.
         This attribute is automatically updated after every successful
         HTTP request.
 
-
     .. abstractcoroutine:: _request(method, url, headers, body=b'')
 
         An abstract :term:`coroutine` to make an HTTP request. The
@@ -110,16 +109,30 @@ experimental APIs without issue.
             Renamed from ``_sleep()``.
 
 
-    .. coroutine:: getitem(url, url_vars={}, *, accept=sansio.accept_format())
+    .. coroutine:: getitem(url, url_vars={}, *, accept=sansio.accept_format(), jwt=None, oauth_token=None)
 
         Get a single item from GitHub.
 
+        *jwt* is the value of the JSON web token, for authenticating as a GitHub
+        App.
+
+        *oauth_token* is the value of the oauth token, for making an authenticated
+        API call.
+
+        Only one of *oauth_token* or *jwt* may be passed. A ``ValueError`` is
+        raised if both are passed. If neither was passed, it defaults to the
+        value of the *oauth_token* attribute.
+
+        .. versionchanged:: 3.0
+
+            Added *jwt* and *oauth_token*.
+
         .. note::
             For ``GET`` calls that can return multiple values and
             potentially require pagination, see ``getiter()``.
 
 
-    .. coroutine:: getiter(url, url_vars={}, *, accept=sansio.accept_format())
+    .. coroutine:: getiter(url, url_vars={}, *, accept=sansio.accept_format(), jwt=None, oauth_token=None)
 
         Get all items from a GitHub API endpoint.
 
@@ -128,21 +141,64 @@ experimental APIs without issue.
         `pagination <https://developer.github.com/v3/#pagination>`_
         will automatically be followed.
 
+        *jwt* is the value of the JSON web token, for authenticating as a GitHub
+        App.
+
+        *oauth_token* is the value of the oauth token, for making an authenticated
+        API call.
+
+        Only one of *oauth_token* or *jwt* may be passed. A ``ValueError`` is
+        raised if both are passed. If neither was passed, it defaults to the
+        value of the *oauth_token* attribute.
+
+        .. versionchanged:: 3.0
+
+            Added *jwt* and *oauth_token*.
+
         .. note::
             For ``GET`` calls that return only a single item, see
             :meth:`getitem`.
 
-    .. coroutine:: post(url, url_vars={}, *, data, accept=sansio.accept_format())
+
+    .. coroutine:: post(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None)
 
         Send a ``POST`` request to GitHub.
 
+        *jwt* is the value of the JSON web token, for authenticating as a GitHub
+        App.
+
+        *oauth_token* is the value of the oauth token, for making an authenticated
+        API call.
+
+        Only one of *oauth_token* or *jwt* may be passed. A ``ValueError`` is
+        raised if both are passed. If neither was passed, it defaults to the
+        value of the *oauth_token* attribute.
+
+        .. versionchanged:: 3.0
+
+            Added *jwt* and *oauth_token*.
+
 
-    .. coroutine:: patch(url, url_vars={}, *, data, accept=sansio.accept_format())
+    .. coroutine:: patch(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None)
 
         Send a ``PATCH`` request to GitHub.
 
+        *jwt* is the value of the JSON web token, for authenticating as a GitHub
+        App.
 
-    .. coroutine:: put(url, url_vars={}, *, data=b"", accept=sansio.accept_format())
+        *oauth_token* is the value of the oauth token, for making an authenticated
+        API call.
+
+        Only one of *oauth_token* or *jwt* may be passed. A ``ValueError`` is
+        raised if both are passed. If neither was passed, it defaults to the
+        value of the *oauth_token* attribute.
+
+        .. versionchanged:: 3.0
+
+            Added *jwt* and *oauth_token*.
+
+
+    .. coroutine:: put(url, url_vars={}, *, data=b"", accept=sansio.accept_format(), jwt=None, oauth_token=None)
 
         Send a ``PUT`` request to GitHub.
 
@@ -150,7 +206,39 @@ experimental APIs without issue.
         `locking an issue <https://developer.github.com/v3/issues/#lock-an-issue>`_
         will return no content, leading to ``None`` being returned.
 
+        *jwt* is the value of the JSON web token, for authenticating as a GitHub
+        App.
+
+        *oauth_token* is the value of the oauth token, for making an authenticated
+        API call.
+
+        Only one of *oauth_token* or *jwt* may be passed. A ``ValueError`` is
+        raised if both are passed. If neither was passed, it defaults to the
+        value of the *oauth_token* attribute.
+
+        .. versionchanged:: 3.0
+
+            Added *jwt* and *oauth_token*.
 
-    .. coroutine:: delete(url, url_vars={}, *, data=b"", accept=sansio.accept_format())
+
+    .. coroutine:: delete(url, url_vars={}, *, data=b"", accept=sansio.accept_format(), jwt=None, oauth_token=None)
 
         Send a ``DELETE`` request to GitHub.
+
+        *jwt* is the value of the JSON web token, for authenticating as a GitHub
+        App.
+
+        *oauth_token* is the value of the oauth token, for making an authenticated
+        API call.
+
+        Only one of *oauth_token* or *jwt* may be passed. A ``ValueError`` is
+        raised if both are passed. If neither was passed, it defaults to the
+        value of the *oauth_token* attribute.
+
+        .. versionchanged:: 2.5
+
+            Added *data* argument.
+
+        .. versionchanged:: 3.0
+
+            Added *jwt* and *oauth_token*.
diff --git a/docs/sansio.rst b/docs/sansio.rst
index e27bd79..c10d851 100644
--- a/docs/sansio.rst
+++ b/docs/sansio.rst
@@ -122,7 +122,7 @@ by helping to automate the GitHub-specific aspects of a REST call.
    support.
 
 
-.. function:: create_headers(requester, *, accept=accept_format(), oauth_token=None)
+.. function:: create_headers(requester, *, accept=accept_format(), oauth_token=None, jwt=None)
 
    Create a dict representing GitHub-specific header fields.
 
@@ -141,8 +141,18 @@ by helping to automate the GitHub-specific aspects of a REST call.
    This can be important if you need the expanded rate limit provided by an
    authenticated request.
 
+   The *jwt* allows making an authenticated request as a `GitHub App
+   <https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app>`_.
+   You can pass only one: *oauth_token* or *jwt*, but not both.
+
+   ``ValueError`` will be raised if both *jwt* and *oauth_token* are supplied.
+
    For consistency, all keys in the returned dict will be lowercased.
 
+   .. versionchanged:: 3.0
+
+       Added ``jwt`` argument.
+
 
 Responses
 '''''''''
@@ -200,6 +210,9 @@ that are provided to you. Continuing from the example in the Requests_ section::
         Create a :class:`RateLimit` instance from the HTTP headers of a GitHub API
         response.  Returns ``None`` if the ratelimit is not found in the headers.
 
+        .. versionchanged:: 3.0
+
+            Returns ``None`` if the ratelimit is not found in the headers.
 
 .. function:: decipher_response(status_code, headers, body)
 
diff --git a/gidgethub/__init__.py b/gidgethub/__init__.py
index 0eb7197..900b444 100644
--- a/gidgethub/__init__.py
+++ b/gidgethub/__init__.py
@@ -1,5 +1,5 @@
 """An async GitHub API library"""
-__version__ = '3.0.0.dev'
+__version__ = '3.0.0.dev0'
 
 import http
 from typing import Any
diff --git a/gidgethub/abc.py b/gidgethub/abc.py
index d0180b5..faec1c0 100644
--- a/gidgethub/abc.py
+++ b/gidgethub/abc.py
@@ -32,11 +32,27 @@ class GitHubAPI(abc.ABC):
         """Sleep for the specified number of seconds."""
 
     async def _make_request(self, method: str, url: str, url_vars: Dict,
-                            data: Any, accept: str) -> Tuple[bytes, Opt[str]]:
+                            data: Any, accept: str,
+                            jwt: Opt[str] = None,
+                            oauth_token: Opt[str] = None,
+                            ) -> Tuple[bytes, Opt[str]]:
         """Construct and make an HTTP request."""
+        if oauth_token is not None and jwt is not None:
+            raise ValueError("Cannot pass both oauth_token and jwt.")
         filled_url = sansio.format_url(url, url_vars)
-        request_headers = sansio.create_headers(self.requester, accept=accept,
-                                                oauth_token=self.oauth_token)
+        if jwt is not None:
+            request_headers = sansio.create_headers(
+                self.requester, accept=accept,
+                jwt=jwt)
+        elif oauth_token is not None:
+            request_headers = sansio.create_headers(
+                self.requester, accept=accept,
+                oauth_token=oauth_token)
+        else:
+            # fallback to using oauth_token
+            request_headers = sansio.create_headers(
+                self.requester, accept=accept,
+                oauth_token=self.oauth_token)
         cached = cacheable = False
         # Can't use None as a "no body" sentinel as it's a legitimate JSON type.
         if data == b"":
@@ -73,37 +89,63 @@ class GitHubAPI(abc.ABC):
         return data, more
 
     async def getitem(self, url: str, url_vars: Dict = {},
-                      *, accept: str = sansio.accept_format()) -> Any:
+                      *, accept: str = sansio.accept_format(),
+                      jwt: Opt[str] = None,
+                      oauth_token: Opt[str] = None
+                      ) -> Any:
         """Send a GET request for a single item to the specified endpoint."""
-        data, _ = await self._make_request("GET", url, url_vars, b"", accept)
+
+        data, _ = await self._make_request("GET", url, url_vars, b"", accept,
+                                           jwt=jwt, oauth_token=oauth_token)
         return data
 
     async def getiter(self, url: str, url_vars: Dict = {},
-                      *, accept: str = sansio.accept_format()) -> AsyncGenerator[Any, None]:
+                      *, accept: str = sansio.accept_format(),
+                      jwt: Opt[str] = None,
+                      oauth_token: Opt[str] = None
+                      ) -> AsyncGenerator[Any, None]:
         """Return an async iterable for all the items at a specified endpoint."""
-        data, more = await self._make_request("GET", url, url_vars, b"", accept)
+        data, more = await self._make_request("GET", url, url_vars, b"", accept,
+                                              jwt=jwt, oauth_token=oauth_token)
         for item in data:
             yield item
         if more:
             # `yield from` is not supported in coroutines.
-            async for item in self.getiter(more, url_vars, accept=accept):
+            async for item in self.getiter(more, url_vars, accept=accept,
+                                           jwt=jwt, oauth_token=oauth_token):
                 yield item
 
     async def post(self, url: str, url_vars: Dict = {}, *, data: Any,
-                   accept: str = sansio.accept_format()) -> Any:
-        data, _ = await self._make_request("POST", url, url_vars, data, accept)
+                   accept: str = sansio.accept_format(),
+                   jwt: Opt[str] = None,
+                   oauth_token: Opt[str] = None
+                   ) -> Any:
+        data, _ = await self._make_request("POST", url, url_vars, data, accept,
+                                           jwt=jwt, oauth_token=oauth_token)
         return data
 
     async def patch(self, url: str, url_vars: Dict = {}, *, data: Any,
-                    accept: str = sansio.accept_format()) -> Any:
-        data, _ = await self._make_request("PATCH", url, url_vars, data, accept)
+                    accept: str = sansio.accept_format(),
+                    jwt: Opt[str] = None,
+                    oauth_token: Opt[str] = None
+                    ) -> Any:
+        data, _ = await self._make_request("PATCH", url, url_vars, data, accept,
+                                           jwt=jwt, oauth_token=oauth_token)
         return data
 
     async def put(self, url: str, url_vars: Dict = {}, *, data: Any = b"",
-                  accept: str = sansio.accept_format()) -> Any:
-        data, _ = await self._make_request("PUT", url, url_vars, data, accept)
+                  accept: str = sansio.accept_format(),
+                  jwt: Opt[str] = None,
+                  oauth_token: Opt[str] = None
+                  ) -> Any:
+        data, _ = await self._make_request("PUT", url, url_vars, data, accept,
+                                           jwt=jwt, oauth_token=oauth_token)
         return data
 
     async def delete(self, url: str, url_vars: Dict = {}, *, data: Any = b"",
-                     accept: str = sansio.accept_format()) -> None:
-        await self._make_request("DELETE", url, url_vars, data, accept)
+                     accept: str = sansio.accept_format(),
+                     jwt: Opt[str] = None,
+                     oauth_token: Opt[str] = None
+                     ) -> None:
+        await self._make_request("DELETE", url, url_vars, data, accept,
+                                 jwt=jwt, oauth_token=oauth_token)
diff --git a/gidgethub/sansio.py b/gidgethub/sansio.py
index e26a381..3d58849 100644
--- a/gidgethub/sansio.py
+++ b/gidgethub/sansio.py
@@ -146,7 +146,8 @@ def accept_format(*, version: str = "v3", media: Optional[str] = None,
 
 
 def create_headers(requester: str, *, accept: str = accept_format(),
-                   oauth_token: Optional[str] = None) -> Dict[str, str]:
+                   oauth_token: Optional[str] = None,
+                   jwt: Optional[str] = None) -> Dict[str, str]:
     """Create a dict representing GitHub-specific header fields.
 
     The user agent is set according to who the requester is. GitHub asks it be
@@ -158,9 +159,14 @@ def create_headers(requester: str, *, accept: str = accept_format(),
     development -- or if you are looking for a different format return type,
     e.g. wanting the rendered HTML of a Markdown file.
 
-    The oauth_token allows making an authenticated request. This can be
-    important if you need the expanded rate limit provided by an authenticated
-    request.
+    The 'oauth_token' allows making an authenticated request using a personal access
+    token. This can be important if you need the expanded rate limit provided
+    by an authenticated request.
+
+    The 'jwt' allows authenticating as a GitHub App by passing in the
+    bearer token.
+
+    You can only supply only one of oauth_token or jwt, not both.
 
     For consistency, all keys in the returned dict will be lowercased.
     """
@@ -168,9 +174,15 @@ def create_headers(requester: str, *, accept: str = accept_format(),
     # accept: https://developer.github.com/v3/#current-version
     #         https://developer.github.com/v3/media/
     # authorization: https://developer.github.com/v3/#authentication
+    # authenticating as a GitHub App: https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
+
+    if oauth_token is not None and jwt is not None:
+        raise ValueError("Cannot pass both oauth_token and jwt.")
     headers = {"user-agent": requester, "accept": accept}
     if oauth_token is not None:
         headers["authorization"] = f"token {oauth_token}"
+    elif jwt is not None:
+        headers["authorization"] = f"bearer {jwt}"
     return headers
 
 
diff --git a/setup.py b/setup.py
index f59ccd9..c2701aa 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@ long_description = pathlib.Path("README.rst").read_text("utf-8")
 
 setuptools.setup(
     name="gidgethub",
-    version="3.0.0.dev",
+    version="3.0.0.dev0",
     description="An async GitHub API library",
     long_description=long_description,
     url="https://gidgethub.readthedocs.io",


Test patch:
diff --git a/gidgethub/test/test_abc.py b/gidgethub/test/test_abc.py
index a7baaa4..e1462ad 100644
--- a/gidgethub/test/test_abc.py
+++ b/gidgethub/test/test_abc.py
@@ -17,11 +17,11 @@ class MockGitHubAPI(gh_abc.GitHubAPI):
                        "content-type": "application/json"}
 
     def __init__(self, status_code=200, headers=DEFAULT_HEADERS, body=b'', *,
-                 cache=None):
+                 cache=None, oauth_token=None):
         self.response_code = status_code
         self.response_headers = headers
         self.response_body = body
-        super().__init__("test_abc", oauth_token="oauth token", cache=cache)
+        super().__init__("test_abc", oauth_token=oauth_token, cache=cache)
 
     async def _request(self, method, url, headers, body=b''):
         """Make an HTTP request."""
@@ -56,13 +56,48 @@ async def test_url_formatted():
 async def test_headers():
     """Appropriate headers are created."""
     accept = sansio.accept_format()
-    gh = MockGitHubAPI()
+    gh = MockGitHubAPI(oauth_token="oauth token")
     await gh._make_request("GET", "/rate_limit", {}, "", accept)
     assert gh.headers["user-agent"] == "test_abc"
     assert gh.headers["accept"] == accept
     assert gh.headers["authorization"] == "token oauth token"
 
 
+@pytest.mark.asyncio
+async def test_auth_headers_with_passed_token():
+    """Test the authorization header with the passed oauth_token."""
+    accept = sansio.accept_format()
+    gh = MockGitHubAPI()
+    await gh._make_request("GET", "/rate_limit", {}, "", accept,
+                           oauth_token="oauth token")
+    assert gh.headers["user-agent"] == "test_abc"
+    assert gh.headers["accept"] == accept
+    assert gh.headers["authorization"] == "token oauth token"
+
+
+@pytest.mark.asyncio
+async def test_auth_headers_with_passed_jwt():
+    """Test the authorization header with the passed jwt."""
+    accept = sansio.accept_format()
+    gh = MockGitHubAPI()
+    await gh._make_request("GET", "/rate_limit", {}, "", accept,
+                           jwt="json web token")
+    assert gh.headers["user-agent"] == "test_abc"
+    assert gh.headers["accept"] == accept
+    assert gh.headers["authorization"] == "bearer json web token"
+
+
+@pytest.mark.asyncio
+async def test_make_request_passing_token_and_jwt():
+    """Test that passing both jwt and oauth_token raises ValueError."""
+    accept = sansio.accept_format()
+    gh = MockGitHubAPI()
+    with pytest.raises(ValueError) as exc_info:
+        await gh._make_request("GET", "/rate_limit", {}, "", accept,
+                               jwt="json web token", oauth_token="oauth token")
+    assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
+
 @pytest.mark.asyncio
 async def test_rate_limit_set():
     """The rate limit is updated after receiving a response."""
@@ -110,6 +145,43 @@ async def test_getitem():
     assert data == original_data
 
 
+@pytest.mark.asyncio
+async def test_getitem_with_passed_jwt():
+    original_data = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=UTF-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(original_data).encode("utf8"))
+    await gh.getitem("/fake", jwt="json web token")
+    assert gh.method == "GET"
+    assert gh.headers['authorization'] == "bearer json web token"
+
+
+@pytest.mark.asyncio
+async def test_getitem_with_passed_oauth_token():
+    original_data = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=UTF-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(original_data).encode("utf8"))
+    await gh.getitem("/fake", oauth_token="my oauth token")
+    assert gh.method == "GET"
+    assert gh.headers['authorization'] == "token my oauth token"
+
+
+@pytest.mark.asyncio
+async def test_getitem_cannot_pass_both_jwt_and_oauth_token():
+    original_data = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=UTF-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(original_data).encode("utf8"))
+    with pytest.raises(ValueError) as exc_info:
+        await gh.getitem("/fake", oauth_token="my oauth token", jwt="json web token")
+
+    assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
+
 @pytest.mark.asyncio
 async def test_getiter():
     """Test that getiter() returns an async iterable as well as URI expansion."""
@@ -132,6 +204,57 @@ async def test_getiter():
     assert data[3] == 2
 
 
+@pytest.mark.asyncio
+async def test_getiter_with_passed_jwt():
+    original_data = [1, 2]
+    next_url = "https://api.github.com/fake{/extra}?page=2"
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=UTF-8"
+    headers["link"] = f'<{next_url}>; rel="next"'
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(original_data).encode("utf8"))
+    data = []
+    async for item in gh.getiter("/fake", {"extra": "stuff"},
+                                 jwt="json web token"):
+        data.append(item)
+    assert gh.method == "GET"
+    assert gh.headers["authorization"] == "bearer json web token"
+
+
+@pytest.mark.asyncio
+async def test_getiter_with_passed_oauth_token():
+    original_data = [1, 2]
+    next_url = "https://api.github.com/fake{/extra}?page=2"
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=UTF-8"
+    headers["link"] = f'<{next_url}>; rel="next"'
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(original_data).encode("utf8"))
+    data = []
+    async for item in gh.getiter("/fake", {"extra": "stuff"},
+                                 oauth_token="my oauth token"):
+        data.append(item)
+    assert gh.method == "GET"
+    assert gh.headers["authorization"] == "token my oauth token"
+
+
+@pytest.mark.asyncio
+async def test_getiter_cannot_pass_both_oauth_and_jwt():
+    original_data = [1, 2]
+    next_url = "https://api.github.com/fake{/extra}?page=2"
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=UTF-8"
+    headers["link"] = f'<{next_url}>; rel="next"'
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(original_data).encode("utf8"))
+    with pytest.raises(ValueError) as exc_info:
+        async for _ in gh.getiter("/fake", {"extra": "stuff"},
+                                     oauth_token="my oauth token",
+                                     jwt="json web token"):
+            pytest.fail("Unreachable")  # pragma: no cover
+    assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
+
 @pytest.mark.asyncio
 async def test_post():
     send = [1, 2, 3]
@@ -141,13 +264,51 @@ async def test_post():
     headers['content-type'] = "application/json; charset=utf-8"
     gh = MockGitHubAPI(headers=headers,
                        body=json.dumps(receive).encode("utf-8"))
-    data = await gh.post("/fake", data=send)
+    await gh.post("/fake", data=send)
     assert gh.method == "POST"
     assert gh.headers['content-type'] == "application/json; charset=utf-8"
     assert gh.body == send_json
     assert gh.headers['content-length'] == str(len(send_json))
 
 
+@pytest.mark.asyncio
+async def test_post_with_passed_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.post("/fake", data=send, jwt="json web token")
+    assert gh.method == "POST"
+    assert gh.headers["authorization"] == "bearer json web token"
+
+
+@pytest.mark.asyncio
+async def test_post_with_passed_oauth_token():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.post("/fake", data=send, oauth_token="my oauth token")
+    assert gh.method == "POST"
+    assert gh.headers["authorization"] == "token my oauth token"
+
+@pytest.mark.asyncio
+async def test_post_cannot_pass_both_oauth_and_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    with pytest.raises(ValueError) as exc_info:
+        await gh.post("/fake", data=send, oauth_token="my oauth token", jwt="json web token")
+
+    assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
 @pytest.mark.asyncio
 async def test_patch():
     send = [1, 2, 3]
@@ -157,13 +318,51 @@ async def test_patch():
     headers['content-type'] = "application/json; charset=utf-8"
     gh = MockGitHubAPI(headers=headers,
                        body=json.dumps(receive).encode("utf-8"))
-    data = await gh.patch("/fake", data=send)
+    await gh.patch("/fake", data=send)
     assert gh.method == "PATCH"
     assert gh.headers['content-type'] == "application/json; charset=utf-8"
     assert gh.body == send_json
     assert gh.headers['content-length'] == str(len(send_json))
 
 
+@pytest.mark.asyncio
+async def test_patch_with_passed_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.patch("/fake", data=send, jwt="json web token")
+    assert gh.method == "PATCH"
+    assert gh.headers["authorization"] == "bearer json web token"
+
+
+@pytest.mark.asyncio
+async def test_patch_with_passed_oauth_token():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.patch("/fake", data=send, oauth_token="my oauth token")
+    assert gh.method == "PATCH"
+    assert gh.headers["authorization"] == "token my oauth token"
+
+@pytest.mark.asyncio
+async def test_patch_cannot_pass_both_oauth_and_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    with pytest.raises(ValueError) as exc_info:
+        await gh.patch("/fake", data=send, oauth_token="my oauth token", jwt="json web token")
+
+    assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
 @pytest.mark.asyncio
 async def test_put():
     send = [1, 2, 3]
@@ -173,13 +372,53 @@ async def test_put():
     headers['content-type'] = "application/json; charset=utf-8"
     gh = MockGitHubAPI(headers=headers,
                        body=json.dumps(receive).encode("utf-8"))
-    data = await gh.put("/fake", data=send)
+    await gh.put("/fake", data=send)
     assert gh.method == "PUT"
     assert gh.headers['content-type'] == "application/json; charset=utf-8"
     assert gh.body == send_json
     assert gh.headers['content-length'] == str(len(send_json))
 
 
+@pytest.mark.asyncio
+async def test_put_with_passed_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.put("/fake", data=send, jwt="json web token")
+    assert gh.method == "PUT"
+    assert gh.headers["authorization"] == "bearer json web token"
+
+
+@pytest.mark.asyncio
+async def test_put_with_passed_oauth_token():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.put("/fake", data=send, oauth_token="my oauth token")
+    assert gh.method == "PUT"
+    assert gh.headers["authorization"] == "token my oauth token"
+
+
+@pytest.mark.asyncio
+async def test_put_cannot_pass_both_oauth_and_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    with pytest.raises(ValueError) as exc_info:
+        await gh.put("/fake", data=send, oauth_token="my oauth token", jwt="json web token")
+
+    assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
+
 @pytest.mark.asyncio
 async def test_delete():
     send = [1, 2, 3]
@@ -189,13 +428,53 @@ async def test_delete():
     headers['content-type'] = "application/json; charset=utf-8"
     gh = MockGitHubAPI(headers=headers,
                        body=json.dumps(receive).encode("utf-8"))
-    data = await gh.delete("/fake", data=send)
+    await gh.delete("/fake", data=send)
     assert gh.method == "DELETE"
     assert gh.headers['content-type'] == "application/json; charset=utf-8"
     assert gh.body == send_json
     assert gh.headers['content-length'] == str(len(send_json))
 
 
+@pytest.mark.asyncio
+async def test_delete_with_passed_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.delete("/fake", data=send, jwt="json web token")
+    assert gh.method == "DELETE"
+    assert gh.headers["authorization"] == "bearer json web token"
+
+
+@pytest.mark.asyncio
+async def test_delete_with_passed_oauth_token():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    await gh.delete("/fake", data=send, oauth_token="my oauth token")
+    assert gh.method == "DELETE"
+    assert gh.headers["authorization"] == "token my oauth token"
+
+
+@pytest.mark.asyncio
+async def test_delete_pass_both_oauth_and_jwt():
+    send = [1, 2, 3]
+    receive = {"hello": "world"}
+    headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+    headers['content-type'] = "application/json; charset=utf-8"
+    gh = MockGitHubAPI(headers=headers,
+                       body=json.dumps(receive).encode("utf-8"))
+    with pytest.raises(ValueError) as exc_info:
+        await gh.delete("/fake", data=send, oauth_token="my oauth token", jwt="json web token")
+
+    assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
+
 class TestCache:
 
     @pytest.mark.asyncio
diff --git a/gidgethub/test/test_sansio.py b/gidgethub/test/test_sansio.py
index dd3b023..5753147 100644
--- a/gidgethub/test/test_sansio.py
+++ b/gidgethub/test/test_sansio.py
@@ -166,6 +166,24 @@ class TestCreateHeaders:
         assert headers["accept"] == test_api
         assert headers["authorization"] == f"token {oauth_token}"
 
+    def test_authorization_with_jwt(self):
+        user_agent = "brettcannon"
+        jwt = "secret"
+        headers = sansio.create_headers(user_agent, jwt=jwt)
+        assert headers["user-agent"] == user_agent
+        assert headers["authorization"] == f"bearer {jwt}"
+
+    def test_cannot_pass_both_jwt_and_oauth(self):
+        user_agent = "brettcannon"
+        jwt = "secret jwt"
+        oauth_token = "secret oauth token"
+        with pytest.raises(ValueError) as exc_info:
+            sansio.create_headers(
+                user_agent,
+                oauth_token=oauth_token,
+                jwt=jwt)
+        assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
+
 
 class TestRateLimit: