DEV Community

simo
simo

Posted on • Edited on

OAuth Like a BOSS

In my previous article I talked about Grant:

An OAuth middleware for NodeJS that also happens to be a completely transparent OAuth proxy.

This time around we're going to explore a few real world examples:

  1. Login from a server app, written in JavaScript.
  2. Login from a browser app hosted on GitHub Pages.
  3. Login from a Browser Extension.
  4. Login from a server app, written in another programming language.

The Usual Stuff

Imagine that we have a web app hosted on awesome.com Imagine also that our app leverages the GitHub API to manage our user's repositories. We also have a web form on our website to allow our users to pick only the permissions they are willing to grant to our app:

<form action="/connect/github" method="POST">
  <p>Grant read/write access to:</p>
  <label>
    <input type="radio" group="scope" name="scope" value="repo" />
    public and private repositories</label>
  <label>
    <input type="radio" group="scope" name="scope" value="public_repo" />
    public repositories only</label>
  <button>Login</button>
</form>

This form is going to POST the chosen OAuth scope to the /connect/github route, that Grant operates on.

Next we need a Grant server to handle the OAuth flow for us:

var express = require('express')
var session = require('express-session')
var parser = require('body-parser')
var grant = require('grant-express')

express()
  .use(session({secret: 'dev.to'}))
  .use(parser.urlencoded()) // only needed for POST requests
  .use(grant(require('./config.json')))
  .use('/login', (req, res) => res.end(`the above HTML form`))
  .use('/hello', (req, res) => {
    var {access_token} = req.session.grant.response
    console.log(access_token)
    res.end('nice!')
  })
  .listen(3000)

With the following configuration:

{
  "defaults": {
    "origin": "https://awesome.com", "state": true, "transport": "session"
  },
  "github": {
    "key": "...", "secret": "...", "dynamic": ["scope"], "callback": "/hello"
  }
}

We are allowing the OAuth scope to be set dynamically for GitHub. We are also going to use the session as transport to deliver the result of the OAuth flow in our final callback route.

Lastly, we have to create an actual OAuth app on GitHub and copy/paste its key and secret to the configuration above. We also have to set its Authorization callback URL to https://awesome.com/connect/github/callback, that's the second route reserved by Grant.

This will allow us to pick a scope and login with GitHub by navigating to https://awesome.com/login

Login from Another Host

Now imagine that we have another app hosted on GitHub Pages at https://simov.github.io/stars/, that allows users to explore stats and history about the Stars received by a given repository hosted on GitHub.

Our app is going to access public data only. Unfortunately the default rate limit imposed by GitHub on their API is 60 HTTP request per hour. If the request is sent along with an access token, however, the rate limit is lifted to up to 5000 HTTP requests per hour.

So, we need to login again, but we already have a Grant server up and running on awesome.com, so why not reuse it:

{
  "defaults": {
    "origin": "https://awesome.com", "state": true, "transport": "session"
  },
  "github": {
    "key": "...", "secret": "...", "dynamic": ["scope"], "callback": "/hello",
    "overrides": {
      "stars": {
        "key": "...", "secret": "...", "dynamic": ["callback"], "transport": "querystring"
      }
    }
  }
}

We want to have a sub configuration for GitHub, called stars. It will be a different OAuth app, notice the key and the secret.

We also want to set the final callback URL dynamically, but not the scope allowed above it. We're going to login without any explicit scopes set, which in the case of GitHub means getting read access to public data only.

Lastly, we're overriding the transport inherited from the defaults. We need the response data encoded as querystring in a fully qualified absolute callback URL, pointing back to our browser app hosted on GitHub Pages.

Then we have to navigate to the connect route to login:

// store the current URL
localStorage.setItem('redirect', location.href)
// set callback URL dynamically - https://simov.github.io/stars/
var callback = encodeURIComponent(location.origin + location.pathname)
// navigate to the connect route
location.href = `https://awesome.com/connect/github/stars?callback=${callback}`

It's handy to have our final callback URL set dynamically in case we want to host our app on a different domain in future.

After logging in, the user will be redirected back to our browser app, hosted on GitHub:

https://simov.github.io/stars/?access_token=...

Now is the time to extract the access_token from the querystring and store it for future use:

var qs = new URLSearchParams(location.search)
localStorage.setItem('token', qs.get('access_token'))

It's a nice final touch to redirect our users back to where they were before embarking on the quest to login with our OAuth app:

location.href = localStorage.getItem('redirect') // go back to the last URL
localStorage.removeItem('redirect')

Here is the app I was talking about.

Login from Browser Extension

Next we have a Browser Extension that augments the GitHub's UI by adding a cool little button to it. When clicked, it aggregates some useful information about the repository we are currently browsing:

