OAuth with Digital Ocean and Flask

Posted on Tue 03 November 2020 in conveyor

The OAuth flow can be confusing the first time you do it. This article and example project should help you feel more comfortable specifically for people who would like to use OAuth with Digital Ocean.

The code for this application can be found at https://github.com/mikeabrahamsen/flask-digitalocean-oauth-example

Conveyor.dev uses an OAuth application to make requests to the Digital Ocean API without having to manually create an access key. Instead, we can authenticate with Digital Ocean within Conveyor, allowing the Conveyor OAuth application to make Digital Ocean API requests for you.

Don't know what OAuth is? OAuth allows a third party application to make API requests without you having to give your Digital Ocean credentials to Conveyor. Here is an explaination from Digital Ocean:

OAuth 2 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service, such as Facebook, GitHub, and DigitalOcean. It works by delegating user authentication to the service that hosts the user account, and authorizing third-party applications to access the user account. OAuth 2 provides authorization flows for web and desktop applications, and mobile devices.

Create an OAuth application with Digital Ocean

You will first need to create an OAuth application

  1. Visit Digital Ocean API application page
  2. Click Register OAuth Application

Use the following information to create an application that you can run locally to test the project code.

Register OAuth App Details

  • Name: ConveyorOAuthDigitalOceanExample : Give a descriptive name for your application.
  • Homepage URL: This is the homepage for your Flask application
  • Description: A description of your application
  • Callback URL: After the initial OAuth request has been sent to Digital Ocean, this is the URL that Digital Ocean will redirect to and allow us to finish the OAuth process.

Now that we have our OAuth Application registered with Digital Ocean we can create the endpoints in our Flask application to authenticate.

The Flask app

The Flask app will consist of two routes:

  • index: Shows a link to start the OAuth process with Digital Ocean
  • droplets: Lists all droplets after the OAuth process has completed successfully

You will need your DIGITAL_OCEAN_CLIENT_ID and DIGITAL_OCEAN_CLIENT_SECRET that are provided to you after creating an application with Digital Ocean.

import os
import requests
from flask import Flask, request, render_template, redirect, url_for
from digital_ocean_client import DigitalOceanClient, ApiError

app = Flask(__name__)

DIGITAL_OCEAN_CLIENT_ID = os.environ.get('DIGITAL_OCEAN_CLIENT_ID')
DIGITAL_OCEAN_CLIENT_SECRET = os.environ.get('DIGITAL_OCEAN_CLIENT_SECRET')


@app.route('/', methods=['GET'])
def index():
    error = request.args.get('error', None)
    client = DigitalOceanClient(DIGITAL_OCEAN_CLIENT_ID,
                                DIGITAL_OCEAN_CLIENT_SECRET)
    return render_template(
        'index.html',
        oauth_url=client.get_authorize_oauth_url(),
        error=error
    )


@app.route('/digitalocean', methods=['GET'])
def droplets():
    code = request.args.get('code', None)
    error = None
    if code:
        try:
            client = DigitalOceanClient(DIGITAL_OCEAN_CLIENT_ID,
                                        DIGITAL_OCEAN_CLIENT_SECRET)
            token, scope, expiry, refresh_token = client.finish_oauth(code)
            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 ApiError as e:
            error = f'API Error: {e}'
        except TypeError as e:
            error = f'Error: {e}'
    return redirect(url_for('.index', error=error))

Digital Ocean client

The DigitalOceanClient handles generating the OAuth urls, finishing the OAuth request to return a token, and handles refreshing tokens.

class DigitalOceanClient:
    def __init__(self, client_id, client_secret):
        self.base_url = 'https://cloud.digitalocean.com/v1/oauth'
        self.redirect_uri = 'http://localhost:5000/digitalocean'
        self.authorize_url = f'{self.base_url}/authorize'
        self.client_id = client_id
        self.client_secret = client_secret

    # generate the OAuth url
    def get_authorize_oauth_url(self):
        scope = 'read%20write'

        full_url = (
            f'{self.authorize_url}?redirect_uri={self.redirect_uri}'
            f'&client_id={self.client_id}'
            f'&scope={scope}&response_type=code'
        )
        return full_url

    # with our code from Digital Ocean, get a token from 
    # 'https://cloud.digitalocean.com/v1/oauth/token'
    def finish_oauth(self, code):
        url = f'{self.base_url}/token'
        data = {
            'grant_type': 'authorization_code',
            'client_id': self.client_id,
            'client_secret': self.client_secret,
            'code': code,
            'redirect_uri': self.redirect_uri,
        }
        r = requests.post(url, data=data)

        if r.status_code != 200:
            raise ApiError('Oauth Token Exchange Failed', r)

        token = r.json()['access_token']
        scope = r.json()['scope']
        expiry = datetime.now() + timedelta(seconds=r.json()['expires_in'])
        refresh_token = r.json()['refresh_token']

        return token, scope, expiry, refresh_token

Making a request to the API

With our Digital Ocean token we can make a request to the API. In this example application we get our token from Digital Ocean and immediately use that token to request a list of droplets from the api. The result is a simple list of droplets with their name and current status. From my account the following was shown:

conveyor-test-droplet - Status: active
conveyor-web-server - Status: active

Refresh Tokens

In a real application, tokens and their expiry would be stored in order to use the token again. In this application we are generating a new token each time the link is pressed and no tokens are stored. Here is the code that will deal with a refreshing a token.

# from digital_ocean_client
def refresh_oauth_token(self, refresh_token):
    r = requests.post(f'{self.base_url}/token', data={
        'grant_type': 'refresh_token',
        'refresh_token': refresh_token,
    })

    if r.status_code != 200:
        raise ApiError('Refresh failed', r)

    try:
        token = r.json()['access_token']
        scope = r.json()['scope']
        expiry = datetime.now() + timedelta(seconds=r.json()['expires_in'])
        refresh_token = r.json()['refresh_token']
    except KeyError:
        raise ApiError('Failed to get auth token')

    return token, scope, expiry, refresh_token

This example project can be found on Github https://github.com/mikeabrahamsen/flask-digitalocean-oauth-example