OpenAM のスクリプト認証モジュールの紹介

# はじめに

私は社内で OpenAM の開発やサポートに携わっているため OpenAM の機能を紹介していこうと思います。今回のお題は認証モジュールの１つであるスクリプト認証モジュールです。

なお、この記事では OpenAM の機能について記載しますので、OpenAM の概要や設定方法などは省略します。ある程度 OpenAM についての知識が必要になります。

また、利用する OpenAM は弊社の OSSTech OpenAM 13 です。ForgeRock OpenAM 13 との挙動の違いがあるかもしれません、スミマセン…

### なぜスクリプト認証モジュールを選んだのか

OpenAM の標準機能では満たせない要件の案件では開発を行うことがあります。
従来は比較的簡単な要件であっても OpenAM の認証モジュールを Java で開発して対応していました。しかし、スクリプト認証モジュールの登場によって数十行程度のスクリプトを書くだけで済むケースも出てきました。
このような経緯があり、スクリプト認証モジュールで出来ることを紹介しようと思い至った次第です。

# スクリプト認証モジュールとは

スクリプト認証モジュールは OpenAM 12 から登場した機能です。認証モジュールのコアな処理をスクリプトで書けるようにしたものです。

スクリプト認証モジュールには`サーバーサイドスクリプト`と`クライアントサイドスクリプト`という 2 種類のスクリプトで動作します。

<img src="https://qiita-image-store.s3.amazonaws.com/0/316122/db2d3656-2b8a-97c2-a122-9a12b42e7441.png" width=75%>

クライアントが OpenAM にアクセスしてスクリプト認証モジュールが動作すると、まずクライアントサイドスクリプトが返却されます。クライアントサイドスクリプトはクライアントの情報を収集して OpenAM に POST します。OpenAM ではクライアントの情報を入力としてサーバーサイドスクリプトが動作し、認証処理が行われます。

2 種類のスクリプトの特徴をまとめてみます。

* クライアントサイドスクリプト
  * クライアントサイド（ブラウザ）で実行されるスクリプト
  * クライアントサイドの情報収集を担当（情報は自動 POST でサーバーに送られる）
  * スクリプト言語は JavaScript

* サーバーサイドスクリプト
  * サーバーサイドで実行されるスクリプト
  * サーバーサイドでの認証処理を担当
  * スクリプト言語は JavaScript または Groovy
  * できること
     * クライアントスクリプトが収集した情報へのアクセス
     * HTTP Client による外部との通信
     * デバッグログの出力
     * リクエストヘッダーなどのリクエストパラメーターの取得
     * ユーザー情報の読み書き
     * Java クラスの利用(Java class whitelist で利用するクラスを許可しておく必要がある)

## スクリプト認証モジュールを動かしてみる

OpenAM はデフォルトでサーバーサイドスクリプトのサンプルコードが登録されていますので、これを動かしてみます。ちなみに、クライアントスクリプトのサンプルはありません。

### サンプルコード

まずはサンプルコードを眺めてみます。内容は「9 時台から 17 時台の間なら認証 OK」というものです。

```JavaScript
var START_TIME = 9;  // 9am
var END_TIME   = 17; // 5pm

logger.message("Starting authentication javascript");
logger.message("User: " + username);

// Log out current cookies in the request
if (logger.messageEnabled()) {
    var cookies = requestData.getHeaders('Cookie');
    for (cookie in cookies) {
        logger.message('Cookie: ' + cookies[cookie]);
    }
}

if (username) {
    // Fetch user information via REST
    var response = httpClient.get("http://localhost:8080/openam/json/users/" + username, {
        cookies : [],
        headers : []
    });
    // Log out response from REST call
    logger.message("User REST Call. Status: " + response.getStatusCode() + ", Body: " + response.getEntity());
}

var now = new Date();
logger.message("Current time: " + now.getHours());
if (now.getHours() < START_TIME || now.getHours() > END_TIME) {
    logger.error("Login forbidden outside work hours!");
    authState = FAILED;
} else {
    logger.message("Authentication allowed!");
    authState = SUCCESS;
}
```

サンプルとしてスクリプト認証モジュールが提供する API をなるべく利用しているため、無駄な処理も含まれている印象です。サンプルコードには次の API が使われています。

|API|説明|
|---|---|
| logger | デバッグログ出力用 API |
| requestData | リクエストデータアクセス用 API |
| username | ユーザー名<br>IN: 前段の認証で特定したユーザー名<br>OUT: スクリプト認証で特定したユーザー名|
| httpClient | HTTP Client |

