Adding OIDC Member Login to Strapi

Strapi has SSO support in its enterprise plan, but I only want to integrate Strapi in my own environment using my OIDC provider. I'd like to see if it takes a lot of effort to achieve the goal. If I can also learn to work with Strapi's plugin architecture along the way, there's nothing to lose.

Preparation

  • Simple OIDC provider
  • Strapi instance installed

Investigation

Are there open source SSO plugins out there already? Searching the web and the plugin strapi-plugin-sso comes out immediately. Although it is for Strapi 4, but since I have basic OIDC provider set up already, maybe it is not so bad to create a plugin targeting the OIDC provider only? Thanks to yasudacloud and other contributors for the SSO plugin above, where the most critical part is covered, that I can learn how the whole thing works a lot easier.

I am going for a quick and dirty prototype that's good enough for me, not aiming at a polished work worthy of contributing back. Maybe later if I have more time for this.

Strapi Plugins

Assuming we have a Strapi instance installed. A quick going through Strapi's site, we learn:

Login Page

Before going into OIDC integration, I want to know if I can add a link in the login page for user to login though OIDC. There seems no apparent mean through direct admin configuration, at least for Strapi 5, so I searched the web to see how it can be done. One link (https://forum.strapi.io/t/edit-root-page-strapi/30610) has some clues using middleware. So I go with it.

First create a global middleware using Strapi SDK strapi generate:

$ yarn strapi generate
...
? Strapi Generators middleware - Generate a middleware for an API
? Middleware name home
? Where do you want to add this middleware? Add middleware to root of project

A new middleware is created at ./src/middleware/home.ts

import type { Core } from '@strapi/strapi';

export default (config, { strapi }: { strapi: Core.Strapi }) => {
  // Add your own logic here.
  return async (ctx, next) => {
    strapi.log.info('In home middleware.');

    await next();
  };
};

To see if it works, add an entry in ./config/middleware.ts:

export default [
  'strapi::logger',
  ...
  'strapi::favicon',
  {
    resolve: '../src/middlewares/home',
  },
  'strapi::public',
];

Then run

$ yarn develop
...
   Error: Could not load middleware ".../src/middlewares/home".

To fix this quickly I just turn home.ts to home.js to make it work.

module.exports = (config, { strapi }) => {
  // Add your own logic here.
  return async (ctx, next) => {
    strapi.log.info('In home middleware.');

    await next();
  };
};

With the server started normally, I add redirection logic to the middleware:

module.exports = (config, { strapi }) => {
  return async (ctx, next) => {
    if (ctx.request.method === "GET" && ctx.request.path === '/') {
        ctx.response.redirect("/index.html");
        return;
    }

    await next();
  };
};

and create the file ./public/index.html :

<p>Welcome to MyStrapi</p>
<ul>
  <li><a href="/admin">Admin Login</a></li>
  <li><a href="/local-oidc/oidc">OIDC Login</a></li>
</ul>

Re-launch Strapi and visit http://localhost:1337, I get a plain HTML page that allows me to log in normally via the /admin link.

Adding OIDC Login

Create Plugin

In Plugin creation, there are different ways to develop a plugin. Here let's try the one that creates plugin external to the project we have. First install yalc

yarn global add yalc

Next to the Strapi project folder, assuming the plugin name is local-oidc, run

$ npx @strapi/sdk-plugin init local-oidc
[INFO]  Creating a new package at:  local-oidc
✔ git url … 
✔ plugin name … local-oidc
✔ plugin display name … Local OIDC
✔ plugin description … Strapi local OIDC plugin
✔ plugin author name … JY
✔ plugin author email … jy@example.com
✔ plugin license … MIT
✔ register with the admin panel? … no
✔ register with the server? … yes
✔ use editorconfig? … no
✔ use eslint? … yes
✔ use prettier? … yes
✔ use typescript? … yes
...

I don't have dlx installed, so I just run with npx. You can switch to using yarn afterwards if you want, like I did below.

cd local-oidc
yarn
yarn build && yarn verify

Follow along, run

$ yarn watch:link
...
To use this package in Strapi, in a separate shell run:
cd /path/to/strapi/project

Then run one of the commands below based on the package manager used in that project:

## yarn
yarn dlx yalc add --link local-oidc && yarn install

## npm
npx yalc add local-oidc && npx yalc link local-oidc && npm install

Linking Plugin

Therefore, in the Strapi project, on a separate terminal

$ cd strapi
$ yalc add local-oidc && yalc link local-oidc && npm install

We see that there is a link under node_modules pointing to the local-oidc package (via .yalc).

Configure Plugin

To enable the plugin, in the Strapi project, edit the file ./config/plugins.ts:

module.exports = ({ env }) => ({
  'local-oidc': {
    enabled: true,
  },
});

However, launching Strap and visiting http://localhost:1337/local-oidc shows some 401 error message. A quick look around we see that the server/src/routes/index.ts in the plugin package need to be changed:

export default [
  {                                                                                                                                   
    method: 'GET',
    path: '/',
    handler: 'controller.index',
    config: {
      auth: false,
    },
  },
]

Now we see the text when visiting http://localhost:1337/local-oidc:

Welcome to Strapi 🚀

OIDC Provider

It's time to add the OIDC login flow. First, set up the OIDC provider with proper configurations:

import express from 'express';
import bodyParser from 'body-parser';
import Provider from 'oidc-provider';

const PORT = 3000

const app = express();

app.use(bodyParser());
app.use((req, res, next) => {
    console.log("HTTP", req.method, req.url);
    console.log("  header:", req.headers);
    next();
});

const configuration = {
  clients: [{
     client_id: 'oidccli_id',
     client_secret: 'oidccli_secret',
     grant_types: ['authorization_code'],
     redirect_uris: ['http://localhost:1337/local-oidc/oidc'],
     post_logout_redirect_uris: [
       'http://localhost:1337/local-oidc/oidc/callback'
     ],
     response_types: ['code'],
  }],
  scopes: [
    'email', 'profile',
  ],
  claims: {
    email: ['email'],
    profile: ['first_name'],
  },
  async findAccount(ctx, id) {
    return {
      accountId: id,
      async claims(use, scope) {
        return {
            sub: id,
            email: `${id}@example.com`,
            first_name: id[0].toUpperCase() + id.slice(1),
        };
      },
    };
  },
};
const oidc = new Provider(`http://localhost:${PORT}`, configuration);

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

app.listen(PORT, function () {
  console.log(`OIDC is listening on http://localhost:${PORT}`);
});

OIDC Redirect

Back to the plugin. First install the OIDC client library:

cd local-oidc
yarn add openid-client

Let's do it top down. In server/src/routes/index.ts, add a new route instead of modifying the root /:

  {
    method: 'GET',
    path: '/oidc',
    handler: 'controller.oidc',
    config: {
      auth: false,
    },
  },

This means we need to add somet;hing in server/src/controllers/controller.ts:

import type { Core } from '@strapi/strapi';

import { Issuer, generators } from 'openid-client';

var _oidcClient;

async function oidcClient() {
    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:1337/local-oidc/oidc/callback'],
      post_logout_redirect_uris: ['http://localhost:1337/admin'],
      response_types: ['code'],
    });
    return _oidcClient;
}

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  index(ctx) {
    ctx.body = strapi
      .plugin('local-oidc')
      .service('service')
      .getWelcomeMessage();
  },
  async oidc(ctx) {
    const client = await oidcClient();
    const url = await client.authorizationUrl({
        scope: 'openid email profile',
    });
    ctx.redirect(url);
  },
});

