"""Base OAuth flow views. Clients should use the individual site modules.
Example usage::
app = Flask()
app.add_url_rule('/start',
view_func=twitter.Start.as_view('start', '/callback'),
methods=['POST'])
app.add_url_rule('/callback',
view_func=twitter.Callback.as_view('callback', '/after'))
"""
import logging
import urllib.parse
import flask
from flask import request
from flask.views import View
from .webutil import util
logger = logging.getLogger(__name__)
[docs]
class BaseView(View):
"""Base view class. Provides the to() factory method.
Attributes:
DEFAULT_SCOPE (str): default OAuth scope(s) to request
SCOPE_SEPARATOR (str): used to separate multiple scopes
LABEL (str): human-readable label, eg 'Blogger'
NAME (str): module name; usually same as `__name__.split('.')[-1]`
to_path (str): the base redirect URL path for the OAuth callback
scope (str): OAuth scopes, comma-separated
"""
DEFAULT_SCOPE = ''
SCOPE_SEPARATOR = ','
LABEL = None
NAME = None
to_path = None
scope = None
def __init__(self, to_path, scopes=None):
super().__init__()
assert to_path
self.to_path = to_path
self.scope = self.make_scope_str(scopes)
[docs]
@classmethod
def make_scope_str(cls, extra):
"""Returns an OAuth scopes query parameter value.
Combines :attr:`DEFAULT_SCOPE` and extra.
Args:
extra (sequence of str, or None)
"""
if not extra:
return cls.DEFAULT_SCOPE
if not isinstance(extra, str):
extra = cls.SCOPE_SEPARATOR.join(extra)
return cls.SCOPE_SEPARATOR.join(util.trim_nulls((cls.DEFAULT_SCOPE, extra)))
[docs]
def to_url(self, state=None):
"""Returns a fully qualified callback URL based on ``to_path``.
Includes scheme, host, and optional state.
"""
url = urllib.parse.urljoin(request.host_url, self.to_path)
if state:
# unquote first or state will be double-quoted
state = urllib.parse.unquote_plus(state)
url = util.add_query_params(url, [('state', state)])
return url
[docs]
def request_url_with_state(self):
"""Returns the current request URL, with the state query param if provided."""
state = request.values.get('state')
if state:
return util.add_query_params(request.base_url, [('state', state)])
else:
return request.base_url
[docs]
class Start(BaseView):
"""Base class for starting an OAuth flow.
Users should use the :meth:`to` class method when using this view in a WSGI
application. See the file docstring for details.
If the ``state`` query parameter is provided in the request data, it will be
returned to the client in the OAuth callback view. If the ``scope`` query
parameter is provided, it will be added to the existing OAuth scopes.
Alternatively, clients may call :meth:`redirect_url` and HTTP 302 redirect to
it manually, which will start the same OAuth flow.
"""
[docs]
def dispatch_request(self):
scopes = set(request.values.getlist('scope'))
if self.scope:
scopes.add(self.scope)
self.scope = self.SCOPE_SEPARATOR.join(util.trim_nulls(scopes))
# str() is since WSGI middleware chokes on unicode redirect URLs :/ eg:
# InvalidResponseError: header values must be str, got 'unicode' (u'...') for 'Location'
# https://console.cloud.google.com/errors/CPafw-Gq18CrnwE
url = str(self.redirect_url(state=request.values.get('state')))
logger.info(f'Starting OAuth flow: redirecting to {url}')
return flask.redirect(url)
[docs]
def redirect_url(self, state=None):
"""Returns the local URL for the OAuth service to redirect back to.
Subclasses must implement this.
Args:
state (str): user-provided value to be returned as a query parameter in
the return redirect
"""
raise NotImplementedError()
[docs]
class Callback(BaseView):
"""Base OAuth callback view.
Users can use :meth:`to` when using this view in a WSGI application to make it
redirect to a given URL path on completion. See the file docstr for details.
Alternatively, you can subclass it and implement :meth:`finish`, which will be
called in the OAuth callback request directly, after the user has been
authenticated.
The auth entity and optional state parameter provided to Start will be
passed to :meth:`finish` or as query parameters to the redirect URL.
"""
[docs]
def finish(self, auth_entity, state=None):
"""Called when the OAuth flow is complete. Clients may override.
Args:
auth_entity (models.BaseAuth): resulting auth entity, or None if the user
declined the site's OAuth authorization request.
state (str): passed to :meth:`Start.redirect_url`
Returns:
werkzeug.wrappers.Response:
"""
if auth_entity is None:
params = [('declined', True)]
else:
params = [('auth_entity', auth_entity.key.urlsafe().decode()),
('state', state)]
try:
token = auth_entity.access_token()
if isinstance(token, str):
params.append(('access_token', token))
elif token:
params += [('access_token_key', token[0]),
('access_token_secret', token[1])]
except NotImplementedError:
logger.info('access_token() not implemented')
try:
token = auth_entity.refresh_token
params.append(('refresh_token', token))
except AttributeError:
logger.info('refresh_token not included')
url = util.add_query_params(self.to_path, params)
logger.info(f'Finishing OAuth flow: redirecting to {url}')
return flask.redirect(url)