Adding OIDC Member Login to Ghost CMS
Context I am using Ghost for blogging. Ghost's membership model is limited, as I want to manage members via a separate identity management system.
Preparation
- Install local Ghost CMS - see lab
- Set up simple OIDC provider - see lab
- Set up simple OIDC client - see lab
Investigation
First skim through the Ghost codebase under current/core. I learned that Ghost uses Express.js and want to see how to add an alternative login path for members, possibly by adding some middleware and endpoints for OIDC. From boot.js I found
initExpressApps()for backend and frontend, then./server/web/parent/frontend.jsfor frontend routes, then./server/web/members/app.jsfor members sub app, in which I saw the middleware
// Currently global handling for signing in with ?token= magiclinks
membersApp.use(middleware.createSessionFromMagicLink);It is known that in Ghost members log in using magic links, and an email containing a URL with some token is sent out waiting for a user to link back. Looking into createSessionFromMagicLink() also confirms this:
if (!req.url.includes('token=')) {
return next();
}
...
const member = await membersService.ssr.exchangeTokenForSession(req, res);The function does "exchange" the token with a session for the user, and will create an account the email is first seen. After several attempts, I decided to just follow the same magic link process, create tokens in the same way, but without actually sending emails out. Instead the authentication is done though OIDC.
Dummy Short Circuit
First I wanted to make sure the idea works. So in the file ./server/web/members/app.js I added the code in the "Routing" section after the createSessionFromMagicLink middleware:
...
const { parse: parseUrl } = require('url');
const urlUtils = require('../../../shared/url-utils');
...
// Currently global handling for signing in with ?token= magiclinks
membersApp.use(middleware.createSessionFromMagicLink);
// Routing
membersApp.get('/oidc',
middleware.loadMemberSession,
async (req, res, next) =>
{
if (req.member) {
return res.redirect(`${urlUtils.getSiteUrl()}`);
}
const name = 'me';
const email = 'me@example.com';
const { api, ssr } = membersService;
const member = await api.getMemberIdentityData(email);
if (!member) {
const newMember = await api.members.create({ name, email });
member = await api.getMemberIdentityData(email);
}
const signinUrl = await api.getMagicLink(email, 'signin');
const {query} = parseUrl(signinUrl, true);
const token = query.token;
if (Object.keys(req.query).length === 0) {
req.url += `?token=${token}`;
} else {
req.url += `&token=${token}`;
}
await ssr.exchangeTokenForSession(req, res);
// Do a standard 302 redirect to the homepage, with success=true
res.redirect(`${urlUtils.getSubdir()}/?success=true`);
});middleware.loadMemberSessionis used to populatereq.memberreq.urlis modified fiercely to generate token throughapi.getMagicLink()(after I tried and failed to compute the token directly without modifying other part of the codebase)
With the code added I can log in as the member me with email me@example.com successfully. I can visit the path /members/oidc directly, or add the link http://localhost:2368/member/oidc in the site navigation section of Ghost's admin panel.
OIDC Integration
Because I have done the OIDC experiments previously, it shouldn't take long to adapt the code above enable SSO for members! Below is an example:
...
const { parse: parseUrl } = require('url');
const urlUtils = require('../../../shared/url-utils');
const models = require('../../models');
const logging = require('@tryghost/logging');
const cookieSession = require('cookie-session');
const { Issuer, generators } = require('openid-client');
var _oidcClient = null;
async function getOidcClient() {
if (_oidcClient) return _oidcClient;
const issuer = await Issuer.discover('http://localhost:3000/oidc');
_oidcClient = new issuer.Client({
client_id: 'oidccli_id',
client_secret: 'oidccli_secret',
redirect_uris: ['http://localhost:2368/members/oidc/callback'],
post_logout_redirect_uris: ['http://localhost:2368/'],
response_types: ['code'],
});
return _oidcClient;
}
...
module.exports = function setupMembersApp() {
debug('Members App setup start');
const membersApp = express('members');
membersApp.use(cookieSession({
name: 'session-oidc',
keys: ['some secret key'],
}));
// Members API shouldn't be cached
membersApp.use(shared.middleware.cacheControl('private'));
// Support CORS for requests from the frontend
membersApp.use(cors({maxAge: config.get('caching:cors:maxAge')}));
// Currently global handling for signing in with ?token= magiclinks
membersApp.use(middleware.createSessionFromMagicLink);
// Routing
membersApp.get('/oidc', middleware.loadMemberSession, async (req, res, next) => {
if (req.member) {
return res.redirect(`${urlUtils.getSiteUrl()}`);
}
const oidcClient = await getOidcClient();
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
req.session.oidc_code_verifier = code_verifier;
const url = oidcClient.authorizationUrl({
scope: 'openid email profile',
code_challenge,
code_challenge_method: 'S256',
});
res.redirect(url);
});
membersApp.get('/oidc/callback', middleware.loadMemberSession, async (req, res, next) => {
if (req.member) {
return res.redirect(`${urlUtils.getSiteUrl()}`);
}
const code_verifier = req.session.oidc_code_verifier;
const oidcClient = await getOidcClient();
const params = oidcClient.callbackParams(req);
const tokenSet = await oidcClient.callback(
'http://localhost:2368/members/oidc/callback', params, { code_verifier },
);
delete req.session.oidc_code_verifier;
req.session.oidc_id_token = tokenSet.id_token;
const claims = tokenSet.claims();
logging.warn('validated ID Token claims ' + JSON.stringify(claims, null, 2));
let { sub, email, first_name: name } = await oidcClient.userinfo(tokenSet);
logging.warn(`userinfo: ${sub} ${email} ${name}`);
const api = membersService.api;
let member = await api.getMemberIdentityData(email);
if (!member) {
const newMember = await api.members.create({name, email});
await models.MemberLoginEvent.add({member_id: newMember.id});
member = await api.getMemberIdentityData(email);
}
const signinUrl = await api.getMagicLink(email, 'signin');
const {query} = parseUrl(signinUrl, true);
const token = query.token;
if (Object.keys(req.query).length === 0) {
req.url += `?token=${token}`;
} else {
req.url += `&token=${token}`;
}
await membersService.ssr.exchangeTokenForSession(req, res);
// Do a standard 302 redirect to the homepage, with success=true
res.redirect(`${urlUtils.getSubdir()}/?success=true`);
});
// Webhooks
...
};- For illustration I just used the module
cookie-sessionthat comes with Ghost for persisting the required data exchanges. A more well-rounded revision is needed. You can refer to how Ghost handles the session cookies for members inexchangeTokenForSession() - Logout procedure is not done. For now you can clear the cookie store manually from the browser's dev panel.
OIDC Provider
The corresponding OIDC provider is shown below, which is just a minor modification from lab:
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:2368/members/oidc/callback'],
post_logout_redirect_uris: ['http://localhost:2368/'],
response_types: ['code'],
}],
scopes: [
'email', 'profile',
],
claims: {
email: ['email'],
profile: ['first_name'],
},
async findAccount(ctx, id) {
return {
accountId: id,
async claims(use, scope) {
console.log("FIND", id, "USE", use, "SCOPE", scope);
return {
sub: id,
email: `${id}@example.com`,
first_name: id[0].toUpperCase() + id.slice(1),
};
},
};
},
};
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');
});To be continued...