export default controller;

Running both yarn watch:link in the plugin folder and yarn develop in the Strapi project folder, we can see the home page:

Welcome to MyStrapi

* Admin Login
* OIDC Login

And following the "OIDC Login" link, which leads to http://localhost:1337/local-oidc/oidc, redirect us the the OIDC login page.

OIDC Callback

Now to the callback part. So in server/src/routes/index.ts, we can add one more route:

  {
    method: 'GET',
    path: '/oidc/callback',
    handler: 'controller.oidcCallback',
    config: {
      auth: false,
    },
  },

and in server/src/controllers/controller.ts, add the callback controller:

...
import { v4 } from 'uuid';
...

  async oidcCallback(ctx) {
    const userService = strapi.service('admin::user')
    const oauthService = strapi.plugin('local-oidc').service('oauth')
    const tokenService = strapi.service('admin::token')

    const client = await oidcClient();
    const params = client.callbackParams(ctx.request);
    const tokenSet = await client.callback(
       'http://localhost:1337/local-oidc/oidc/callback', params,
    );

    const claims = tokenSet.claims();
    console.log('validated ID Token claims %j', claims);

    const user = await client.userinfo(tokenSet);
    console.log('userinfo: %j', user);

    const { sub, email, first_name } = user;
    const dbUser = await userService.findOneByEmail(email);
    let activateUser;
    let jwtToken;

    if (dbUser) {
      // Already registered
      activateUser = dbUser;
      jwtToken = await tokenService.createJwtToken(dbUser);
    } else {
      const roles = [];
      activateUser = await oauthService.createUser(
        email,
        '',
        first_name,
        'en',
        roles,
      );

      jwtToken = await tokenService.createJwtToken(activateUser);
    }

    // Login Event Call
    oauthService.triggerSignInSuccess(activateUser)

    // Client-side authentication persistence and redirection
    const nonce = v4()
    const html = oauthService.renderSignUpSuccess(jwtToken, activateUser, nonce)
    ctx.set('Content-Security-Policy', `script-src 'nonce-${nonce}'`)
    ctx.send(html);
  },
});

