Devise Token Auth is like the continuation of Devise for single-page apps. It has several front-end packages, but Vue is sadly missing from their list. Nonetheless I found it pretty easy to integrate. In this article I'll walk you through it all. This assumes you're using Vue with axios for your HTTP client, vuex, and vue-cookie. Here's the general workflow:

  1. Commit auth tokens to Vuex on initial sign-in

  2. Commit auth tokens to Vuex when they change with an Axios response interceptor

  3. Pull auth tokens from Vuex and inject into each request with an Axios request interceptor

  4. Keep a cookie up-to-date with the tokens whenever #1 and #2 happen

  5. Read from the cookie when the Vue application is first loaded (beforeCreate)

Workflow of initial request, changed headers, and reopening the app

Flow of Vue app + Devise Token Auth

Setting up the store

A simple Vuex definition that supports the concepts outlined in this article might look like this.

export default new Vuex.Store({
  state: {
    user: null,
    auth: {}
  },
  getters: {
    user: state => state.user,
    auth: state => state.auth
  },
  mutations: {
    user (state, value) {
      state.user = value
    },
    auth (state, value) {
      state.auth = value
    }
  }
})

That gives us a user and an auth getter and mutator. user defaults to null which will allow us to write a condition such as if (user), whereas auth will always be an object where the keys are header names and the values are the actual tokens.

Keeping track of auth tokens when they change

The way devise_token_auth works is that it creates two randomized values, the access-token and client, and expects to receive these as HTTP headers whenever making an authenticated API request. The catch is that it also continuously changes these values for security.

What that means is that any time the Vue app talks to your backend, it needs to grab and keep track of these changed values in the response. To do this, turn to Axios interceptors. Here is how you can implement this in main.js:

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from 'axios'
import vueCookie from 'vue-cookie'
import status from 'http-status'
import { pick } from 'lodash'

// Put your instance of Axios in `Vue.prototype` which gives you easy global access to it.
// You can pass in configuration that is shared application-wide.
Vue.prototype.$http = axios.create()

// This first fat arrow function defines a callback invoked after any SUCCESSFUL request.
// This is where we check if the backend included an `access-token` header.
Vue.prototype.$http.interceptors.response.use((response) => {
  if (response.headers['access-token']) {
    // Commits the relevant headers to the store, calling mutation `auth`.
    const authHeaders = pick(r.headers, ["access-token","client","expiry","uid","token-type"])
    store.commit('auth', authHeaders)

    var session = vueCookie.get('session')

    // Stores the tokens in a cookie called `session` too. Cookie values are strings, so have to
    // serialize it to JSON.
    //
    // The object we are storing in session looks like this:
    // {'tokens': {'access-token': 'foo', ...}, 'user': {'email': 'foo@bar.com', ...}}
    // so that is why we are extracting and modifying, so that the `user` key isn't blown away.
    if (session) {
      var session = JSON.parse(session)
      session['tokens'] = authHeaders

      vueCookie.set('session', JSON.stringify(session), { expires: '14D' })
    }
  }

  return response
}, (error) => {
  // This second fat arrow function defines a callback invoked after any FAILED request.
  // This handles every case when the server responds with a 403 / unauthorized. That means
  // we need to redirect the user to the SignIn component.
  if (router.currentRoute.name !== 'signin' && error.response.status === status.UNAUTHORIZED) {
    store.commit('user', null)
    router.push({ name: 'signin' })
  }

  return Promise.reject(error)
})

Initial auth token set-up on sign in

When making the request after the user submits their credentials, store the result in Vuex and a cookie. This lives in whatever component handles your sign in request.

import { pick } from 'lodash'

// ...

this.$http.post('/v1/authentication/sign_in', { email: this.email, password: this.password })
  .then((response) => {
  // Again commits the relevant headers to the store.
  const authHeaders = pick(response.headers,
                           ["access-token","client","expiry","uid","token-type"])
  this.$store.commit('auth', authHeaders)

  // response.data.data is an object containing public information about the current user.
  // This is useful to keep track of so that your app can display the current user's
  // email/username somewhere.
  this.$store.commit('user', response.data.data)

  // Write both the response headers and the current user data to the cookie.
  const contents = {
    tokens: authHeaders,
    user: response.data.data
  }

  this.$cookie.set('session',
                   JSON.stringify(contents),
                   { expires: '14D' })

  // Go home or wherever the user originally wanted to go
  this.$router.push({ name: 'home' })
})

