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):
- node-oidc-provider for the OIDC server
- node-openid-client for the OIDC client
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...