API の詳細は [ForgeRock のドキュメント](https://backstage.forgerock.com/docs/openam/13/dev-guide/#scripting-api) をご覧ください。

### サンプルを動かす

実際に動かしてみます。サンプルは `username` を入力値としています。この値は別の認証から取得する必要があるため、ここではデフォルトの認証であるデータストア認証と組み合わせています。 

OpenAM にアクセスするとデータストア認証の画面が表示されます。

![SampleAuth1.png](https://qiita-image-store.s3.amazonaws.com/0/316122/1f171ad1-d809-4016-2ddc-3efc4bfba372.png)

ユーザー名パスワードを入力してログインボタンを押すと、画面が変化して `Collecting Data...` と表示されます。この間にクライアントサイドスクリプトが動作します（設定していれば）。

![SampleAuth2.png](https://qiita-image-store.s3.amazonaws.com/0/316122/e431d6a1-f6ef-9a06-0f8c-bd8a9216f165.png)

9 時台から 17 時台の間であれば認証が成功し、ユーザープロファイル画面が表示されます。

![SampleAuth3.png](https://qiita-image-store.s3.amazonaws.com/0/316122/51fcae7f-cc00-f76c-ea5b-eb16600420e5.png)

18 時以降であれば認証が失敗します。

![SampleAuth4.png](https://qiita-image-store.s3.amazonaws.com/0/316122/6c2a74c6-fe9d-bb31-32ab-24f952581533.png)

このサンプルでスクリプト認証モジュールの良さは伝わるのでしょうか？**いや、かなり厳しいですね…**

スクリプト認証モジュール良さを伝えるために、こんな事もできるという実例を紹介していこうと思います。

## 利用例 1 : パスワード警告画面の表示

近頃パスワードの定期更新は非推奨の方針へ変わっていますが、これまでは要件に含まれていることが多々ありました。
既存環境で有効期限を独自属性で用意していて、SSO 導入時にこの属性で警告画面を用意する要件があると、開発が必要になってしまいます。

ではパスワード警告画面の表示をスクリプト認証でやってみましょう。

### 前提条件

* パスワード有効期限が切れていたら認証を失敗させる
* パスワード有効期限属性を持つ(今回は ExpireAttribute)
* パスワード有効期限のフォーマットは `yyyy/MM/dd HH:mm:ss`
* 警告画面にはパスワード変更画面へのリンクを用意する(今回は `​http://localhost`)

### 有効期限チェック用サーバーサイドスクリプト

```JavaScript
function getAttribute(attributeName) {
  var profiles = idRepository.getAttribute(username, attributeName),
      iter,
      value;

  if (profiles) {
    iter = profiles.iterator();
    if (iter.hasNext()) {
      value = iter.next();
      if (value != null) {
        return value;
      }
    }
  }
  return null;
}

var attr = getAttribute("ExpireAttribute");
var now = new java.util.Date();
if (attr != null) {
  var df = new java.text.SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
  var expire = df.parse(attr);
  if (now.after(expire)) {
    authState = FAILED;
  } else {
    authState = SUCCESS;
  }
} else {
    authState = SUCCESS;
}
```
OpenAM のデフォルト設定では次のクラスの利用が許可されていません。Java class whitelist の設定に追加して、許可する必要があります。

* java.util.Date
* java.text.SimpleDateFormat

### 警告画面表示用クライアントスクリプト

```JavaScript
spinner.hideSpinner(); // スピナーを表示しない
$('#messages').hide(); // エラーメッセージを表示しない
$('#loginButton_0').hide(); // ログインボタンを表示しない
autoSubmitDelay = 1000000; // 自動サブミットの時間を延ばす
$('h1').text('パスワードの有効期限が切れています。');
$('h1').css('color','red');
$('.form-group').eq(0).append('<a title="パスワード変更はこちら" href="http://localhost">パスワード変更はこちら</a>');
$('.form-group').eq(0).addClass("text-center");
```

### 認証連鎖

1. データストア認証
  * Requisite（成功時は認証連鎖継続、失敗時は認証失敗として認証連鎖終了）
2. スクリプト認証１
  * Sufficient（成功時は認証成功として認証連鎖終了、失敗時は認証連鎖継続）
  * 有効期限チェック用サーバーサイドスクリプト
3. スクリプト認証２
  * Requisite（ここでは必ず認証失敗）
  * 警告画面表示用のクライアントスクリプトを指定
  * `authState = FAILED` のみのサーバーサイドスクリプトを指定（必ず認証失敗）

### 動作

パスワード有効期限が切れている場合、データストア認証の後に次の画面が表示されます。前提条件には`認証失敗させる`と書きましたが正確には認証を継続できなくする形となります。

![Case1_1.png](https://qiita-image-store.s3.amazonaws.com/0/316122/f0cffa7f-98c5-3091-5431-17364ac6af30.png)

このようにサーバーサイドスクリプトを使えば OpenAM 標準のアダプティブリスクモジュールと比べて柔軟な属性チェックができますし、クライアントスクリプトでは画面カスタマイズも可能です。

## 利用例 2 ： SSO 連携

ある SSO 製品から OpenAM に移行するという案件がよくあります。そして、移行計画によっては旧 SSO 環境と新 SSO 環境を平行稼働させて、逐次、旧 SSO 配下のアプリケーションを新 SSO 配下につなぎ直すというケースもあります。

そういったケースでは平行稼働中に新・旧 SSO で 2 回認証するのを避けるために旧 SSO 配下に新 SSO をぶら下げたいという要件も出てきます。

<img src="https://qiita-image-store.s3.amazonaws.com/0/316122/5dbf6e97-22f4-3f84-307b-698bca49045c.png" width=75%>

スクリプト認証では HTTP Client を利用できます。旧 SSO の仕様に依りますが、OpenAM から旧 SSO に問い合わせることで SSO 連携が可能かもしれません。

### 前提条件

* 旧 SSO 製品の認証情報が OpenAM へのリクエストに含まれる（今回は Cookie。Cookie ドメインが同じであること）
* 旧 SSO 製品には認証情報を問い合わせるための REST API がある

今回は旧 SSO も OpenAM を利用します…
なお、クッキー名は 旧SSO が iPlanetDirectoryProOld、新 SSO が iPlanetDirectoryProNew とします（新しい方は特に意識する必要はないですが）。

### SSO 連携用サーバーサイドスクリプト

新 OpenAM に渡された旧 OpenAM のクッキーでユーザー名を問い合わせるスクリプトです。

```JavaScript
authState = FAILED;
var cookieStr = requestData.getHeader('Cookie');
if (cookieStr) {
  logger.error('Cookie: ' + cookieStr);
  var cookies = cookieStr.split(";"),
      sessionCookie;
  for (i in cookies) {
    if (cookies[i].indexOf("iPlanetDirectoryProOld") > 0) {
       sessionCookie = cookies[i].split("=")[1];
    }
  }
  if (sessionCookie) {
    var response = httpClient.post(
        "http://oldsso.example.co.jp:8080/openam/json/users?_action=idFromSession", 
        "{}",
        {
          cookies:[
            {
              "domain": ".example.co.jp",
              "field": "iPlanetDirectoryPro",
              "value": sessionCookie
             }
          ],
          headers:[
            {
              "field": "Content-Type",
              "value": "application/json"
             }
          ]
        }
    );
    if (response.getStatusCode() == 200) {
      var entity = JSON.parse(response.getEntity());
      username = entity.id;
      authState = SUCCESS;
    }
  }
}
```

ちなみに、このサーバーサイドスクリプトを動作させるためには OpenAM に改修が必要です…

httpClient はリクエストの Content-Type ヘッダーに `*/*` を指定します。しかし、OpenAM の `users` REST API はこの Content-Type ヘッダーを拒否してしまいます。`application/json` を指定する必要があるのですが、OpenAM 13 の httpClient にはいくつか問題があって、Content-Type ヘッダーを指定することができないのです…

### 認証連鎖

1. スクリプト認証
  * Sufficient（成功時は認証成功として認証連鎖終了、失敗時は認証連鎖継続）
  * SSO 連携用サーバーサイドスクリプト
2. データストア認証
  * Requisite（成功時は認証連鎖継続、失敗時は認証失敗として認証連鎖終了）

### 動作

旧 SSO にログインした状態でアクセスすると、認証が成功します。

![SampleAuth3.png](https://qiita-image-store.s3.amazonaws.com/0/316122/51fcae7f-cc00-f76c-ea5b-eb16600420e5.png)

旧 SSO にログインしていない状態でアクセスすると、データストア認証が表示されます。より連携を強化するのであれば旧 SSO へリダイレクトする仕組みを組み込むべきかもしれません。

![Case2_2.png](https://qiita-image-store.s3.amazonaws.com/0/316122/31998e4c-67dd-f673-6608-44f9bf4ede80.png)


このようにスクリプト認証を活用すれば他の IdP 等との連携も可能です。

## スクリプト認証モジュールの制限

スクリプト認証モジュールでできることは`リクエストパラメーターへのアクセス`、`HTTP クライアントによる外部との通信`、`ユーザー属性の操作`、果ては `Java クラスも利用できる`ので、とても範囲が大きいですが、できないこともあります。いくつか洗い出してみました。

#### レスポンスを操作できない

リクエストを操作する API はありますが、レスポンスを操作する API はありません。たとえばクッキーを付与することはできません。

#### クライアントサイドスクリプトを動的に変更することができない

クライアントスクリプトは固定になります。サーバーサイドスクリプトの前に実施されるという仕様ですし、スクリプトの一部を置換するような仕組みもありません。そのため、例えば利用例 1 でパスワード有効期限の残り時間を表示させたいという要件があっても、その対応は困難です。

#### インタラクティブではない（一方向）

サンプルコードを動作させるとわかりますが、スクリプト認証モジュールが動作している間、ユーザーはなにも入力を行いません（フォーム入力などはない）。そのため、スクリプト認証モジュールは JavaScript の情報や HTTP リクエストの情報から自動で判断するものになります。

クライアントスクリプトを作りこむことで、フォームの入力を求めるといったことも可能かもしれませんが、少なくとも向いてはいないでしょう。

また、再帰処理や状態遷移もありません。OpenAM のワンタイムパスワード認証の中には OTP を 3 回まで入力可能なものがありますが、スクリプト認証モジュールは各スクリプトを 1 回ずつしか実行できません。つまり、サーバーサイドスクリプトの結果から、再度クライアントスクリプトを表示させるようなことはできません。

## まとめ

スクリプト認証モジュールの可能性を広げてみました。認証部分のちょっとしたカスタマイズであれば**スクリプト認証モジュール**を活用してみてはいかがでしょうか。