今回は自作の認証プロバイダを実装していきます。認証プロバイダを実装するにあたりoidc-providerとよばれるオープンソースのライブラリを使用します。

~目次~

oidc-providerのインストール

oidc-providerとは

oidc-providerは、Node.jsで認証プロバイダ(OpenID Provider)を実装するためのライブラリです。connect, express, fastify, hapi, koaといったWebフレームワークに対応しています。

oidc-providerはあくまでライブラリなので、インストールしたらそのまま動作するかというとそんなことはなく、サンプル提供されているソースを元に、自分好みにいくつか修正を加えてあげて、ようやく使えるようになります。

また、oidc-providerはOpen ID Connectの認証処理の実装に特化しているため、ユーザ情報に関する処理ロジック(つまりユーザIDの存在確認やパスワードチェック処理など)は自分でコーディングしないといけません。

oidc-providerのインストール

今回はauthserverディレクトリを作成し、そこに独自の認証プロバイダを構築していきます。

以下の手順で、Node.js、Express、その他必要なライブラリ群をインストールしていきます。

$ mkdir authserver
$ cd authserver

$ npm init
$ npm install express --save
$ npm install helmet lodash --save
$ npm install cors --save
$ npm install basic-auth --save

$ npm install dotenv --save

oidc-providerをインストールします。

$ npm install oidc-provider --save

今回は2021年8月時点のバージョン7.6.0を使用していますので、もし同じバージョンを使いたい場合には、npm install oidc-provider@7.6.0 --saveと、バージョン指定でインストールしてください。

 

次に認証プロバイダ用のNode.jsをポート番号=3001で起動します。

これまで認証利用アプリ用のNode.jsをポート番号=3000で起動してきたので、これとは異なるポート番号である3001を使用します。

 

.envファイルを新規作成し、以下の1行を記述します。

PORT=3001

これでソース中に、require('dotenv').config();を記載すればPORT値が読み込まれます。

認証プロバイダのサンプル(node-oidc-provider)をダウンロード

oidc-providerの公式サイトにはoidc-providerを使用した認証プロバイダのサーバーサンプルが提供されています。今回はこのサンプルを元ネタにして自分好みにソース修正していきます。

gitコマンドを使って公式サイトからソース一式をダウンロードします。任意のディレクトリで以下を実行します。

git clone https://github.com/panva/node-oidc-provider

これでサンプルプログラムのnode-oidc-providerがダウンロードされます。
今回は2021年8月時点のバージョン7.6.0を使用していますので、同じバージョンのものを使用したい場合には、git clone https://github.com/panva/node-oidc-provider/tree/v7.6.0 でダウンロードしてください。

認証プロバイダのサンプル(node-oidc-provider)をコピー

ダウンロードした認証プロバイダのサンプル(node-oidc-provider)を、先ほど準備したauthserver内にコピーしていきます。

今回はExpressのサンプルを使用するのでexpress.jsをコピーします。

$ cd node-oidc-provider/example

 

$ cp -rp adapters (authserverの作成場所)/authserver

$ cp -rp routes (authserverの作成場所)/authserver

$ cp -rp support (authserverの作成場所)/authserver

 

$ cp -p express.js (authserverの作成場所)/authserver/index.js

$ cp -p my_adapters.js (authserverの作成場所)/authserver/adapters

authserverのディレクトリイメージはこんな感じになります。

 

 

認証プロバイダのサンプルの修正

node-oidc-providerはそのままでは機能が貧弱なので、自分なりにソース修正していきます。

トップ画面(index.js)

トップ画面にあたるindex.jsを次のように修正します。

require('dotenv').config(); // Read environment values from .env file

const path = require('path');

const url = require('url');

const express = require('express'); // eslint-disable-line import/no-unresolved

const helmet = require('helmet');

const cors = require('cors');

const { Provider } = require('oidc-provider'); // Modified from '../lib' to 'oidc-provider'

const Account = require('./support/account');

const configuration = require('./support/configuration');

const routes = require('./routes/express');

const { PORT = 3000, ISSUER = `http://localhost:${PORT}` } = process.env;

configuration.findAccount = Account.findAccount;

configuration.clientBasedCORS = (ctx, origin, client) => {

  return false;

};

 

const app = express();

app.use(helmet());

app.set('views', path.join(__dirname, 'views'));

app.set('view engine', 'ejs');

// CORSを許可する

app.use(cors({ origin: 'http://localhost:3000', credentials: true }));

 

let server;

