-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
181 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
"""Medium OAuth drop-in. | ||
API docs: | ||
https://github.com/Medium/medium-api-docs#contents | ||
https://medium.com/developers/welcome-to-the-medium-api-3418f956552 | ||
Medium doesn't let you use a localhost redirect URL. : / A common workaround is | ||
to map an arbitrary host to localhost in your /etc/hosts, e.g.: | ||
127.0.0.1 my.dev.com | ||
You can then test on your local machine by running dev_appserver and opening | ||
http://my.dev.com:8080/ instead of http://localhost:8080/ . | ||
""" | ||
|
||
import json | ||
import logging | ||
import urllib | ||
|
||
import appengine_config | ||
import handlers | ||
from models import BaseAuth | ||
from webutil import util | ||
|
||
from google.appengine.ext import ndb | ||
from webob import exc | ||
|
||
# medium is behind cloudflare, which often blocks requests's user agent, so set | ||
# our own. | ||
USER_AGENT = 'oauth-dropins (https://oauth-dropins.appspot.com/)' | ||
|
||
# URL templates. Can't (easily) use urllib.urlencode() because I want to keep | ||
# the %(...)s placeholders as is and fill them in later in code. | ||
GET_AUTH_CODE_URL = str('&'.join(( | ||
'https://medium.com/m/oauth/authorize?' | ||
'client_id=%(client_id)s', | ||
# https://github.com/Medium/medium-api-docs#user-content-21-browser-based-authentication | ||
# basicProfile, listPublications, publishPost, uploadImage | ||
'scope=%(scope)s', | ||
# redirect_uri here must be the same in the access token request! | ||
'redirect_uri=%(redirect_uri)s', | ||
'state=%(state)s', | ||
'response_type=code', | ||
))) | ||
|
||
API_BASE = 'https://api.medium.com/v1/' | ||
GET_ACCESS_TOKEN_URL = API_BASE + 'tokens' | ||
API_USER_URL = API_BASE + 'me' | ||
|
||
|
||
class MediumAuth(BaseAuth): | ||
"""An authenticated Medium user. | ||
Provides methods that return information about this user and make OAuth-signed | ||
requests to the Medium REST API. Stores OAuth credentials in the datastore. | ||
See models.BaseAuth for usage details. | ||
Medium-specific details: implements get() but not urlopen(), http(), or api(). | ||
The key name is the username. | ||
""" | ||
access_token_str = ndb.StringProperty(required=True) | ||
user_json = ndb.TextProperty() | ||
|
||
def site_name(self): | ||
return 'Medium' | ||
|
||
def user_display_name(self): | ||
"""Returns the user's full name or username. | ||
""" | ||
if self.user_json: | ||
data = json.loads(self.user_json).get('data') | ||
if data: | ||
return data.get('name') or data.get('username') | ||
|
||
return self.key.string_id() | ||
|
||
def access_token(self): | ||
"""Returns the OAuth access token string. | ||
""" | ||
return self.access_token_str | ||
|
||
def get(self, *args, **kwargs): | ||
"""Wraps requests.get() and adds the Bearer token header. | ||
""" | ||
kwargs.setdefault('headers', {}).update({ | ||
'Authorization': 'Bearer ' + self.access_token_str, | ||
'User-Agent': USER_AGENT, | ||
}) | ||
resp = util.requests_get(*args, **kwargs) | ||
try: | ||
resp.raise_for_status() | ||
except BaseException, e: | ||
util.interpret_http_exception(e) | ||
raise | ||
return resp | ||
|
||
|
||
class StartHandler(handlers.StartHandler): | ||
"""Starts Medium auth. Requests an auth code and expects a redirect back. | ||
""" | ||
DEFAULT_SCOPE = 'basicProfile' | ||
|
||
def redirect_url(self, state=None): | ||
assert (appengine_config.MEDIUM_CLIENT_ID and | ||
appengine_config.MEDIUM_CLIENT_SECRET), ( | ||
"Please fill in the medium_client_id and " | ||
"medium_client_secret files in your app's root directory.") | ||
return str(GET_AUTH_CODE_URL % { | ||
'client_id': appengine_config.MEDIUM_CLIENT_ID, | ||
'redirect_uri': urllib.quote_plus(self.to_url()), | ||
# Medium requires non-empty state | ||
'state': urllib.quote_plus(state if state else 'unused'), | ||
'scope': self.scope, | ||
}) | ||
|
||
|
||
class CallbackHandler(handlers.CallbackHandler): | ||
"""The OAuth callback. Fetches an access token and stores it. | ||
""" | ||
|
||
def get(self): | ||
# handle errors | ||
error = self.request.get('error') | ||
if error: | ||
error_description = urllib.unquote_plus( | ||
self.request.get('error_description', '')) | ||
if error == 'access_denied': | ||
logging.info('User declined: %s', error_description) | ||
self.finish(None, state=self.request.get('state')) | ||
return | ||
else: | ||
raise exc.HTTPBadRequest('Error: %s %s ' % (error, error_description)) | ||
|
||
# extract auth code and request access token | ||
auth_code = util.get_required_param(self, 'code') | ||
data = { | ||
'code': auth_code, | ||
'client_id': appengine_config.MEDIUM_CLIENT_ID, | ||
'client_secret': appengine_config.MEDIUM_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': self.request.path_url, | ||
'grant_type': 'authorization_code', | ||
} | ||
resp = util.requests_post( | ||
GET_ACCESS_TOKEN_URL, data=urllib.urlencode(data), | ||
headers={'User-Agent': USER_AGENT}).text | ||
logging.debug('Access token response: %s', resp) | ||
|
||
try: | ||
resp = json.loads(resp) | ||
access_token = resp['access_token'] | ||
# TODO: handle refresh token | ||
except: | ||
logging.exception('Could not decode JSON') | ||
raise | ||
|
||
user_json = MediumAuth(access_token_str=access_token).get(API_USER_URL).text | ||
username = json.loads(user_json)['data']['username'] | ||
auth = MediumAuth(id=username, access_token_str=access_token, user_json=user_json) | ||
auth.put() | ||
|
||
self.finish(auth, state=self.request.get('state')) |
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters