How to Create an Oauth2 Authentication Flow With Koa

Contents

Hello everyone, in this tutorial we will create a CRUD app in Storyblok and authenticate it with OAuth2. This is the app we will build:

You can clone this tutorial at https://github.com/storyblok/storyblok-koa-oauth-example

Environment Setup

Requirements

  • Node.Js and NPM installed (Or yarn to manage your packages)

  • A Storyblok account to manage content

  • Basic knowledge about Oauth2

  • Read our documentation about the OAuth2 flow on Storyblok

Creating a Storyblok Application

To create an application in Storyblok, you need to be a part of our Partner Program. In our Partner portal, you will see the Apps section on the left panel. After clicking the New button, you will see the following modal:


Fill out all of the required information (for now, only name and slug is necessary) and click on Save. After this, you will see a detail page for your App. On this page, access the Edit page to view the OAuth credentials and configure the URLs to your App. For now, store your Client ID and Client Secret credentials. Above, you can see where you will find these credentials:

Starting the API Development

For this tutorial we will use the grant package and the Koa library to develop our API and abstract some tasks in the OAuth2 workflow.

Setup the project

First, create a directory and start a project with:

yarn init # or npm init

We will use yarn, but you can use npm as well :)

Then, we will install the dependencies:

yarn add koa && 
yarn add grant-koa && 
yarn add storyblok-js-client &&
yarn add axios &&
yarn add koa-router &&
yarn add koa-static &&
yarn add koa-ejs &&
yarn add koa-session &&
yarn add koa-qs &&
yarn add qs &&
yarn add dotenv &&
yarn add koa-bodyparser &&
yarn add crypto-js &&
yarn add uuid

After, create an .env file to put your credentials. The .env file should look like this:

CONFIDENTIAL_CLIENT_ID="Your-Client-ID"
CONFIDENTIAL_CLIENT_SECRET="Your-Client-Secret"
CONFIDENTIAL_CLIENT_REDIRECT_URI=http://yourid.ngrok.io/connect/storyblok/callback

With the basic settings let's develop our app already authenticating with Storyblok using OAuth protocol.

Develop the Application

Setup initial code

Create a app.js file in the root folder on your project. Below is the initial code for this file:

// setting the environment variables
require('dotenv').config()

// the nodejs imports
const path = require('path')

// the koa imports
const Koa = require('koa')
const session = require('koa-session')
const Router = require('koa-router')
const koaqs = require('koa-qs')
const render = require('koa-ejs')
const serve = require('koa-static')
const bodyParser = require('koa-bodyparser')

// Instantiating our app
const app = new Koa()

// setup application key for session
app.keys = ['grant', 'storyblok']

// use koa middlewares
app.use(bodyParser())
app.use(session(app))
koaqs(app)

// setup the views folder as a folder for our templates
render(app, {
  root: path.join(__dirname, 'views'),
  layout: 'template',
  viewExt: 'html',
  cache: false,
  debug: false,
  async: true
})

// use koa-static middleware for serve the public folder as static folder
app.use(serve(path.join(__dirname, '/public')))

// Initializing the router
const router = new Router()

router
  .get('/', async ctx => {
    ctx.body = 'Server started!'
  })

// use the router instance and initialize the server
app
  .use(router.routes())
  .use(router.allowedMethods())
  .listen(3000)

console.log('Server listen on port 3000')

After this, run the node app.js command in your terminal and open your browser at http://localhost:3000/. You will see a Server Started! message.

Setup grant for OAuth2 flow

The first step is to create a grant configuration and setup the callback route. Let's code:

Create a utils folder and put a factory-grant-config.js file in it. This file will export a factory function for the grant config.

const SHA256 = require('crypto-js/sha256')
const { v4: uuid } = require('uuid')

const getConfig = () => {
  const codeIdentifier = uuid()

  return {
    defaults: {
      origin: 'http://localhost:3000'
    },
    // we need to create a custom provider
    // https://github.com/simov/grant#custom-providers
    storyblok: {
      key: process.env.CONFIDENTIAL_CLIENT_ID,
      secret: process.env.CONFIDENTIAL_CLIENT_SECRET,
      redirect_uri: process.env.CONFIDENTIAL_CLIENT_REDIRECT_URI,
      callback: '/callback',
      authorize_url: 'https://app.storyblok.com/oauth/authorize',
      access_url: 'https://app.storyblok.com/oauth/token',
      oauth: 2,
      scope: 'read_content write_content',
      // create some custom parameters to send in URL
      // https://github.com/simov/grant#custom-parameters
      // this additional parameters are explain in Storyblok OAuth documentation
      custom_params: {
        code_chalenge: SHA256(codeIdentifier).toString(),
        code_chalenge_method: 'S256',
        state: codeIdentifier
      }
    }
  }
}

