Adding Digital Ocean to Flask-Dance

Posted on Fri 13 November 2020 in conveyor

In the last article we learned how to set up and use a Digital Ocean Oauth app. Now that we understand how that works, let's make all of that hard work more accessible to the rest of the Flask community. Flask-Dance is an extension that is used to complete the "OAuth Dance" with various services. Digital Ocean is not supported, yet. Fortunately, Flask-Dance has great documentation about how a new provider can be added.

Flask-Dance provider requirements

  1. You must create a Python file in this directory named after the provider: for example, my_provider.py.
  2. The file must declare a maintainer variable, with the name and email address of the maintainer of this configuration.
  3. The file must have a factory function that returns an instance of OAuth1ConsumerBlueprint or an instance of OAuth2ConsumerBlueprint. The factory function must be named after the provider: for example, make_my_provider_blueprint().
  4. The file must expose a variable named after the provider, which is a local proxy to the session attribute on the blueprint returned by the factory function.
  5. You must create a Python file in the tests/contrib directory named after your provider with a prefix of test_: for example, test_my_provider. This file must contain tests for the file you created in this directory.
  6. You must add your provider to the docs/providers.rst file, and ensure that your factory function has a RST-formatted docstring that the documentation can pick up.
  7. All automated tests must pass, and the test coverage must not drop.
  8. You must update the CHANGELOG.rst file to indicate that this provider was added.

Digital Ocean as a provider

The digitalocean.py file will take care of the first 4 requirements

from __future__ import unicode_literals

from flask_dance.consumer import OAuth2ConsumerBlueprint
from functools import partial
from flask.globals import LocalProxy, _lookup_app_object

try:
    from flask import _app_ctx_stack as stack
except ImportError:
    from flask import _request_ctx_stack as stack


__maintainer__ = "Michael Abrahamsen [email protected]>"


def make_digitalocean_blueprint(
    client_id=None,
    client_secret=None,
    scope=None,
    redirect_url=None,
    redirect_to=None,
    login_url=None,
    authorized_url=None,
    session_class=None,
    storage=None,
):
    """
    Make a blueprint for authenticating with Digital Ocean using OAuth 2.
    This requires a client ID and client secret from Digital Ocean.
    You should either pass them to this constructor, or make sure that your
    Flask application config defines them, using the variables
    :envvar:`DIGITALOCEAN_OAUTH_CLIENT_ID` and
    :envvar:`DIGITALOCEAN_OAUTH_CLIENT_SECRET`.

    Args:
        client_id (str): Client ID for your application on Digital Ocean
        client_secret (str): Client secret for your Digital Ocean application
        scope (str, optional): comma-separated list of scopes for the OAuth
            token. Digital Ocean uses space delimitation but comma deliminated
            values are used for library consistency and the commas are replaced
            with spaces when the blueprint is declared below
        redirect_url (str): the URL to redirect to after the authentication
            dance is complete
        redirect_to (str): if ``redirect_url`` is not defined, the name of the
            view to redirect to after the authentication dance is complete.
            The actual URL will be determined by :func:`flask.url_for`
        login_url (str, optional): the URL path for the ``login`` view.
            Defaults to ``/digitalocean``
        authorized_url (str, optional): the URL path for the ``authorized`` view.
            Defaults to ``/digitalocean/authorized``.
        session_class (class, optional): The class to use for creating a
            Requests session. Defaults to
            :class:`~flask_dance.consumer.requests.OAuth2Session`.
        storage: A token storage class, or an instance of a token storage
                class, to use for this blueprint. Defaults to
                :class:`~flask_dance.consumer.storage.session.SessionStorage`.

    :rtype: :class:`~flask_dance.consumer.OAuth2ConsumerBlueprint`
    :returns: A :ref:`blueprint <flask:blueprints>` to attach to your Flask app.
    """
    digitalocean_bp = OAuth2ConsumerBlueprint(
        "digitalocean",
        __name__,
        client_id=client_id,
        client_secret=client_secret,
        scope=scope.replace(",", " ") if scope else None,
        base_url="https://cloud.digitalocean.com/v1/oauth",
        authorization_url="https://cloud.digitalocean.com/v1/oauth/authorize",
        token_url="https://cloud.digitalocean.com/v1/oauth/token",
        redirect_url=redirect_url,
        redirect_to=redirect_to,
        login_url=login_url,
        authorized_url=authorized_url,
        session_class=session_class,
        storage=storage,
    )
    digitalocean_bp.from_config["client_id"] = "DIGITALOCEAN_OAUTH_CLIENT_ID"
    digitalocean_bp.from_config["client_secret"] = "DIGITALOCEAN_OAUTH_CLIENT_SECRET"

    @digitalocean_bp.before_app_request
    def set_applocal_session():
        ctx = stack.top
        ctx.digitalocean_oauth = digitalocean_bp.session

    return digitalocean_bp


digitalocean = LocalProxy(partial(_lookup_app_object, "digitalocean_oauth"))

Creating tests

Most of the tests were copied from another provider. Two additional tests were added that were specific to the digital ocean provider. The scope is passed in as a comma deliminated string to keep with the standards of the Flask-Dance library. Digital Ocean expects the scope to be deliminated by spaces. This is something I plan to ask the project maintainer about. By default the scope is not set but it may make sense to have the default set to "read".

def test_scope_list_is_valid_with_single_scope():
    digitalocean_bp = make_digitalocean_blueprint(client_id="foobar",
                                                  client_secret="supersecret",
                                                  scope="read")
    assert digitalocean_bp.session.scope == "read"


def test_scope_list_is_converted_to_space_delimited():
    digitalocean_bp = make_digitalocean_blueprint(client_id="foobar",
                                                  client_secret="supersecret",
                                                  scope="read,write")
    assert digitalocean_bp.session.scope == "read write"

Testing the new provider with an example application

An example application has been created and can be found at https://github.com/mikeabrahamsen/flask-dance-digitalocean-oauth-example

import os
import requests
from flask import Flask, request, render_template, redirect, url_for
from flask_dance.contrib.digitalocean import (make_digitalocean_blueprint,
                                              digitalocean)

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "supersecretkey")
app.config["DIGITALOCEAN_OAUTH_CLIENT_ID"] = os.environ.get("DIGITALOCEAN_OAUTH_CLIENT_ID")
app.config["DIGITALOCEAN_OAUTH_CLIENT_SECRET"] = os.environ.get("DIGITALOCEAN_OAUTH_CLIENT_SECRET")

digitalocean_bp = make_digitalocean_blueprint(
    scope="read",
    redirect_url="http://localhost:5000/digitalocean")
app.register_blueprint(digitalocean_bp, url_prefix="/")


@app.route('/', methods=['GET'])
def index():
    oauth_url = None
    if not digitalocean.authorized:
        oauth_url = url_for("digitalocean.login")
        return redirect(oauth_url)
    else:
        return redirect(url_for("droplets"))
    error = request.args.get('error', None)

    return render_template(
        'index.html',
        oauth_url=oauth_url,
        error=error
    )


@app.route('/droplets', methods=['GET'])
def droplets():
    error = None
    try:
        token = digitalocean.access_token

        headers = {"Authorization": f"Bearer {token}"}
        api_droplet_list_url = "https://api.digitalocean.com/v2/droplets"

        servers = requests.get(api_droplet_list_url, headers=headers,
                               timeout=3).json()
        return render_template(
            'server_list.html',
            servers=servers.get('droplets', None)
        )
    except TypeError as e:
        error = f'Error: {e}'
        print(error)
    return redirect(url_for('.index', error=error))