(async () => {

  let adapter;

  adapter = require('./adapters/my_adapter'); // 自作アダプタを定義

  /*

  if (process.env.MONGODB_URI) {

    adapter = require('./adapters/mongodb'); // eslint-disable-line global-require

    await adapter.connect();

  }

  */

 ・・・以下、省略・・・

require('dotenv').config();

.envファイル内の環境変数を読み込みます。今回はPORT=3001の定義を読み込みます。

app.use(cors({ origin: 'http://localhost:3000', credentials: true }));

今回はローカルPC上の3000番ポート(認証利用アプリ)から3001番ポート(認証プロバイダ)への通信を行います。しかしセキュリティ上の問題から、何も設定をしないとそのままでは通信エラーとなってしまいます。

この定義をすることで、localhostの3000番ポートからの通信だけを許可するように設定します。

adapter = require('./adapters/my_adapter'); 

node-oidc-providerでは認証処理で払い出した各ユーザのセッション情報やアクセストークン値を保存するための処理を「アダプタ」と呼んでいます。後ほど用意する自作アダプタmy_adapter.jsを読み込みます。

 

メイン処理 (routes/express.js)

認証プロバイダのメイン処理部分になります。エンドユーザ(ブラウザ)からのHTTPリクエスト内容に応じた処理を記載していきます。

 

(主要部分のみ抜粋)

module.exports = (app, provider) => {
  const { constructor: { errors: { SessionNotFound } } } = provider;

  ...中略...


  // エンドユーザからのリクエスト(ログインや認可確認)時に呼ばれる処理

  app.get('/interaction/:uid', setNoCache, async (req, res, next) => {
    try {
      const {
        uid, prompt, params, session,
      } = await provider.interactionDetails(req, res);

      const client = await provider.Client.find(params.client_id);
      switch (prompt.name) {
        case 'login': {
          return res.render('login', {
            client,
            uid,
            details: prompt.details,
            params,
            title: 'Sign-in',
            session: session ? debug(session) : undefined,
            dbg: {
              params: debug(params),
              prompt: debug(prompt),
            },
          });
        }
        case 'consent': {
          return res.render('interaction', {
            client,
            uid,
            details: prompt.details,
            params,
            title: 'Authorize',
            session: session ? debug(session) : undefined,
            dbg: {
              params: debug(params),
              prompt: debug(prompt),
            },
          });
        }
        default:
          return undefined;
      }
    } catch (err) {
      return next(err);
    }
  });
  // ログイン画面でユーザID・パスワード入力後に呼ばれる処理
  app.post('/interaction/:uid/login', setNoCache, body, async (req, res, next) => {
    try {
      const { prompt: { name } } = await provider.interactionDetails(req, res);
      assert.equal(name, 'login');

      // サンプルではログインIDしか渡していないが、ログインIDとパスワードを渡すように修正
       // const account = await Account.findByLogin(req.body.login);
       const account = await Account.findByLogin(req.body);

      let result;
      if ( account ){
        result = {
          login: {
            accountId: account.accountId,
          },
        };
      } else {
        res.status = 401;
        result = {
          error: 'not_authenticated',
          error_description: 'Illegal login id or password.',
        };
      }
      await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
    } catch (err) {
      next(err);
    }
  });
  // 認可確認画面でContinueを押下した後、最終的にアクセストークンを発行する処理
  app.post('/interaction/:uid/confirm', setNoCache, body, async (req, res, next) => {
    try {
      const interactionDetails = await provider.interactionDetails(req, res);
      const { prompt: { name, details }, params, session: { accountId } } = interactionDetails;
      assert.equal(name, 'consent');

      let { grantId } = interactionDetails;
      let grant;

      if (grantId) {
        // we'll be modifying existing grant in existing session
        grant = await provider.Grant.find(grantId);
      } else {
        // we're establishing a new grant
        grant = new provider.Grant({
          accountId,
          clientId: params.client_id,
        });
      }

      if (details.missingOIDCScope) {
        grant.addOIDCScope(details.missingOIDCScope.join(' '));
      }
      if (details.missingOIDCClaims) {
        grant.addOIDCClaims(details.missingOIDCClaims);
      }
      if (details.missingResourceScopes) {
        // eslint-disable-next-line no-restricted-syntax
        for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
          grant.addResourceScope(indicator, scopes.join(' '));
        }
      }
      grantId = await grant.save();
      const consent = {};
      if (!interactionDetails.grantId) {
        // we don't have to pass grantId to consent, we're just modifying existing one
        consent.grantId = grantId;
      }

      const result = { consent };
      await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
    } catch (err) {
      next(err);
    }
  });

app.get('/interaction/:uid', setNoCache, async (req, res, next) ...

エンドユーザからのリクエスト(ログインや認可確認)時に呼ばれる処理です。prompt.nameがloginの場合にはログイン画面を表示し、consentの場合には認可確認画面(Authorize画面)を表示します。

app.post('/interaction/:uid/login', setNoCache, body, async (req, res, next)  ...

ログイン画面でユーザID・パスワード入力後に呼ばれる処理です。

const account = await Account.findByLogin(req.body);

サンプルではAccountクラスにログインIDしか渡していないですが、今回はログインIDとパスワードを渡せるように、HTTPリクエストのBody部をまるっと渡すように修正しています。これでAccountクラスでログインIDとパスワードのチェックを行えるようになります。

app.post('/interaction/:uid/confirm', setNoCache, body, async (req, res, next)  ...

認可確認画面(Authorize画面)でContinueやCancelを押下した場合の処理です。
 

アダプタ (adapters/my_adapter.js)

アダプタ(adapters/my_adapter.js)では、認証プロバイダが内部で保持する各データ(例えば、各ユーザに払い出すAccessTokenやAuthorizationCodeなど)の値を管理する処理を記述します。

デフォルトではメソッドのひな型だけしか用意されておらず、内部処理をまるっと記述する必要がありますが、各データをMongoDBに保存するサンプル(node-oidc-provider/example/adapters/mongodb.js)や、各データをメモリに保存するサンプル(oidc-provider/lib/adapters/memory_adapter.js)を参考に記述していきます。

今回はシンプルに各データをメモリに保存するサンプル(memory_adapters.js)を参考に、自作アダプタを用意します。

'use strict';
const QuickLRU = require('quick-lru');
let storage = new QuickLRU({ maxSize: 1000 });
const grantable = new Set([
  'AccessToken',
  'AuthorizationCode',
  'RefreshToken',
  'DeviceCode',
  'BackchannelAuthenticationRequest',
]);

class MyAdapter {
  constructor(model) {
    this.model = model;
  }

  async upsert(id, payload, expiresIn) {
     const key = `${this.model}:${id}`;
     if (this.model === 'Session') {
       storage.set(`sessionUid:${payload.uid}`, id, expiresIn * 1000);
     }
     const { grantId, userCode } = payload;
     if (grantable.has(this.name) && grantId) {
       const grantKey = `grant:${grantId}`;
       const grant = storage.get(grantKey);
       if (!grant) {
         storage.set(grantKey, [key]);
       } else {
         grant.push(key);
       }
     }
     if (userCode) {
       storage.set(`userCode:${userCode}`, id, expiresIn * 1000);
     }
     storage.set(key, payload, expiresIn * 1000);
  }

  async find(id) {
    return storage.get(`${this.model}:${id}`);
  }

  async findByUserCode(userCode) {
    const id = storage.get(`userCode:${userCode}`);
    return this.find(id);
  }

  async findByUid(uid) {
    const id = storage.get(`sessionUid:${uid}`);
    return this.find(id);
  }

  async consume(id) {
    storage.get(`${this.model}:${id}`).consumed = 60*60*24;
  }

  async destroy(id) {
    storage.delete(`${this.model}:${id}`);
  }

  async revokeByGrantId(grantId) {
    const grantKey = `grant:${grantId}`;
    const grant = storage.get(grantKey);
    if (grant) {
      grant.forEach((token) => storage.delete(token));
      storage.delete(grantKey);
    }
  }
}
module.exports = MyAdapter;

 

let storage = new QuickLRU({ maxSize: 1000 });

各データをメモリ保存するための入れ物(storage)を定義します。

constructor(model) { ... }

入力パラメタのmodelにはデータの種類("AccessToken"や"AuthorizationCode")が渡されてきます。以後、データの種類(model)に応じた処理を実装していきます。

async upsert(id, payload, expiresIn) { ... }

データを登録・更新する処理を記述します。

データの種類が"Session"のケースやgrantable配列にある種類名のケースなどに場合分けして処理を記載します。主要な処理は最後の「storage.set(key, payload, expiresIn * 1000);」の部分で、"データの種類":"id"という文字列を主キー(key)としてデータをメモリ保存します。

async find(id) { ... }

async findByUserCode(userCode) { ... }

async findByUid(uid) { ... }

キーを元にメモリに保存していたデータを取得します。

async destroy(id) { ... }

キーを元にメモリに保存していたデータを削除します。

 

今回実装したアダプタでは認証プロバイダのプロセス(Node.js)を停止してしまうと、ユーザに払い出した"AccessToken"や"AuthorizationCode"が消えてなくなってしまうので、信頼性を求められるサービスの場合、やはりデータベースに保存するロジックにするのがよいでしょう。

 

認証定義部 (support/configuration.js)

認証の定義情報を設定していきます。ここで設定した値は、後ほど作成する認証利用アプリ(AuthSample3)で設定する値と一致させる必要がありますので、忘れないようにメモしておきます。

  clients: [

     {

       client_id: 'SJvt23j8431D********************',

       // client_secret: '7fa_dVrC********************',

       grant_types: ['refresh_token', 'authorization_code'],

       redirect_uris: ['http://localhost:3000/authentication/callback'],

       post_logout_redirect_uris: ['http://localhost:3000/'],

     }

  ],

  clientDefaults : {

    token_endpoint_auth_method: 'none',

  },

...以下略...

client_id: ...

Client IDとして任意の文字列を設定します。認証利用アプリ(AuthSample3)で設定する値と一致させる必要がありますので、忘れないようにメモしておきます。

// client_secret: ...

今回はクライアントシークレットを使った厳密な認証チェックをせずにまずは簡単な方法で実装してみます。ですので現時点では値を設定しません。

token_endpoint_auth_method: 'none' ...

今回はクライアントシークレットを使わないパターン(Client Type = "public")で処理実装するので、この値を'none'にします。

 

アカウント処理部 (support/account.js)

アカウント処理(account.js)では、ログインIDとパスワードの認証チェック処理や、ユーザプロファイル情報を返却したりする処理を実装します。

サンプルのaccount.jsはログイン画面でログインID・パスワードを入力しても、常に固定のユーザプロファイル情報を応答する作りになっていてサンプルとしてはいまひとつです。

今回はもう少しユーザ認証っぽい実装に作り替えます。

// 簡易的なユーザマスタ情報
const accountList = {
  'musashi': { password: '@musashi',name: 'Musashi Miyamoto', email: 'musashi@xxxxx.com' },
  'kojiro' : { password: '@kojiro', name: 'Kojiro Sasaki'   , email: 'kojiro@xxxxx.com'  },
  'kato'   : { password: '@kato'  , name: 'Kiyomasa Kato'   , email: 'kato@xxxxx.com'  },
};

const store = new Map();
const logins = new Map();
const { nanoid } = require('nanoid');

class Account {
  constructor(id, profile) {
    this.accountId = id || nanoid();
    this.profile = profile;
    store.set(this.accountId, this);
  }

  async claims(use, scope) {
    if (this.accountId && accountList[this.accountId]){
      // ログインIDがaccountListにある場合には、emailやnameの情報を返却する
      return {
        sub: this.accountId,
        email: accountList[this.accountId].email,
        name: accountList[this.accountId].name,
      };
    } else {
      // ログインIDがaccountListにない場合には、IDだけを返却する
      return {
        sub: this.accountId,
      }
    }
  }
  // 最初のログイン時に呼び出されるfunction
  //   body: {
  //     login: ログインID,
  //     password: パスワード,
  //   }
  static async findByLogin(body) {
    if (body && body.login){
      if ( accountList[body.login] && accountList[body.login].password === body.password ){
        // ログイン成功時
        return {
          accountId: body.login
        }
      } else {
        // ログイン失敗時
        return undefined;
      }
    }
    return undefined;
  }
  // ユーザ情報ローディングfunction - ログイン認証後(findByLogin)や認可時などに呼び出される
  static async findAccount(ctx, id, token) {
    if (!store.get(id)) new Account(id);
    return store.get(id);
  }
}
module.exports = Account;

const accountList = { ... }

いわゆるユーザマスタです。ログインチェックの元ネタとなるログインID・パスワード・メールアドレスなどの情報を記述しています。本来はデータベースなどに格納する情報ですが、今回はサンプルなので固定値としています。

async claims(use, scope){ ... }

トークン要求(id_token)やユーザ情報要求(userinfo)に対して、ユーザ情報を応答する処理を実装します。

本来scopeの値に応じて応答するユーザ情報を制御すべきですが、今回は持っているユーザ情報をすべて応答します。

static async findByLogin(body) { ... }

ログイン画面でログインIDとパスワードが入力されたときに呼び出されるパスワードチェック処理です。

accountListにログインIDが存在していて、かつパスワードが一致していればログインIDを返却します。不一致の場合にはundefinedを返却します。

 

認証プロバイダの起動

それでは実際に動かしてみます。authserverディレクトリ上で、Node.jsを起動します。

(authserverディレクトリ内で)

$ node index.js

application is listening on port 3001, check its /.well-known/openid-configuration

上記のようにport 3001で要求待ち状態になっていれば成功です。

 

 

次回はこの認証プロバイダに接続する認証利用アプリを作成します。

 

ReactでOpenID Connectを実装する~その7 自前認証プロバイダを立ち上げてみる(認証利用アプリ編)