Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support managing a GitHub App's JWT in GitHubAPI #201

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

jonathansick
Copy link

This PR makes it possible for a GitHubAPI instance to automatically authenticate requests as a GitHub App (as opposed to an app installation). By setting the app_id and private_key arguments, GitHubAPI will automatically generate and use a JWT to authenticate as the app. Passing an oauth_token or jwt argument directly to one of the request methods overrides the managed JWT as the authentication method.

The JWT is available from the GitHubApi.jwt property. The computed JWT is cached, but is regenerated if necessary.

Let me know what you think about this caching functionality. The verify_exp option on jwt.decode() is what I landed on for testing an existing JWT's validity. I'm not sure how much more efficient this is than treating JWT's as entirely ephemeral, but seems to work well.

I also did some other clean-up activities to get the tests/linters to work. I've put those in separate commits.

Closes #199

The about-events page dropped that anchor link; this is the new page
listing webhook events.
This is needed because apps.py needs to reference GitHubAPI in its type
defs, but abc.py also imports apps.py
This change makes it possible to cache the app ID and private key for a
GitHub App in the GitHubApi client. When set, the client will
automatically generate and use a JWT to authenticate as the GitHub App
(similar to caching the oauth_token on GitHubApi when working as an app
installation or with a PAT). The cached JWT is available from the `jwt`
property on the class. When the `jwt` property is accessed, the cached
jwt token's expiration is tested, and a new jwt is automatically
generated if necessary.
- Ensure a GitHubApi caches either an oauth_token or the GitHub App
  credentials, but not both
- Test that the GithubApi.jwt property is cached while the token is
  valid, but regenerates if a token is expired.
- Test that a jwt directly passed to a request method overrides the
  cached JWT
@jonathansick
Copy link
Author

I'm not entirely sure about the <100% coverage being reported under Python 3.9. It looks like these are related to decorated methods. This PR adds one, GitHubApi.jwt, but there is similar partial coverage being report on existing code.

image

@Mariatta
Copy link
Member

Thanks for the PR! Sorry for not responding. I just came back from traveling and still jetlagged. I'll be able to review next week.

@Mariatta
Copy link
Member

Mariatta commented Aug 4, 2023

Going to close and re-open to see if readthedocs preview will kick in.

@Mariatta Mariatta closed this Aug 4, 2023
@Mariatta Mariatta reopened this Aug 4, 2023
@codecov
Copy link

codecov bot commented Aug 4, 2023

Codecov Report

Merging #201 (8d50bf7) into main (dbcdf4b) will not change coverage.
Report is 1 commits behind head on main.
The diff coverage is 100.00%.

❗ Current head 8d50bf7 differs from pull request most recent head 50fd864. Consider uploading reports for the commit 50fd864 to get more accurate results

@@            Coverage Diff            @@
##              main      #201   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            9         9           
  Lines          471       494   +23     
  Branches        87        93    +6     
=========================================
+ Hits           471       494   +23     
Files Changed Coverage Δ
gidgethub/abc.py 100.00% <100.00%> (ø)
gidgethub/apps.py 100.00% <100.00%> (ø)

docs/changelog.rst Outdated Show resolved Hide resolved
docs/abc.rst Show resolved Hide resolved
gidgethub/abc.py Outdated Show resolved Hide resolved

import time
import jwt

from gidgethub.abc import GitHubAPI
if TYPE_CHECKING:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This perhaps should be done as a separate PR.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure I can do that. Let me know and I'll set up another PR that can be merged before this one. The TYPE_CHECKING guard is needed to prevent a circular import since abc now imports from apps in this PR.

@Mariatta
Copy link
Member

In the apps I've built, the authentication flow is as follows:

  • get installation access token (by passing in app_id and private_key, and jwt)
  • use the installation access token (treated as oauth_token) for all other requests.

Right now once we receive the installation access token, I need to manage the expiration of it myself, and this is the part I'm hoping can be managed internally in gidgethub.

With this change, it seems like user will either use either always JWT or always oauth token, is that right? Or perhaps I'm not understanding the whole workflow.

@jonathansick
Copy link
Author

Thanks for the review @Mariatta!

You're right that this change is designed around a pattern where a specific instance of GitHubAPI will either be used as an app client (using the managed jwt) or as an app installation (using the cached oauth_token). Most of the time my GitHub Apps also tend to get the oauth token as soon as possible to do work as an installation, but sometimes I'll do other work as the app itself (e.g. GET /app/installations).

The API in my app I'm hoping to make possible is a GitHubAPI factory class where I can get a GitHubAPI instance that's authenticated either as an app or as an app installation and that I can use without having to access my app's GitHub App config/secrets while making API calls. This factory looks like this:

class GitHubAPIFactory:

    def __init__(
        self, *, id: str, key: str, name: str, http_client: httpx.AsyncClient
    ) -> None:
        ...

    def create_app_client(self) -> GitHubAPI:
        """Create a client authenticated as the GitHub App."""
        ...

    async def create_installation_client(
        self, installation_id: str
    ) -> GitHubAPI:
        """Create a client authenticated as an installation of the GitHub App
        given an installation ID (provided e.g. by a webhook payload).
        """
        ...

    async def create_installation_client_for_repo(
        self, owner: str, repo: str
    ) -> GitHubAPI:
        """Create a client authenticated as an installation of the GitHub App
        for a specific repository.
        """
        ...

It's this GitHubAPIFactory.create_app_client method that can't really be accomplished at the moment.

Another approach that put this capability into GitHubAPI itself would be to have a method that creates a client authenticated as an app installation, e.g.:

# Create a client authenticated as the app, using a JWT by default
app_client = GitHubApi("myapp", app_id=app_id, private_key=key)

# Installation client given an ID
installation_client = app_client.create_installation_client(installation_id)

# Installation client given a repo that internally gets the ID
installation_client = app_client.create_installation_client_for_repo(owner, repo) 

With either of these set ups, the user gets the configs for the GitHub App only once, and all the details of calling the APIs in gidgethub's apps module are taken care of either by the factory (in my first example) or the mini factory embedded in GitHubApi (in my second example).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Adding jwt caching in GitHubAPI, like oauth_token?
2 participants