OpenAM Policy Agent for Node.js に触れてみる

## はじめに

ForgeRock の GitHub で [OpenAM Policy Agent for Node.js](https://github.com/ForgeRock/node-openam-agent) というリポジトリを見つけたので触ってみようと思います。

## OpenAM Policy Agent って？

アプリケーションを OpenAM で保護(SSO 化)するために使うモジュールです。アプリケーションへのリクエストをインターセプトして、認証を促し・認可を行う仕組みになっています。

これまでの Agent は Apache や IIS などの Web サーバーや Tomcat などのサーブレットコンテナに対応しており、各 Web サーバーのモジュールやサーブレットフィルターとして実装されていました。

`OpenAM Policy Agent for Node.js` の登場により、Node.js で Agent を使うことができます。

**(　ﾟдﾟ)ﾎﾟｶｰﾝ**

Web サーバー向けのモジュールが JavaScript(Node.js) で動作するというのはどういうことなんでしょうね…
私は Node.js に明るくないのでイメージが湧きません。

## デモアプリを触ってみる

イメージをつかむために実際に触ってみようと思います。[Agent for Node.js のデモアプリ](https://github.com/ForgeRock/node-openam-agent-demo) があったので、こちらを使ってみることにしました。

ちなみに、Agent for Node.js の最新は 5.0.0 の β 版ですが、デモアプリで利用している Agent のバージョンは 3.1.2 と古いようです。ただ、バージョンによる違いが分からないので気にせずに行きます。

### 環境

Agent の動作には OpenAM が必要になります。今回は OpenAM と Agent でサーバーを分けています。

* OpenAM

| 項目 | 値 |
|---|---|
| OS | CentOS 7.5 |
| ホスト名 | openam.example.co.jp |
| OpenAM のバージョン | OpenAM 13.0.0 |
| URL | `http://openam.example.co.jp:8080/openam` |

* Agent

| 項目 | 値 |
|----|----|
| OS | CentOS 7.5 |
| ホスト名 | app.example.co.jp |
| Node.js のバージョン | v6.14.3 (EPEL の RPM パッケージを利用)|
| URL | `http://app.example.co.jp:8080` |

### OpenAM の準備

OpenAM の初期設定を行い、Agent for Node.js 用に Agent の設定を作成しておきます。ここでは名前に `demo-agent`、パスワードに `changeit` と入力しています。

![agent-setting.png](https://qiita-image-store.s3.amazonaws.com/0/316122/17d4768c-72c9-8c92-54a8-58cbc26af772.png)


### デモアプリの準備

デモアプリのリポジトリを取得します。

```
$ git clone https://github.com/ForgeRock/node-openam-agent-demo.git
```

npm コマンドで依存パッケージをインストールします。

```
$ cd node-openam-agent-demo
$ npm install
```

設定ファイルである `lib/config.json` を編集します。

```json
{
  "serverUrl": "http://openam.example.co.jp:8080/openam", // OpenAM の URL
  "appUrl": "http://app.example.co.jp:8080",              // デモアプリ(Agent) の URL
  "notificationRoute": "/",                               // OpenAM からの通知を受け取るパス?
  "notificationsEnabled": true,                           // 通知の有効化
  "username": "demo-agent",                               // Agent 名
  "password": "changeit",                                 // Agent のパスワード
  "realm": "/",                                           // OpenAM のレルム
  "appName": "iPlanetAMWebAgentService",                  // ポリシーセット名
  "logLevel": "info"                                      // ログレベル
}
```

`npm start` を実行すると 8080 ポートでデモアプリが起動します。

```
$ npm start
...
2018-12-06T04:18:25.291Z - info: [5i2h0NCxC] Agent initialized.
Example 1 started on port 8080
```

### デモアプリの動作

デモアプリ起動後、`http://app.example.co.jp:8080/members` にアクセスしてみます。

OpenAM での認証が済んでいない状態だと 401 の画面が表示されます。他の Agent では認証が済んでいない場合、OpenAM へ認証のためにリダイレクトされるのですが、挙動が違っています。

![example1_401.png](https://qiita-image-store.s3.amazonaws.com/0/316122/d2f74b09-503f-bb97-4fd5-70d6d186a5be.png)

認証が済んでいると `Hello demo` と表示されます。

![example1_200.png](https://qiita-image-store.s3.amazonaws.com/0/316122/94e8854f-083d-d470-f54a-b37dfebae69e.png)

## デモアプリのコードを確認する

それでは Agnet をどのように使っているのか、デモアプリのソースコード(`lib/example1/index.js`)を見てみましょう。

```javascript
  1 var http = require('http'),
  2     openamAgent = require('openam-agent'), // Agent モジュールのロード
  3     PolicyAgent = openamAgent.PolicyAgent, 
  4     CookieShield = openamAgent.CookieShield;
  5 
  6 var agent = new PolicyAgent(require('../config.json')), // Agent オブジェクト
  7     shield = new CookieShield({                         // Shield オブジェクト
  8         getProfiles: true,
  9         noRedirect: true
 10     });
 11 
 12 
 13 var server = http.createServer(function (req, res) {
 14     var middleware = agent.shield(shield); // Agent にクッキーによる保護を設定
 15 
 16     if (req.url === '/members') {
 17         // enforce shield
 18         middleware(req, res, function () { // パスが /members の場合に Agent で保護
 19             // app logic
 20             res.writeHead(200);
 21             res.end('Hello ' + req.session.data.username + '!');
 22 
 23         });
 24     } else {
 25         res.writeHead(404);
 26         res.end('Not found');
 27     }
 28 });
 29 
 30 server.listen(8080, function () {
 31     console.log('Example 1 started on port %s', server.address().port);
 32 });
```

Web サーバーに導入する Agent と違い、SDK という感じですね。

`CookieShield` はクッキーで保護するオブジェクトです。これを使うことで OpenAM で認証が済んでいればリソースにアクセスできます。
また、9 行目で `noRedirect` を `true` に設定しているため、認証が済んでいない場合でも OpenAM にリダイレクトされません。

## 他のデモアプリ

デモに最初に動作を確認したもの(example1)以外に 2 つのデモアプリが含まれています。デモの内容・特徴は次の通りです。

* example2
  * 認可の方式を変える
  * キャッシュの方式を変える
* example3
  * 認可の方式のカスタマイズ

それでは特徴毎にコードを見ていきましょう。

### 認可の方式を変える

example1 で `CookieShield` が登場しましたが、example2 では他の Shield が登場します。

```javascript
 46 var cookieShield = new openamAgent.CookieShield({getProfiles: true, cdsso: false, noRedirect: false}),
 47     passThroughShield = new openamAgent.CookieShield({getProfiles: true, passThrough: true}),
 48     policyShield = new openamAgent.PolicyShield(config.appName),
 49     oauth2Shield = new openamAgent.OAuth2Shield(),
 50     basicAuthShield = new openamAgent.BasicAuthShield();
```

* CookieShield
 * OpenAM の認証クッキーで保護する
 * passThrough に true を指定すると認証していなくてもアクセス可能(適用されない URL の属性の取得に相当)
* PolicyShield
 * OpenAM のポリシーで保護する
* OAuth2Shield
  * OAuth2 のアクセストークンで保護する
* BasicAuthShield
  * Basic 認証で保護する

各 Shield の動作を試すには `npm start example2` を実行して example2 のデモアプリを起動します。起動後、`http://app.example.co.jp:8080` にアクセスするとデモアプリのトップページが表示されるのでメニューに従って試してみてください。

![example2.png](https://qiita-image-store.s3.amazonaws.com/0/316122/6ad93a7e-a774-b862-4353-a339b6bbd1c4.png)

### キャッシュの方式を変える

OpenAM Policy Agent ではセッションや認可情報をキャッシュする仕組みがあります。Apache HTTP Server の Agent ではメモリーで管理していますが、Agent for Node.js ではメモリー以外のバックエンドを選択できるようです。

```javascript
  4     SimpleCache = require('openam-agent-cache-simple').SimpleCache,
  5     MemcachedCache = require('openam-agent-cache-memcached').MemcachedCache,
  6     CouchDBCache = require('openam-agent-cache-couchdb').CouchDBCache,
  7     RedisCache = require('openam-agent-cache-redis').RedisCache,
  8     MongoCache = require('openam-agent-cache-mongodb').MongoCache,
```

SimpleCache はメモリーを利用します。その他はクラス名からバックエンドが類推できるので説明は省きます…

### 認可の方式のカスタマイズ

先ほど 幾つかの認可の方式を紹介しましたが、Shield を自分で拡張することで独自の認可処理を行うこともできます。

example3 では CustomerShield という独自の認可処理が定義されています。ユーザー情報の roles に `customer` が入っていればアクセスを許可するという内容です。

```javascript
  5 class CustomerShield extends Shield {
  6     evaluate(req, res, agent) {
  7         const rolesProperty = 'roles';
  8 
  9         return new Promise((resolve, reject) => {
 ..(省略)
 22             if (!req.session.data[rolesProperty].includes('customer')) {
 23                 reject(new ShieldEvaluationError(403, 'not a customer'));
 24             }
 25 
 26             resolve();
 27         });
 28     }
 29 }
```

## まとめ

Node.js で実装された Web サーバーに組み込める Agent を紹介しました。

OAuth 2.0 のリソースサーバーを実装できる点や、認可処理を Agent 側で自由に変更できる点が他の Agent には無い面白さでしょうか。

ただ、使いどころは難しい印象です。既存アプリを SSO 化するのであれば Web サーバーに Agent を導入した方が簡単でしょうし、新規アプリの SSO 化に使うとしても OpenID Connect や SAML 2.0 といったオープンなプロトコルに対応したもの方がロックインされないメリットがあります…

## 余談（ハマりどころ）

**PolicyShield** の動作を確認するのに苦労しました。

PolicyShield は OpenAM の policy evaluation REST API を利用するのですが、POST データは次のような JSON になっていました。

```json
{
  "resources": [
    "/admin"
  ],
  "application": "iPlanetAMWebAgentService",
  "subject": {
    "ssoToken": "AQI..."
  }
}
```

本来 `resources` には URL を指定してほしいのですが、リクエストにはパスしか含まれていません。そのため、アクセスが拒否されてしまいました。

結局、パスのみのポリシーを特別に用意して回避しました。