Passing the current tokens to every request

Not much to this, take the current auth headers from store and inject it into every request. Placed in main.js or somewhere in initialization:

Vue.prototype.$http.interceptors.request.use((config) => {
  const headers = store.getters['auth']

  // object that holds configuration of the request that's about to be made
  config.headers = headers
  return config
})

Persisting state through an app reload

When your clientside application is initialized, load the headers from your cookie. I placed this in my top-most component, App.vue, in its beforeCreate. If you're not sure where to put this, it will likely be whatever component contains <router-view>.

const existingSession = this.$cookie.get('session')

if (existingSession && existingSession.length) { // A string at this point
  const session = JSON.parse(existingSession)
  this.$store.commit('user', session.user)
  this.$store.commit('auth', session.tokens)
}

Allow direct requests to your backend

Sometimes it's necessary to link directly to your backend app, such as when downloading a file. There are clever workarounds to serve files with Javascript, but the cleanest method is still a direct server round trip.

If you try to do this using devise_token_auth, you'll encounter these caveats:

  1. The user will be denied access because the current auth tokens won't be passed as headers. We can't set headers on links (<a></a>).

  2. The request will break your session because the tokens will change and your Vue app won't know about it.

Remember that cookies are sent via HTTP headers, so your backend app can know about the tokens that way. To authorize the user, reach into the session cookie to retrieve the current tokens, and copy them to the correct headers before authenticating your user. That way, devise_token_auth sees them. Here's how this would work in Rails:

before_action do
  tokens = JSON.parse(cookies.fetch("session")).fetch("tokens")
  relevant_headers = tokens.symbolize_keys.slice(*DeviseTokenAuth.headers_names.keys)

  # NOTE Potentially dangerous depending on the value of DeviseTokenAuth.headers_names.keys.
  relevant_headers.each do |k, v|
    request.headers[k] = v
  end
end

Same thing in reverse on the way out. This allows the headers to change, and updates the cookie:

prepend_after_action do
  cookie_session = JSON.parse(cookies.fetch("session"))
  token_headers = response.headers.symbolize_keys.slice(*DeviseTokenAuth.headers_names.keys)
  cookie_session["tokens"] = token_headers
  cookies["session"] = cookie_session.to_json
end

Finally, refresh your Vue app once the cookies change. I don't have a good code snippet for this, but I would try monitoring the cookie contents on a recurring timer until it detects a change after the user clicks a link that triggers this whole request to the server.

Server-side troubleshooting

This is some helpful debug output on the Ruby side to verify you're passing the correct headers:

puts request.headers.env["HTTP_TOKEN_TYPE"].inspect
puts request.headers.env["HTTP_UID"].inspect
puts request.headers.env["HTTP_ACCESS_TOKEN"].inspect
puts request.headers.env["HTTP_CLIENT"].inspect
puts request.headers.env["HTTP_EXPIRY"].inspect

Keep a close eye on what is exposed to the world

I highly recommend defining User#token_validation_response. This defines the attribute hash given back by Devise's endpoints, and what you are now storing in a cookie. As you add data to your user object, we surely don't want possibly sensitive data in there.

def token_validation_response
  as_json(only: [:id, :email, :uid, :allow_password_change, :name, :nickname, :image])
end

This way, you must explicitly add any attributes in the future.

Testing

If you happen to be using Minitest, I'll share a caveat with you. To authenticate a fixture user in your test suite, double check it has attribute uid defined. This is just the user's email or username. You also want to set an encrypted password and confirmed_at, to make sure it's active for authentication.

test/fixtures/users.yml:

bob:
  uid: 'bob@foobar.com'
  email: 'bob@foobar.com'
  encrypted_password: 'xyz123'
  confirmed_at: <%= 1.hour.ago %>
  created_at: <%= Time.now %>
  updated_at: <%= Time.now %>

In an ActionDispatch::IntegrationTest, pass the result of User#create_new_auth_token as the value of headers:

get foo_bar_url, params: {foo: 'bar'}, headers: users(:bob).create_new_auth_token

That should make your authenticate_(foo)_user! callback happy, and you should be well on your way to building a great API application in Rails without reimplementing authentication for the 10th time in your career.

More blog posts