{
  "manifest_version": 2,
  "name": "Cool Little Button",
  "version": "1.0.0",
  "background" : {"scripts": ["background.js"]},
  "content_scripts": [{"matches": ["https://github.com/*"], "js": ["content.js"]}],
  "permissions": ["storage"]
}

This extension, however, relies on data fetched from the GitHub API, that by default will, again, limit us to 60 HTTP requests per hour. So it will be nice to let our users login quickly and easily, directly from within our extension, and therefore lift the rate limit to up to 5000 HTTP requests per hour:

{
  "defaults": {
    "origin": "https://awesome.com", "state": true, "transport": "session"
  },
  "github": {
    "key": "...", "secret": "...", "dynamic": ["scope"], "callback": "/hello",
    "overrides": {
      "stars": {
        "key": "...", "secret": "...", "dynamic": ["callback"], "transport": "querystring"
      },
      "extension": {
        "dynamic": false, "transport": "querystring",
        "callback": "https://github.com/extension/callback"
      }
    }
  }
}

This time we are going to reuse the OAuth app inherited from the root level of the GitHub configuration (the key and the secret). We also don't want the user to set any of the configuration options dynamically.

Then we can open up a new tab from within the background.js script and let our users login:

chrome.tabs.create({url: 'https://awesome.com/connect/github/extension')})

We will redirect our users back to a non existing page on GitHub. In this case GitHub will respond with generic HTML page for the 404 Not Found status code, but our access token will be encoded in the querystring:

https://github.com/extension/callback?access_token=...

So again, all we need to do is extract it and store it, this time by placing the following code into the content.js script:

var qs = new URLSearchParams(location.search)
chrome.storage.sync.set({token: qs.get('access_token')})

Login from Another Language

Grant is not limiting us to JavaScript and NodeJS on the server. Being able to be configured dynamically over HTTP, and sent back the result wherever we want to, Grant gives us access to 180+ login providers from any other programming language.

This time around we'll name our sub configuration proxy, allowing dynamic configuration of every option available in Grant:

{
  "defaults": {
    "origin": "https://awesome.com", "state": true, "transport": "session"
  },
  "github": {
    "key": "...", "secret": "...", "dynamic": ["scope"], "callback": "/hello",
    "overrides": {
      "stars": {
        "key": "...", "secret": "...", "dynamic": ["callback"], "transport": "querystring"
      },
      "extension": {
        "dynamic": false, "transport": "querystring",
        "callback": "https://github.com/extension/callback"
      },
      "proxy": {
        "dynamic": true
      }
    }
  }
}

For the purpose of this example I'm going to use Go, I just had to pick one, but the following applies to any other language:

package main
import (
  "fmt"
  "net/url"
  "net/http"
)
func main() {
  http.HandleFunc("/login", func (w http.ResponseWriter, r *http.Request) {
    qs := url.Values{}
    qs.Add("key", "...") // yes
    qs.Add("secret", "...") // we're passing an OAuth app dynamically!
    qs.Add("scope", "repo user gist")
    qs.Add("transport", "querystring")
    qs.Add("callback", "http://localhost:3000/hello")
    http.Redirect(w, r, "https://awesome.com/connect/github/proxy?" + qs.Encode(), 301)
  })
  http.HandleFunc("/hello", func (w http.ResponseWriter, r *http.Request) {
    qs, _ := url.ParseQuery(r.URL.RawQuery)
    fmt.Println(qs["access_token"][0])
    w.Write([]byte("nice!"))
  })
  http.ListenAndServe(":3000", nil)
}

Now all we need to do is navigate to http://localhost:3000/login

Conclusion

Well, I can summarize this entire article in 5 lines of JSON configuration.

The point of it, however, was to demonstrate how the server side login can be connected with various types of applications, and how Grant fits in the broader picture.

Hopefully this will serve as a handy guide for frontend developers wanting the leverage the servers side login, and also backend developers wanting to quickly get access to a lot of providers.

Here is the source code of all examples.

Happy Coding!

Top comments (2)

Collapse
 
jswhisperer profile image
Greg, The JavaScript Whisperer

Thanks for the article, I like the approach. I was wondering what your thoughts are on jwt auth with serverless or mixing oauth and jwt to get around rate limiting etc and avoid cookies?

Collapse
 
simov profile image
simo

Hi @Gregory, I'm glad you like the article.

Cookies are being used only as a mechanism to execute the OAuth flow successfully. It's a built-in feature in browsers and it's well understood concept. Depending on the cookie configuration you set on your end you can expire the session after you receive the access tokens.

As for JWT tokens, once you receive back the credentials from the OAuth flow, you can generate a JWT and return that to the user.