module.exports = getConfig

After this, edit the app.js file to import the grant-koa package and the factory function. The code will be the following:

// for example, after the previous imports
const grant = require('grant').koa()
const grantConfig = require('./utils/factory-grant-config')()

// after the koa middlewares
// use the grant middleware with the grantConfig
app.use(grant(grantConfig))

// in registering routes section
router
  .get('/', async ctx => {
    ctx.body = 'Server started!'
  })
  // let's register a callback route to get the token data
  .get('/callback', ctx => {
    ctx.body = {
      data: ctx.query
    }
  })

Now install your application on Storyblok space. After you install the application, open your browser in http://localhost:3000/connect/storyblok (this URL is explained in the documentation). Your browser will redirect to an authorization URL and open a page to request read and write approval in your space:

After approval, the browser will redirect to the callback route and will show the grant response data with the access and refresh tokens.

Recovering the space_id

When using the complete grant workflow, we don't have control of authorization and access routes. That's a problem because the access url redirects to the application with the space_id parameter in the URL. The space_id parameter is very important, so we need to make some changes in our backend to get and store the space_id in the session.

First, we need to change the redirect_uri variable on our .env file. Change this variable to http://localhost:3000/callback. Set the same URL on the Edit Application form on Storyblok in the "Oauth2 callback url" field.

After this, rewrite the callback route to get the code and space_id from authorization URL and get the access and refresh tokens from access URL. Create a new file in utils folder called get-token-from-code.js. This file will contain the following content:

const axios = require('axios')
const qs = require('qs')

/**
 * The getTokenFromCode function will be receive an acess_url
 * and a config object to make a request to the access_url
 * to get access and refresh tokens
 * 
 * An example of config to get access token:
 * {
 *   grant_type: 'authorization_code',
 *   code: 'XXXXXXX',
 *   client_id: 'YYYYYYY',
 *   client_secret: 'ZZZZZZZ',
 *   redirect_uri: 'WWWWWWW'
 * }
 * 
 * An another example of config to refresh the acess token:
 * {
 *   grant_type: 'refresh_token',
 *   refresh_token: 'XXXXXXX', // instead of using code
 *   client_id: 'YYYYYYY',
 *   client_secret: 'ZZZZZZZ',
 *   redirect_uri: 'WWWWWWW'
 * }
 * 
 * @method getTokenFromCode
 * @param  {String} access_url
 * @param  {Object} config
 * @return {Promise<Object>}
 */
const getTokenFromCode = (access_url, config) => {
  return new Promise((resolve, reject) => {
    const requestConfig = {
      url: access_url,
      method: 'POST',
      headers: {
        'content-type': 'application/x-www-form-urlencoded'
      },
      data: qs.stringify({
        ...config
      })
    }
  
    axios(requestConfig)
      .then(response => {
        const { access_token, refresh_token } = response.data
  
        resolve({
          access_token,
          refresh_token: refresh_token || config.refresh_token
        })
      })
      .catch(reject)
  })
}

module.exports = getTokenFromCode

Then, edit the callback route to use this function to get access and refresh tokens:

// in top of your code
const getTokenFromCode = require('./utils/get-token-from-code')


// in routes declaration
.get('/callback', async ctx => {
  // for now, we will not use the space_id
  const { space_id, code } = ctx.query
  try {
    const config = {
      grant_type: 'authorization_code',
      code,
      client_id: grantConfig.storyblok.key,
      client_secret: grantConfig.storyblok.secret,
      redirect_uri: grantConfig.storyblok.redirect_uri
    }
    const { access_token, refresh_token } = await getTokenFromCode(
      grantConfig.storyblok.access_url,
      config
    )

    ctx.session.application_code = code
    ctx.session.access_token = access_token
    ctx.session.refresh_token = refresh_token

    ctx.body = {
      data: {
        access_token,
        refresh_token
      }
    }
  } catch (e) {
    ctx.status = e.response.status
    ctx.body = {
      error: true,
      message: e.message
    }
  }
})

