Awesome
<img src="https://raw.github.com/snarfed/oauth-dropins/main/oauth_dropins/static/oauth_shiny.png" alt="OAuth logo" width="125" /> oauth-dropins
Drop-in Python OAuth for popular sites!
About
This is a collection of drop-in Python Flask views for the initial OAuth client flows for many popular sites, including Blogger, Disqus, Dropbox, Facebook, Flickr, GitHub, Google, IndieAuth, Instagram, LinkedIn, Mastodon, Medium, Tumblr, Twitter, and WordPress.com.
oauth-dropins stores user credentials in Google Cloud Datastore. It's primarily designed for Google App Engine, but it can be used in any Python web application, regardless of host or framework.
- Available on PyPi. Install with
pip install oauth-dropins
. - Getting started docs.
- Reference docs.
- Demo app at oauth-dropins.appspot.com.
- Source code on GitHub.
This software is released into the public domain. See LICENSE for details.
Quick start
Here's a full example of using the GitHub drop-in.
-
Install oauth-dropins with
pip install oauth-dropins
. -
Put your GitHub OAuth application's ID and secret in two plain text files in your app's root directory,
github_client_id
andgithub_client_secret
. (If you use git, you'll probably also want to add them to your.gitignore
.) -
Create a
github_oauth.py
file with these contents:from oauth_dropins import github from app import app # ...or wherever your Flask app is app.add_url_rule('/start', view_func=github.Start.as_view('start', '/callback'), methods=['POST']) app.add_url_rule('/callback', view_func=github.Callback.as_view('callback', '/after'))
Voila! Send your users to /github/start
when you want them to connect their GitHub account to your app, and when they're done, they'll be redirected to /after?access_token=...
in your app.
All of the sites provide the same API. To use a different one, just import the site module you want and follow the same steps. The filenames for app keys and secrets also differ by site; see each site's .py
file for its filenames.
Usage details
There are three main parts to an OAuth drop-in: the initial redirect to the site itself, the redirect back to your app after the user approves or declines the request, and the datastore entity that stores the user's OAuth credentials and helps you use them. These are implemented by Start
and Callback
, which are Flask View classes, and auth entities, which are Google Cloud Datastore ndb models.
Start
This view class redirects you to an OAuth-enabled site so it can ask the user to grant your app permission. It has two useful methods:
-
The constructor,
__init__(self, to_path, scopes=None)
.to_path
is the OAuth callback, ie URL path on your site that the site's OAuth flow should redirect back to after it's done. This is handled by aCallback
view in your application, which needs to handle theto_path
route.If you want to add OAuth scopes beyond the default one(s) needed for login, you can pass them to the
scopes
kwarg as a string or sequence of strings, or include them in thescopes
query parameter in the POST request body. This is supported in most sites, but not all.Some OAuth 1 sites support alternatives to scopes. For Twitter, the
Start
constructor takes an additionalaccess_type
kwarg that may beread
orwrite
. It's passed through to Twitterx_auth_access_type
. For Flickr,Start
accepts aperms
POST query parameter that may beread
,write
ordelete
; it's passed through to Flickr unchanged. (Flickr claims it's optional, but sometimes breaks if it's not provided.) -
redirect_url(state=None)
returns the URL to redirect to at the site to initiate the OAuth flow.Start
will redirect here automatically if it's used in a WSGI application, but you can call this manually if you want to control that redirect yourself:
import flask
class MyView(Start):
def dispatch_request(self):
...
flask.redirect(self.redirect_url())
Callback
This class handles the HTTP redirect back to your app after the user has granted or declined permission. It also has two useful methods:
-
The constructor,
__init__(self, to_path, scopes=None)
.to_path
is the URL path on your site that users should be redirected to after the callback view is done. It will include astate
query parameter with the value provided toStart
. It will also include an OAuth token in its query parameters, eitheraccess_token
for OAuth 2.0 oraccess_token_key
andaccess_token_secret
for OAuth 1.1. It will also include anauth_entity
query parameter with the string key of an auth entity that has more data (and functionality) for the authenticated user. If the user declined the OAuth authorization request, the only query parameter besidesstate
will bedeclined=true
. -
finish(auth_entity, state=None)
is run in the initial callback request after the OAuth response has been processed.auth_entity
is the newly created auth entity for this connection, orNone
if the user declined the OAuth authorization request.By default,
finish
redirects toto_path
, but you can subclassCallback
and override it to run your own code instead of redirecting:
class MyCallback(github.Callback):
def finish(self, auth_entity, state=None):
super().finish(auth_entity, state=state) # ignore returned redirect
self.response.write('Hi %s, thanks for connecting your %s account.' %
(auth_entity.user_display_name(), auth_entity.site_name()))
Auth entities
Each site defines a Google Cloud Datastore ndb.Model class that stores each user's OAuth credentials and other useful information, like their name and profile URL. The class name is generally of the form <em>Site</em>Auth, e.g. GitHubAuth
. Here are the useful methods:
-
site_name()
returns the human-readable string name of the site, e.g. "Facebook". -
user_display_name()
returns a human-readable string name for the user, e.g. "Ryan Barrett". This is usually their first name, full name, or username. -
access_token()
returns the OAuth access token. For OAuth 2 sites, this is a single string. For OAuth 1.1 sites (currently just Twitter, Tumblr, and Flickr), this is a(string key, string secret)
tuple.
The following methods are optional. Auth entity classes usually implement at least one of them, but not all.
-
api()
returns a site-specific API object. This is usually a third party library dedicated to the site, e.g. Tweepy or python-instagram. See the site class's docstring for details. -
urlopen(data=None, timeout=None)
wrapsurlopen()
and adds the OAuth credentials to the request. Use this for making direct HTTP request to a site's REST API. Some sites may provideget()
instead, which wrapsrequests.get()
.
Troubleshooting/FAQ
-
If you get this error:
bash: ./bin/easy_install: ...bad interpreter: No such file or directory
You've probably hit this virtualenv bug: virtualenv doesn't support paths with spaces.
The easy fix is to recreate the virtualenv in a path without spaces. If you can't do that, then after creating the virtualenv, but before activating it, edit the activate, easy_install and pip files in local/bin/
to escape any spaces in the path.
For example, in activate
, VIRTUAL_ENV=".../has space/local"
becomes VIRTUAL_ENV=".../has\ space/local"
, and in pip
and easy_install
the first line changes from #!".../has space/local/bin/python"
to #!".../has\ space/local/bin/python"
.
This should get virtualenv to install in the right place. If you do this wrong at first, you'll have installs in eg /usr/local/lib/python3.7/site-packages
that you need to delete, since they'll prevent virtualenv from installing into the local site-packages
.
-
If you see errors importing or using
tweepy
, it may be becausesix.py
isn't installed. Trypip install six
manually.tweepy
does includesix
in its dependencies, so this shouldn't be necessary. Please let us know if it happens to you so we can debug! -
If you get an error like this:
Running setup.py develop for gdata ... error: option --home not recognized ... InstallationError: Command /usr/bin/python -c "import setuptools, tokenize; __file__='/home/singpolyma/src/bridgy/src/gdata/setup.py'; exec(compile(getattr(tokenize, 'open', open)(__file__).read().replace('\r\n', '\n'), __file__, 'exec'))" develop --no-deps --home=/tmp/tmprBISz_ failed with error code 1 in .../src/gdata
...you may be hitting Pip bug 1833. Are you passing -t
to pip install
? Use the virtualenv instead, it's your friend. If you really want -t
, try removing the -e
from the lines in requirements.txt
that have it.
Changelog
6.5 - unreleased
- IndieAuth:
- Bug fix: handle relative URLs in
Link
headers. (Thanks catgirlinspace!)
- Bug fix: handle relative URLs in
6.4 - 2024-06-24
Misc webutil updaates.
6.3 - 2024-03-15
- Bluesky:
- Normalize handles to lower case, remove leading
@
(bridgy#1667).
- Normalize handles to lower case, remove leading
Miscellaneous changes in webutil
.
6.2 - 2023-09-15
Miscellaneous changes in webutil
.
6.1 - 2023-03-22
Non-breaking changes:
- IndieAuth:
- Store access token and refresh token in
IndieAuth
datastore entities.
- Store access token and refresh token in
- Flickr:
- Handle errors from initial OAuth 1.0 authorization request.
6.0 - 2022-12-03
Breaking changes:
- Remove
webutil.handlers
, which was based on the largely unmaintainedwebapp2
. All known clients have migrated to Flask andwebutil.flask_util
. - Drop Python 3.6 support. Python 3.7 is now the minimum required version.
Non-breaking changes:
- Add new
twitter_v2
module for Twitter's new OAuth 2 with PKCE support and v2 API. - IndieAuth:
- Add support for authorization endpoints, along with existing token endpoint support. Thanks @jamietanna! (#284)
- Blogger:
- Fix bug when user approves the OAuth prompt but has no Blogger blogs. Instead of crashing, we now redirect to the callback with
declined=True
, which is still wrong, but less bad.
- Fix bug when user approves the OAuth prompt but has no Blogger blogs. Instead of crashing, we now redirect to the callback with
- Mastodon:
- Change
MastodonAuth.access_token_str
from ndbTextProperty
toStringProperty
so that it's indexed in the Datastore. - When the callback gets an invalid
state
parameter, return HTTP 400 instead of raisingJSONDecodeError
.
- Change
- Misc webutil updates.
5.0 - 2022-03-23
Breaking changes:
- Drop Python 3.5 support. Python 3.6 is now the minimum required version.
Non-breaking changes:
- Switch from app_server to
flask run
for local development. - Add
webutil.util.set_user_agent
to setUser-Agent
header to be sent with all HTTP requests.
4.0 - 2021-09-15
Breaking changes:
-
Migrate from webapp2 to Flask. webapp2 had a good run, but it's no longer actively developed, and Flask is one of the most widely adopted standalone web frameworks in the Python community.
-
Remove
to()
class methods. Instead, now pass redirect paths to Flask'sas_view()
function, eg:app = Flask() app.add_url_rule('/start', view_func=twitter.Callback.as_view('start', '/oauth_callback'))
-
Remove deprecated
blogger_v2
module alias. -
webutil
: migrate webapp2 HTTP request handlers in thehandlers
module -XrdOrJrdHandler
,HostMetaHandler
, andHostMetaXrdsHandler
- to Flask views in a newflask_util
module.
Non-breaking changes:
webutil
: implement Webmention protocol in newwebmention
module.webutil
: add misc Flask utilities and helpers in newflask_util
module.
3.1 - 2021-04-03
- Add Python 3.8 support, drop 3.3 and 3.4. Python 3.5 is now the minimum required version.
- Add Pixelfed support, heavily based on Mastodon.
- Add Reddit support. Thanks Will Stedden!
- WordPress.com:
- Handle errors from access token request.
3.0 - 2020-03-14
Breaking changes:
- Python 2 is no longer supported! Including the App Engine Standard Python 2 runtime. On the plus side, the Python 3 runtimes, both Standard and Flexible, are now supported.
- Replace
handlers.memcache_response()
, which used Python 2 App Engine's memcache service, withcache_response()
, which uses local runtime memory. - Remove the
handlers.TemplateHandler.USE_APPENGINE_WEBAPP
toggle to use Python 2 App Engine'sgoogle.appengine.ext.webapp2.template
instead of Jinja. - Blogger:
- Login is now based on Google Sign-In. The
api_from_creds()
,creds()
, andhttp()
methods have been removed. Use the remainingapi()
method to get aBloggerClient
, oraccess_token()
to make API calls manually.
- Login is now based on Google Sign-In. The
- Google:
- Replace
GoogleAuth
with the newGoogleUser
NDB model class, which doesn't depend on the deprecated oauth2client. - Drop
http()
method (which returned anhttplib2.Http
).
- Replace
- Mastodon:
StartHandler
: dropAPP_NAME
/APP_URL
class attributes andapp_name
/app_url
kwargs in theto()
method and replace them with newapp_name()
/app_url()
methods that subclasses should override, since they often depend on WSGI environment variables likeHTTP_HOST
andSERVER_NAME
that are available during requests but not at runtime startup.
webutil
:- Drop
handlers.memcache_response()
since the Python 3 runtime doesn't include memcache. - Drop
handlers.TemplateHandler
support forwebapp2.template
viaUSE_APPENGINE_WEBAPP
, since the Python 3 runtime doesn't includewebapp2
built in. - Remove
cache
andfail_cache_time_secs
kwargs fromutil.follow_redirects()
. Caching is now built in. You can bypass the cache withfollow_redirects.__wrapped__()
. Details.
- Drop
Non-breaking changes:
- Add Meetup support. (Thanks Jamie Tanna!)
- Blogger, Google:
- The
state
query parameter now works!
- The
- Add new
outer_classes
kwarg tobutton_html()
for the outer<div>
, eg as Bootstrap columns. - Add new
image_file
kwarg toStartHandler.button_html()
2.2 - 2019-11-01
- Add LinkedIn and Mastodon!
- Add Python 3.7 support, and improve overall Python 3 compatibility.
- Add new
button_html()
method to allStartHandler
classes. Generates the same button HTML and styling as on oauth-dropins.appspot.com. - Blogger: rename module from
blogger_v2
toblogger
. Theblogger_v2
module name is still available as an alias, implemented via symlink, but is now deprecated. - Dropbox: fix crash with unicode header value.
- Google: fix crash when user object doesn't have
name
field. - Facebook: upgrade Graph API version from 2.10 to 4.0.
- Update a number of dependencies.
- Switch from Python's built in
json
module toujson
(built into App Engine) to speed up JSON parsing and encoding.
2.0 - 2019-02-25
- Breaking change: switch from Google+ Sign-In (which shuts down in March) to Google Sign-In. Notably, this removes the
googleplus
module and adds a newgoogle_signin
module, renames theGooglePlusAuth
class toGoogleAuth
, and removes itsapi()
method. Otherwise, the implementation is mostly the same. - webutil.logs: return HTTP 400 if
start_time
is before 2008-04-01 (App Engine's rough launch window).
1.14 - 2018-11-12
- Fix dev_appserver in Cloud SDK 219 /
app-engine-python
1.9.76 and onward. Background. - Upgrade
google-api-python-client
from 1.6.3 to 1.7.4 to stop using the global HTTP Batch endpoint. - Other minor internal updates.
1.13 - 2018-08-08
- IndieAuth: support JSON code verification responses as well as form-encoded (snarfed/bridgy#809).
1.12 - 2018-03-24
- More Python 3 updates and bug fixes in webutil.util.
1.11 - 2018-03-08
- Add GitHub!
- Facebook:
- Pass
state
to the initial OAuth endpoint directly, instead of encoding it into the redirect URL, so the redirect can match the Strict Mode whitelist.
- Pass
- Add Python 3 support to webutil.util!
- Add humanize dependency for webutil.logs.
1.10 - 2017-12-10
Mostly just internal changes to webutil to support granary v1.10.
1.9 - 2017-10-24
Mostly just internal changes to webutil to support granary v1.9.
- Flickr:
- Handle punctuation in error messages.
1.8 - 2017-08-29
- Facebook:
- Upgrade Graph API from v2.6 to v2.10.
- Flickr:
- Fix broken
FlickrAuth.urlopen()
method.
- Fix broken
- Medium:
- Bug fix for Medium OAuth callback error handling.
- IndieAuth:
- Store authorization endpoint in state instead of rediscovering it from
me
parameter, which is going away.
- Store authorization endpoint in state instead of rediscovering it from
1.7 - 2017-02-27
- Updates to bundled webutil library, notably WideUnicode class.
1.6 - 2016-11-21
- Add auto-generated docs with Sphinx. Published at oauth-dropins.readthedocs.io.
- Fix Dropbox bug with fetching access token.
1.5 - 2016-08-25
- Add Medium.
1.4 - 2016-06-27
- Upgrade Facebook API from v2.2 to v2.6.
1.3 - 2016-04-07
- Add IndieAuth.
- More consistent logging of HTTP requests.
- Set up Coveralls.
1.2 - 2016-01-11
- Flickr:
- Add upload method.
- Improve error handling and logging.
- Bug fixes and cleanup for constructing scope strings.
- Add developer setup and troubleshooting docs.
- Set up CircleCI.
1.1 - 2015-09-06
- Flickr: split out flickr_auth.py file.
- Add a number of utility functions to webutil.
1.0 - 2015-06-27
- Initial PyPi release.
Development
Pull requests are welcome! Feel free to ping me in #indieweb-dev with any questions.
First, fork and clone this repo. Then, install the Google Cloud SDK and run gcloud components install cloud-firestore-emulator
to install the Firestore emulator. Once you have them, set up your environment by running these commands in the repo root directory:
gcloud config set project oauth-dropins
git submodule init
git submodule update
python3 -m venv local
source local/bin/activate
pip install -r requirements.txt
Run the demo app locally with flask run
:
gcloud emulators firestore start --host-port=:8089 --database-mode=datastore-mode < /dev/null >& /dev/null &
GAE_ENV=localdev FLASK_ENV=development flask run -p 8080
To deploy to production:
gcloud -q beta app deploy --no-cache oauth-dropins *.yaml
The docs are built with Sphinx, including apidoc, autodoc, and napoleon. Configuration is in docs/conf.py
To build them, first install Sphinx with pip install sphinx
. (You may want to do this outside your virtualenv; if so, you'll need to reconfigure it to see system packages with python3 -m venv --system-site-packages local
.) Then, run docs/build.sh
.
Release instructions
Here's how to package, test, and ship a new release. (Note that this is largely duplicated in granary's readme too.)
- Run the unit tests.
source local/bin/activate.csh gcloud emulators firestore start --host-port=:8089 --database-mode=datastore-mode < /dev/null >& /dev/null & sleep 2s python -m unittest discover kill %1 deactivate
- Bump the version number in
setup.py
anddocs/conf.py
.git grep
the old version number to make sure it only appears in the changelog. Change the current changelog entry inREADME.md
for this new version from unreleased to the current date. - Build the docs. If you added any new modules, add them to the appropriate file(s) in
docs/source/
. Then run./docs/build.sh
. git commit -am 'release vX.Y'
- Upload to test.pypi.org for testing.
python setup.py clean build sdist setenv ver X.Y source local/bin/activate.csh twine upload -r pypitest dist/oauth-dropins-$ver.tar.gz
- Install from test.pypi.org.
cd /tmp python -m venv local source local/bin/activate.csh pip install --upgrade pip # mf2py 1.1.2 on test.pypi.org is broken :( pip install mf2py pip install -i https://test.pypi.org/simple --extra-index-url https://pypi.org/simple oauth-dropins deactivate
- Smoke test that the code trivially loads and runs.
Test code to paste into the interpreter:source local/bin/activate.csh python # run test code below deactivate
from oauth_dropins.webutil import util util.__file__ util.UrlCanonicalizer()('http://asdf.com') # should print 'https://asdf.com/' exit()
- Tag the release in git. In the tag message editor, delete the generated comments at bottom, leave the first line blank (to omit the release "title" in github), put
### Notable changes
on the second line, then copy and paste this version's changelog contents below it.git tag -a v$ver --cleanup=verbatim git push git push --tags
- Click here to draft a new release on GitHub. Enter
vX.Y
in the Tag version box. Leave Release title empty. Copy### Notable changes
and the changelog contents into the description text box. - Upload to pypi.org!
twine upload dist/oauth-dropins-$ver.tar.gz