Hands-on OAuth 2.0 and OIDC

Context I want to build and grow my collection of tools and services, and allow others to use them in a cohesive environment gradually. OAuth 2.0 and OIDC are an attractive approach towards this goal. Also, I prefer to start with something advanced, possibly outside my radar for now, so that I can learn by doing, and discover more interesting topics along the way!

Plan

I will not start with the introduction to the background and fundamentals of OAuth 2.0 and OIDC. (Yes I even keep all the acronyms as is without explanation). More general discussions and details will be placed at the latter part of this post, after I have more in-depth understanding in this area. In general, I will try to steer through the mist of online documents and arrive at something relatively simple, complete, and working. This way I can gain more "valid" insights into the underlying technical details as well as the higher level picture. If further investigation is needed I will explore more.

Anyways, let's setup both an OIDC server and a matching client using the two popular open-source libraries that I finally pick (after hours or digging and experimenting):

The scenario we want to achieve is roughly:

  • Assuming a user has an account in the OIDC provider
  • To log in into a website, the website redirects the user to the OIDC provider's log-in interface
  • The user authenticates successfully, then the OIDC provider redirects the user back to the website with session established

Setup an OIDC Provider

First visit node-oidc-provider, where we see immediately a quick OIDC server example:

import Provider from 'oidc-provider';
const configuration = {
  // refer to the documentation for other available configuration
  clients: [{
    client_id: 'foo',
    client_secret: 'bar',
    redirect_uris: ['http://lvh.me:8080/cb'],
    // ... other client properties
  }],
};

const oidc = new Provider('http://localhost:3000', configuration);

oidc.listen(3000, () => {
  console.log('oidc-provider listening on port 3000, check http://localhost:3000/.well-known/openid-configuration');
});

I'd like to use Express.js, and also mount the OIDC provider at some specific location other than the root. Let's follow the link Mounting oidc-provider and see the snippet:

// assumes express ^4.0.0
expressApp.use('/oidc', oidc.callback());

We also look at the Accounts section of the API documentation page, which describes the hook for looking up user information:

const oidc = new Provider('http://localhost:3000', {
  async findAccount(ctx, id) {
    return {
      accountId: id,
      async claims(use, scope) { return { sub: id }; },
    };
  }
});

Based on the gathered information so far, we can kick start a server implementation with specific configuration filled in for the client later:

import express from 'express';
import Provider from 'oidc-provider';

const app = express();

const configuration = {
  clients: [{
     client_id: 'oidccli_id',
     client_secret: 'oidccli_secret',
     grant_types: ['authorization_code'],
     redirect_uris: ['http://localhost:8080/login/callback'],
     post_logout_redirect_uris: ['http://localhost:8080/'],
     response_types: ['code'],
  }],
  async findAccount(ctx, id) {
    return {
      accountId: id,
      async claims(use, scope) {
        return { sub: id };
      },
    };
  },
};
const oidc = new Provider('http://localhost:3000', configuration);

app.use('/oidc', oidc.callback());

app.listen(3000, function () {
  console.log('OIDC is listening on http://localhost:3000');
})
Check this up for more detailed instructions

Set Up an OIDC Client

Now for the OIDC client part. First visit node-openid-client. We are aiming at the Authorization Code Flow section, but using the OIDC server set up previously. Since we already use Express.js, let's use the passport Strategy provided by the library:

const express = require('express');
const session = require('express-session');
const passport = require('passport');

const { Issuer, Strategy } = require('openid-client');

const app = express();

app.use(session({ secret: 'secret', resave: false, saveUninitialized: true }));
app.use(passport.initialize());
app.use(passport.session());

passport.serializeUser((user, done) => {
    console.log('serializeUser:', user);
    done(null, user);
});
passport.deserializeUser((user, done) => {
    console.log('deserializeUser:', user);
    done(null, user);
});

var oidcClient;

Issuer.discover('http://localhost:3000/oidc')
.then(issuer => {
    oidcClient = new issuer.Client({
      client_id: 'oidccli_id',
      client_secret: 'oidccli_secret',
      redirect_uris: ['http://localhost:8080/login/callback'],
      post_logout_redirect_uris: ['http://localhost:8080/'],
      response_types: ['code'],
    });

    passport.use('oidc', new Strategy(
        { client: oidcClient, passReqToCallback: true },
        (req, tokenSet, userinfo, done) => {
            const claims = tokenSet.claims();
            console.log('claims:', claims);
            console.log('userinfo:', userinfo);
            return done(null, {
                ...claims,
                name: claims.sub,
                id_token: tokenSet.id_token,
            });
        },
    ));
});

app.get('/login', passport.authenticate('oidc', { scope: 'openid' }));

app.get('/login/callback', (req,res,next) => {
    passport.authenticate('oidc', {
        successRedirect: '/profile',
        failureRedirect: '/'
    })(req, res, next);
});

app.get('/', (req, res) => {
    if (req.user) {
        res.send(`Hi, ${req.user.name} (${req.user.email}),<br>
            you can go to <a href="/profile">profile</a>, or<br>
            <a href="/logout">logout locally</a>, or<br>
            <a href="/logout?oidc=1">logout globally</a>`);
    } else {
        res.send('<a href="/login">Log in with OIDC Provider</a>')
    }
});
app.get('/profile', (req, res) =>{
    res.send(`<pre>${JSON.stringify(req.user, null, 2)}</pre><a href="/">home</a>`);
});
app.get('/logout', (req, res) =>{
    if (!req.session?.passport?.user) res.redirect('/');

    let { token_id } = req.session.passport.user;
    req.logout(err => {
        if (err) { return next(err); }

        if (!req.query.oidc) return res.redirect('/');

        const url = oidcClient.endSessionUrl({
          token_id_hint: token_id,
        });
        res.redirect(url);
    });
});

app.listen(8080, () => {
    console.log('Server running at http://localhost:8080');
});
Check lab up for more detailed setup.

To be continued...