Source code for oauth_dropins.github

"""GitHub OAuth drop-in.

API docs:

* https://developer.github.com/v4/
* https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/#web-application-flow
"""
import logging
import urllib.parse

from flask import request
from google.cloud import ndb

from . import views
from .models import BaseAuth
from .webutil import appengine_info, flask_util, util
from .webutil.util import json_dumps, json_loads

logger = logging.getLogger(__name__)

if appengine_info.DEBUG:
  GITHUB_CLIENT_ID = util.read('github_client_id_local')
  GITHUB_CLIENT_SECRET = util.read('github_client_secret_local')
else:
  GITHUB_CLIENT_ID = util.read('github_client_id')
  GITHUB_CLIENT_SECRET = util.read('github_client_secret')
# URL templates. Can't (easily) use urlencode() because I want to keep
# the %(...)s placeholders as is and fill them in later in code.
GET_AUTH_CODE_URL = '&'.join((
    'https://github.com/login/oauth/authorize?'
    'client_id=%(client_id)s',
    # https://developer.github.com/apps/building-oauth-apps/scopes-for-oauth-apps/
    'scope=%(scope)s',
    # if provided, must be the same in the access token request, or a subpath!
    'redirect_uri=%(redirect_uri)s',
    'state=%(state)s',
))

GET_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'

API_GRAPHQL = 'https://api.github.com/graphql'
# https://developer.github.com/v4/object/user/
GRAPHQL_USER = {
  'query': """
query {
  viewer {
    id
    login
    name
    url
    avatarUrl
    id
    location
    websiteUrl
    bio
  }
}""",
}


[docs] class GitHubAuth(BaseAuth): """An authenticated GitHub user. Provides methods that return information about this user and make OAuth-signed requests to the GitHub REST API. Stores OAuth credentials in the datastore. See :class:`models.BaseAuth` for usage details. GitHub-specific details: implements :meth:`get` but not :meth:`urlopen`, or :meth:`api`. The key name is the username. """ access_token_str = ndb.StringProperty(required=True) user_json = ndb.TextProperty()
[docs] def site_name(self): return 'GitHub'
[docs] def user_display_name(self): """Returns the user's full name or username. """ return self.key_id()
[docs] def access_token(self): """Returns the OAuth access token string. """ return self.access_token_str
[docs] def get(self, *args, **kwargs): """Wraps :func:`requests.get` and adds the Bearer token header. TODO: unify with medium.py. """ return self._requests_call(util.requests_get, *args, **kwargs)
[docs] def post(self, *args, **kwargs): """Wraps :func:`requests.post` and adds the ``Bearer`` token header. TODO: unify with medium.py. """ return self._requests_call(util.requests_post, *args, **kwargs)
def _requests_call(self, fn, *args, **kwargs): headers = kwargs.setdefault('headers', {}) headers['Authorization'] = 'Bearer ' + self.access_token_str resp = fn(*args, **kwargs) assert 'errors' not in resp, resp try: resp.raise_for_status() except BaseException as e: util.interpret_http_exception(e) raise return resp
[docs] class Start(views.Start): """Starts GitHub auth. Requests an auth code and expects a redirect back. """ NAME = 'github' LABEL = 'GitHub' DEFAULT_SCOPE = ''
[docs] def redirect_url(self, state=None): assert GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, \ "Please fill in the github_client_id and github_client_secret files in your app's root directory." return GET_AUTH_CODE_URL % { 'client_id': GITHUB_CLIENT_ID, 'redirect_uri': urllib.parse.quote_plus(self.to_url()), # TODO: does GitHub require non-empty state? 'state': urllib.parse.quote_plus(state or ''), 'scope': self.scope, }
[docs] @classmethod def button_html(cls, *args, **kwargs): return super(cls, cls).button_html( *args, input_style='background-color: #444444', **kwargs)
[docs] class Callback(views.Callback): """The OAuth callback. Fetches an access token and stores it. """
[docs] def dispatch_request(self): # handle errors error = request.values.get('error') if error: if error == 'access_denied': logger.info('User declined') return self.finish(None, state=request.values.get('state')) else: flask_util.error(f"{error} {request.values.get('error_description')}") # extract auth code and request access token auth_code = request.values['code'] data = { 'code': auth_code, 'client_id': GITHUB_CLIENT_ID, 'client_secret': GITHUB_CLIENT_SECRET, # redirect_uri here must be the same in the oauth code request! # (the value here doesn't actually matter since it's requested server side.) 'redirect_uri': request.base_url, } resp = util.requests_post(GET_ACCESS_TOKEN_URL, data=urllib.parse.urlencode(data)) resp.raise_for_status() resp = resp.text logger.debug(f'Access token response: {resp}') resp = urllib.parse.parse_qs(resp) error = resp.get('error') if error: flask_util.error(f"{error[0]} {resp.get('error_description')}") access_token = resp['access_token'][0] resp = GitHubAuth(access_token_str=access_token).post( API_GRAPHQL, json=GRAPHQL_USER).json() logger.debug(f'GraphQL data.viewer response: {resp}') user_json = resp['data']['viewer'] auth = GitHubAuth(id=user_json['login'], access_token_str=access_token, user_json=json_dumps(user_json)) auth.put() return self.finish(auth, state=request.values.get('state'))