ReactでOpenID Connectを実装する~その6
今回は自作の認証プロバイダを実装していきます。認証プロバイダを実装するにあたりoidc-providerとよばれるオープンソースのライブラリを使用します。~目次~ ReactでOpenID Connectを実装する~その1 準備 ReactでOpenID Connectを実装する~その2 Auth0サービスを使ってみる (Auth0設定編) ReactでOpenID Connectを実装する~その3 Auth0サービスを使ってみる (アプリ開発編) ReactでOpenID Connectを実装する~その4 react-oidcを使ってみる ReactでOpenID Connectを実装する~その5 自前認証プロバイダを立ち上げてみる(処理フロー編) ReactでOpenID Connectを実装する~その6 自前認証プロバイダを立ち上げてみる(認証プロバイダ編) ☆この記事☆ ReactでOpenID Connectを実装する~その7 自前認証プロバイダを立ち上げてみる(認証利用アプリ編) ReactでOpenID Connectを実装する~その8 あとがき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 --saveoidc-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/adaptersauthserverのディレクトリイメージはこんな感じになります。 認証プロバイダのサンプルの修正node-oidc-providerはそのままでは機能が貧弱なので、自分なりにソース修正していきます。トップ画面(index.js)トップ画面にあたるindex.jsを次のように修正します。 require('dotenv').config();//Readenvironmentvaluesfrom.envfile constpath=require('path'); consturl=require('url'); constexpress=require('express');//eslint-disable-lineimport/no-unresolved consthelmet=require('helmet'); constcors=require('cors'); const{Provider}=require('oidc-provider');//Modified from '../lib' to 'oidc-provider' constAccount=require('./support/account'); constconfiguration=require('./support/configuration'); constroutes=require('./routes/express'); const{PORT=3000,ISSUER=`http://localhost:${PORT}`}=process.env; configuration.findAccount=Account.findAccount; configuration.clientBasedCORS=(ctx,origin,client)=>{ returnfalse; }; constapp=express(); app.use(helmet()); app.set('views',path.join(__dirname,'views')); app.set('viewengine','ejs'); //CORSを許可する app.use(cors({origin:'http://localhost:3000',credentials:true})); letserver; (async()=>{ letadapter; adapter=require('./adapters/my_adapter');//自作アダプタを定義 /* if(process.env.MONGODB_URI){ adapter=require('./adapters/mongodb');//eslint-disable-lineglobal-require awaitadapter.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でOpenIDConnectを実装する~その7 自前認証プロバイダを立ち上げてみる(認証利用アプリ編)