...

I copied and adapted some code from strapi-plugin-sso, so is the file server/src/services/oauth.ts adapted from there:

import generator from 'generate-password';

export default ({ strapi }) => ({
  async createUser(email, lastname, firstname, locale, roles = []) {
    // If the email address contains uppercase letters, convert it to lowercase and retrieve it from the DB. If not, register a new email address with a lower-case email address.
    const userService = strapi.service("admin::user");
    if (/[A-Z]/.test(email)) {
      const dbUser = await userService.findOneByEmail(email.toLocaleLowerCase());
      if (dbUser) {
        return dbUser;
      }
    }

    const createdUser = await userService.create({
      firstname: firstname ? firstname : "unset",
      lastname: lastname ? lastname : "",
      email: email.toLocaleLowerCase(),
      roles,
      preferedLanguage: locale,
    });

    return await userService.register({
      registrationToken: createdUser.registrationToken,
      userInfo: {
        firstname: firstname ? firstname : "unset",
        lastname: lastname ? lastname : "user",
        password: generator.generate({
          length: 43, // 256 bits (https://en.wikipedia.org/wiki/Password_strength#Random_passwords)
          numbers: true,
          lowercase: true,
          uppercase: true,
          exclude: '()+_-=}{[]|:;"/?.><,`~',
          strict: true,
        }),
      },
    });
  },
  triggerSignInSuccess(user) {
    delete user["password"];
    strapi.eventHub.emit("admin.auth.success", {
      user,
      provider: "local-oidc",
    });
  },
  // Sign In Success
  renderSignUpSuccess(jwtToken, user, nonce) {
    let storage = "sessionStorage";
    return `
<!doctype html>
<html>
<head>
<noscript>
<h3>JavaScript must be enabled for authentication</h3>
</noscript>
<script nonce="${nonce}">
 window.addEventListener('load', function() {

  ${storage}.setItem('jwtToken', '"${jwtToken}"');
  ${storage}.setItem('userInfo', '${JSON.stringify(user)}');
   location.href = '${strapi.config.admin.url}'
 })
</script>
</head>
<body>
</body>
</html>`;
  },
  // Sign In Error
  renderSignUpError(message) {
    return `
<!doctype html>
<html>
<head></head>
<body>
<h3>Authentication failed</h3>
<p>${message}</p>
</body>
</html>`;
  },
});

Make sure the file server/src/services/index.ts is updated:

import service from './service';
import oauth from './oauth';

export default {
  service,
  oauth,
};

You probably need to install both openid-client and generate-password from the Strapi project.

Try OIDC Login

Hope it works out fine. I can log in into Strapi using my OIDC provider. Because the password is generated randomly, I have no way to reset it if I choose to login without OIDC. But if Strapi is set up properly one can always try the "Forgot password" route.

Really Local Plugin

In the example above I choose to implement the plugin external to the Strapi project. As a local plugin for internal use, I will probably just move the plugin code inside the Strapi project in the src/plugins folder, and update configurations accordingly. This is also covered in the Strapi docs.