Node.jsでOpenID ConnectのOPとRPを実装してみた

[OpenID Connect](http://www.openid.or.jp/)を使った最低限の枠組みを試しに作ってみた。
いろいろ認識間違っているところがある気がしているので **優しく** 指摘していただければ……。

動機については以前の記事にて。
[OpenID Connect対応の認証サーバ(OP/IdP)を作るために調べてみた](http://qiita.com/moomooya/items/18946804bac4d8eb05b1)

イメージとしては

1. サービスサイトの認証ページにアクセス
2. 認証プロバイダのログインページにアクセス
3. サービスサイトに戻されて認証状態でサービスサイトを利用する

みたいな仕組みの枠組みを作ってみます。
ユーザ情報の検索などは全てダミーデータを使って代替しています。

念のため用語説明：

OP: OpenID Provider 認証する（プロバイダー）側
RP: Relying Party 認証してもらう（クライアント）側

使ったもの
---

* Node.js v6.10.2
* OP側
    * [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider)
* RP側
    * express
    * express-session
    * passport
    * [panva/node-openid-client](https://github.com/panva/node-openid-client)

Certifiedなライブラリを使いたかったので[panva氏](https://github.com/panva)のライブラリを使用します。

OP側の実装
---

https://github.com/moomoo-ya/oidc-sample

プロバイダー側は最低限でよければこんな感じ。

```js:index.js
'use strict';

const Provider = require('oidc-provider');
const issuer = 'http://localhost:3030';
const config = {
};

// この部分は実際に使うときはDBから取得する
const clients = [
  {
    client_id: '0',
    client_secret: '0',
    scope: 'openid email',
    redirect_uris: [
      'http://localhost:3000/auth/cb'
    ]
  }
];

const oidc = new Provider(issuer, config);
oidc.initialize({ clients }).then(() => {
  console.log(oidc.callback);
  console.log(oidc.app);
  oidc.app.listen(3030);
});
```

clients配列の中のオブジェクトには

* client_id
* client_secret
* scope
* redirect_uris

が必須。

`scope`に関しては[指定できる値が決められている](http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#ScopeClaims)。最初にopenidの指定は必須。
ここの指定が変わることで必要な実装も変わるはずだが、そこまではまだ把握できていない。

`redirect_uris`はコールバック先として許可するURIを配列で保持する。RPから`redirect_uri`として渡すコールバック先はここに指定したURIと完全に一致しないとエラーになる（末尾の`/`の有無も一致させる）。

RP側の実装
---

https://github.com/moomoo-ya/oidc-client-sample

ベースは`express-generator`で作成し、[このコミットでの差分](https://github.com/moomoo-ya/oidc-client-sample/commit/40a796571c207a1d537d413784ea05604ccc806f)がRPとしての実装部分になります。

Express.jsを用いてクライアント側のサービスを書いてみましたが、アプリケーション全体を認証処理でまるっと囲ってあげる感じで実装すれば良さそうです。

### ブートストラップ(`bin/www`)の修正

```js:bin/www
var appFactory = require('../app');

// ...

const { Issuer } = require('openid-client');

// OPの探索処理
Issuer.discover('http://localhost:3030').then(issuer => {

  // 発見したissuerをapp.jsに渡してclientの取得処理を行う（後述）
  const app = appFactory(issuer);

  // 既存のbin/wwwの処理
  // ...

}).catch(err => {
  console.error(err);
  process.exit(1);
});

// スコープの関係で、変数serverを利用しているイベントロガー類を削除
// 実サービスに載せるときはちゃんと実装し直す必要がある
```

`Issuer.discover()`の引数にOP提供のURLを指定するだけで、OPの探索処理が実行されるのでちょっと驚いた。すごくお手軽。

### コントローラ(`app.js`)の修正

まず全体の構成として`express-generator`で作成する雛形だと

```js
var app = express();
// ...
module.exports = app;
```

という形で外部に提供されるが、先述の通り探索した結果の`issuer`を受け取りたいので、以下のように`issuer`を引数とする関数を提供するようにする。

```js
module.exports = (issuer) => {
  // 引数issuerを用いた初期化処理など
  // ...

  var app = express();

  // ...

  return app;
}
```

その他`app.js`への主要な修正は以下の通り。

```js:app.js
module.exports = (issuer) => {

  const client = new issuer.Client({
    client_id: '0',
    client_secret: '0'
  });

  // redirect_uriはOPで定義したredirect_urisに含まれている値でないとエラーになる
  const params = {
    redirect_uri: 'http://localhost:3000/auth/cb'
  };

  // 'oidc'と名前をつけて認証ストラテジーを定義する
  passport.use('oidc', new Strategy({ client, params }, (tokenset, userinfo, done) => {
    console.log('tokenset', tokenset);
    console.log('access_token', tokenset.access_token);
    console.log('id_token', tokenset.id_token);
    console.log('claims', tokenset.claims());
    console.log('userinfo', userinfo);

    // プロバイダー側で入力したユーザIDがtokenset.claims.subに格納されているので
    // 実運用時はこの部分でユーザ検索して存在チェックをする
    // ここで比較するのはユーザー名ではなく、認証サーバーで識別されるユーザーID
    if (tokenset.claims().sub !== '0') {
      return done(null);
    }
    // 存在するユーザーで認証されている場合はユーザー情報を返す
    // 返した情報は`req.user.client_id`に格納される
    return done(null, userinfo);

  }));

  var app = express();

  // ...

  // express-sessionの定義
  app.use(session({
    secret: 'keyboard cat',
    resave: false,
    saveUninitialized: false
  }));

  // passport利用開始の準備
  app.use(passport.initialize());
  app.use(passport.session());

  // ...

  // 認証ページへのルーティング
  app.get('/auth', passport.authenticate('oidc'));
  // プロバイダーからのコールバック先へのルーティング
  app.get('/auth/cb', passport.authenticate('oidc', {
    successRedirect: '/',　　　　　　　　　　//　認証成功時の遷移先
    failureRedirect: '/users' //　認証失敗時の遷移先
  }));

  // ...

  return app;
}

// ユーザ情報をセッションに保存するためにpassportにシリアライザ、デシリアライザを定義する

passport.serializeUser(function(user, done) {
  // 実際の処理ではセッション情報にはIDなどキーとなる情報のみ保存する
  done(null, user);
});

passport.deserializeUser(function(id, done) {
  // 実際の処理ではセッション情報にもつIDなどからユーザ情報を取得する
  done(null, {client_id: id});
});
```

express-sessionの定義部分については「[Node.js + Express.js + express-sessionでセッションにデータ格納する方法](http://qiita.com/moomooya/items/00f89e425a3034b8ea14)」もご参照ください。

起動と動作確認
---

1. OPを`node index.js`で起動
2. RPを`npm start`で起動
    * `bin/www`でOPを探索しているので起動時にOPが起動していないとエラーで起動できない
3. RPの認証ページ`http://localhost:3000/auth`に遷移
4. OPの認証ページ`http://localhost:3030`に飛ばされる
    * ID/PASSをそれぞれ `0` / `0` で認証する
5. RPのコールバック先`http://localhost:3000/auth/cb`に遷移して（見た目にはわからない）、認証成功していれば`http://localhost:3000/`にリダイレクトされる

まだよくわかっていない部分
---

* プロバイダー側とクライアント側で`client_id`, `client_secret`をどこでマッチングしているのか
* パラメータ`scope`の違いによる実装内容、挙動の違い
* `client_id`, `client_secret`はクライアントサービスごとに定義されているっぽいが、クライアントサービス内で管理しているユーザの扱い