Now, open your browser again in http://localhost:3000/connect/storyblok to authenticate and redirect to the callback URL. You will see the JSON with the access_token and refresh_token.

Displaying the home page with the Vuejs app

Now that we have our settings ready, let's create the view that will be rendered in the App we created in Storyblok. First, create a folder called views , and inside it create two files, home.html and template.html.

In the home.html file, paste the code below:

<h2>Explore the API</h2>

<p>Congratulations! You just authorized this client to fetch data from <code>mapi.storyblok.com</code> using OAuth2.</p>
<p>Click on the buttons below to explore <code>mapi.storyblok.com</code> API</p>

<div id="app">
  <button @click="openNew"
          class="btn btn-primary">
    New
  </button>

  <form @submit.prevent="createOrUpdate"
        v-if="showForm"
        class="mt-3 mb-3">
    <div class="form-group">
      <label>Name</label>
      <input class="form-control" type="text" v-model="story.name" />
    </div>
    <div class="form-group">
      <label>Slug</label>
      <input class="form-control" type="text" v-model="story.slug" />
    </div>
    <button type="submit" class="btn btn-primary">Submit</button>
  </form>

  <div>
    <table class="table mt-3">
      <tr v-for="story in stories"
          :key="story.id">
        <td>
          {{ story.name }}
        </td>
        <td>
          <button @click="editStory(story)"
                  class="btn btn-danger">
            Edit
          </button>
          <button @click.prevent="remove(story.id)"
                  class="btn btn-danger">
            Delete
          </button>
        </td>
      </tr>
    </table>
  </div>
</div>

<script type="text/javascript">
  var SPACE_ID = <%= space_id %>
</script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.0"></script>
<script type="module" src="/app.js"></script>

<p id="display-json">Click on the buttons above, the response will show here.</p>

<p>Your access token: <code><%= access_token %></code></p>
<p><a class="btn btn-outline-secondary" href="/refresh?space_id=<%= space_id %>">Refresh Access Token</a></p>

And in the template.html file, paste the code below.

<html lang="en-us">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <title> Storyblok example </title>
    <script type="text/javascript">
      if (window.top == window.self) {
        window.location.assign('https://app.storyblok.com/oauth/app_redirect')
      }
    </script>
  </head>
  <body>
    <div class="container-fluid">
      <div class="container py-md-3">
        <div class="row">
          <div class="col">
            <%- body %>
          </div>
        </div>
      </div>
    </div>
  </body>
</html>

Let's add some logic to our front end. Create a public folder and an app.js file with our Vue.js application with the following code:

var client = window.axios.create({
  baseURL: 'http://localhost:3000/explore/' + window.SPACE_ID + '/',
  timeout: 10000
})

new Vue({
  el: '#app',
  data() {
    return {
      stories: [],
      story: {},
      showForm: false
    }
  },
  created() {
    this.list()
  },
  methods: {
    handleError(err) {
      alert(err)
    },
    editStory(story) {
      this.story = story
      this.showForm = true
    },
    openNew() {
      this.story = {}
      this.showForm = true
    },
    list() {
      this.showForm = false

      client.get('stories')
        .then((response) => {
          this.stories = response.data.stories
        })
        .catch(this.handleError)
    },
    createOrUpdate() {
      if (this.story.id) {
        this.update(this.story.id)
      } else {
        client.post('stories', {story: this.story})
          .then((response) => {
            console.log(response)
            this.list()
          })
          .catch(this.handleError)
      }
    },
    remove(id) {
      client.delete('stories/' + id)
        .then((response) => {
          console.log(response)
          this.list()
        })
        .catch(this.handleError)
    },
    update(id) {
      client.put('stories/' + id, {story: this.story})
        .then((response) => {
          console.log(response)
          this.list()
        })
        .catch(this.handleError)
    }
  }
})

Now, in our backend, we need to create the routes for the CRUD.

Creating the CRUD routes

First, render the Home template when the application is authenticated. Edit the root route with the following code:

.get('/', async ctx => {
  // get the space_id from the URL
  const { space_id } = ctx.query

  // render the home page passing the space_id and access_token values
  await ctx.render('home', {
    space_id,
    access_token: ctx.session.access_token
  })
})

Then, edit the callback route to make a redirect to root route.

// at the end of the route, replace this
ctx.body = {
  data: {
    access_token,
    refresh_token
  }
}

// for this
ctx.redirect(`/?space_id=${space_id}`)

Add CRUD routes. First, create a function to instantiate the Storyblok JS client for us. Create a get-storyblok-client.js in utils folder with the following code:

const StoryblokClient = require('storyblok-js-client')

/**
 * @method getStoryblokClient
 * @param  {String} token access_token
 * @return {StoryblokJSClient}
 */
const getStoryblokClient = token => {
  return new StoryblokClient({
    oauthToken: `Bearer ${token}`
  })
}

module.exports = getStoryblokClient

Finally, add these CRUD routes:

// on top of your code
const getStoryblokClient = require('./utils/get-storyblok-client')

// after all routes declarations
  .get('/explore/:space_id/:resource', async ctx => {
  const { space_id, resource } = ctx.params

  const client = getStoryblokClient(ctx.session.access_token)

  try {
    const response = await client.get(`spaces/${space_id}/${resource}`)

    ctx.body = response.data
  } catch (e) {
    ctx.status = e.response.status
    ctx.body = {
      error: true,
      message: e.message
    }
  }
})
.post('/explore/:space_id/:resource', async ctx => {
  const { space_id, resource } = ctx.params
  const client = getStoryblokClient(ctx.session.access_token)
  const body = ctx.request.body

  try {
    const response = await client.post(`spaces/${space_id}/${resource}`, body)

    ctx.body = response.data
  } catch (e) {
    ctx.status = e.response.status
    ctx.body = {
      error: true,
      message: e.message
    }
  }
})
.put('/explore/:space_id/:resource/:id', async ctx => {
  const { space_id, resource, id } = ctx.params
  const client = getStoryblokClient(ctx.session.access_token)
  const body = ctx.request.body

  try {
    const response = await client.put(`spaces/${space_id}/${resource}/${id}`, body)

    ctx.body = response.data
  } catch (e) {
    ctx.status = e.response.status
    ctx.body = {
      error: true,
      message: e.message
    }
  }
})
.delete('/explore/:space_id/:resource/:id', async ctx => {
  const { space_id, resource, id } = ctx.params
  const client = getStoryblokClient(ctx.session.access_token)

  try {
    const response = await client.delete(`spaces/${space_id}/${resource}/${id}`)

    ctx.body = response.data
  } catch (e) {
    ctx.status = e.response.status
    ctx.body = {
      error: true,
      message: e.message
    }
  }
})

Testing the CRUD app

Now that everything is ready, start the server and type in your terminal:

node app.js

Open your browser in the http://localhost:3000/connect/storyblok URL. If everything goes as it should, you will be redirected to authentication. You should see a list of your stories in your space. An example is this:

 Open app image

Implementing the refresh token route

The last thing that we need to implement is the refresh token method. Add a new route with the following code:

// after all routes declarations
.get('/refresh', async ctx => {
  const { space_id } = ctx.query
  try {
    const config = {
      grant_type: 'refresh_token',
      refresh_token: ctx.session.refresh_token,
      client_id: grantConfig.storyblok.key,
      client_secret: grantConfig.storyblok.secret,
      redirect_uri: grantConfig.storyblok.redirect_uri
    }
    const { access_token, refresh_token } = await getTokenFromCode(
      grantConfig.storyblok.access_url,
      config
    )

    ctx.session.access_token = access_token
    ctx.session.refresh_token = refresh_token

    ctx.redirect(`/?space_id=${space_id}`)
  } catch (e) {
    ctx.status = e.response.status
    ctx.body = {
      error: true,
      message: e.response.data.error_description
    }
  }
})

Conclusion

In this tutorial, we learned how to build an application to Storyblok authenticating it with an OAuth2 protocol. We used the grant package and koa to build our backend. For the frontend, we used Vue.js to perform a full CRUD application. Remember that you can check the code at https://github.com/storyblok/storyblok-koa-oauth-example.


About the author

Emanuel Gonçalves

Emanuel Gonçalves

Working as frontend engineer at Storyblok. He loves contributing to the open source community and studies artificial intelligence in his spare time. His motto is "To go fast, go alone; to go far, go together".