OpenAM + Node.js(+ Express + passport-saml)でSAML認証

## はじめに

以下の構成で、SP initiated SSO(Single Sign On)、IdP Initiated SSO を動作検証した時の記録です。

* IdP: OpenAM
* SP: Node.js(+ Express + passport-saml)

ホストOSは、macOS High Sierra。
OpenAMはDockerコンテナとして、Node.jsはOS上で直接稼働させます。

IdP、SPのFQDNは以下のようにします。

* IdP(OpenAM): [openam.example.local](openam.example.local)
* SP(Node.js): www.example2.local

## OpenAMの起動

Dockerは、OSに導入済みとします。

[こちら](https://github.com/vaultsystems/docker-openam) のDocker Imageをベースに使用します。

まずは、Gitからイメージをダウンロードし、作業ディレクトリにcdします。

```
$ git clone https://github.com/vaultsystems/docker-openam.git
$ cd docker-openam
```

Dockerイメージのベースは、「FROM tomcat:8-jre8」となっていますが、
2017/11現在では、Tomcat 8.5がダウンロードされてしまい、Dockerfileが想定しているTomcat 8.0系ではないため、このままでは動きません。

そこで、「From tomcat:8-jre8」を「From tomcat:8.0-jre8」に変更します。

変更後のDockerfileは、以下の通りです。
(コメント部分はオリジナルのDockerfileに追加した部分になります)

```
# 8.0系を明示的に指定
FROM tomcat:8.0-jre8
MAINTAINER Christoph Dwertmann <christoph.dwertmann@vaultsystems.com.au>

# OpenAMをダウンロードして、TomcatにDeploy
RUN wget https://github.com/OpenRock/OpenAM/releases/download/13.0.0/OpenAM-13.0.0.zip && \
    unzip -d unpacked *.zip && \
    mv unpacked/openam/OpenAM*.war $CATALINA_HOME/webapps/openam.war && \
    rm -rf *.zip unpacked

# ヒープサイズの最大値を変更、Javaはサーバー用VMを指定
ENV CATALINA_OPTS="-Xmx2048m -server"

# Tomcatの設定ファイルであるserver.xmlのConnector port="8443"行のコメントアウトを外すと共に、
# keystoreファイル、keystoreのパスワードが環境変数で渡されるように変更
# 最後にtomcatを起動
CMD perl -0pi.bak -e 's/<!--\n    <Connector port="8443"/<Connector port="8443" maxHttpHeaderSize="102400" keystoreFile="\/opt\/server.keystore" keystorePass="$ENV{'KEYSTORE_PASS'}"/' $CATALINA_HOME/conf/server.xml && \
    perl -0pi.bak -e 's/sslProtocol="TLS" \/>\n    -->/sslProtocol="TLS" \/>/' $CATALINA_HOME/conf/server.xml && \
    catalina.sh run
```

DockerイメージをDockerfileからビルドします。

```
$ docker build -t openam .
Sending build context to Docker daemon  140.8kB
Step 1/5 : FROM tomcat:8.0-jre8
8-jre8: Pulling from library/tomcat
3e17c6eae66c: Pull complete
fdfb54153de7: Pull complete
...(省略)
```

OpenAMのDockerイメージを起動する前に、以下の手順でkeystoreファイルの準備をする必要があります。

* サーバーの秘密鍵を作成します。

```
$ openssl genrsa -aes256 4096 > server.key
Generating RSA private key, 4096 bit long modulus
................................................................................................................++
........................................................++
e is 65537 (0x10001)
Enter pass phrase: <パスフレーズを指定>
Verifying - Enter pass phrase: <パスフレーズを再入力>
```

* CSR(Certificate Signing Request、証明書署名要求)を作成します。
証明書署名要求は、認証局(CA)にサーバの公開鍵に電子署名してもらうよう要求するためのメッセージになります。
(今回は自己署名証明書を作成するため、後ほど自身で署名します)

```
$ openssl req -new -key server.key > server.csr
Enter pass phrase for server.key:
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) []:Chuo-ku
Organization Name (eg, company) []:Hoge company
Organizational Unit Name (eg, section) []:Fuga
Common Name (eg, fully qualified host name) []:openam.example.local
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
```

* CRT(Certificate、サーバー証明書)を作成します。
自己証明書のため、自身の秘密鍵で署名します。
下記の例では、有効期限を10年(3650日)に設定しています。

```
$ openssl x509 -in server.csr -days 3650 -req -signkey server.key > server.crt
Signature ok
subject=/C=JP/ST=Tokyo/L=Chuo-ku/O=Hoge company/OU=Fuga/CN=openam.example.local
Getting Private key
Enter pass phrase for server.key: <秘密鍵作成時に指定したパスフレーズを入力>
```

* PKCS#12形式のキーストアを作成します。
上記で作成された証明書(PEMフォーマット)と秘密鍵(PEMフォーマット)を一つのファイルにまとめます。
その際、PKCS#12(Public Key Cryptography Standard#12)という形式になります。

```
$ openssl pkcs12 -export -in server.crt -inkey server.key -out server.p12 -name tomcat
Enter pass phrase for server.key: <秘密鍵作成時に指定したパスフレーズを入力>
Enter Export Password: <パスフレーズを入力>
Verifying - Enter Export Password: <パスフレーズを入力>
```

* JKS形式のキーストアを作成します。
ここでは、「-deststorepass」、「-destkeypass」で同じパスフレーズを指定してください。

```
$ keytool -importkeystore -deststorepass <作成するキーストアのパスフレーズ> -destkeypass <作成するキーのパスフレーズ> -destkeystore server.keystore -srckeystore server.p12 -srcstoretype PKCS12 -srcstorepass ＜PKCS#12形式のキーストア作成時に指定したパスフレーズ> -alias tomcat
キーストアserver.p12をserver.keystoreにインポートしています...

Warning:
JKSキーストアは独自の形式を使用しています。"keytool -importkeystore -srckeystore server.keystore -destkeystore server.keystore -deststoretype pkcs12"を使用する業界標準の形式であるPKCS12に移行することをお薦めします。
```


OpenAMのDockerイメージを、openamという名前で起動します。

* --add-hostは、起動するイメージの「/etc/hosts」にエントリーを追加します。ここでは、openam.example.local - 127.0.0.1を追加しています。
* -eオプションは環境変数を設定するオプションです。環境変数KEYSTORE_PASSを指定しています。
* -vオプションはホストOS(この場合、MAC OS)のディレクトリとコンテナ内のディレクトリをマッピングします。
* -pオプションはホストOSのポート番号と、コンテナ内のポート番号をマッピングします(ホストOSの8443/TCPにアクセスすると、コンテナの8443/TCPにマッピングします)
* -dオプションはコンテナをバックグラウンドで起動します。

```
$ docker run --add-host=openam.example.local:127.0.0.1 -e KEYSTORE_PASS=<キーストアのパスフレーズ> -v $PWD/config:/root -v $PWD/server.keystore:/opt/server.keystore -v /dev/urandom:/dev/random --name openam -p 8443:8443 -d openam
```

ホストOS(MAC OS)の/etc/hostsにopenam.example.localのエントリーを記述します。

```
$ sudo vi /private/etc/hosts
127.0.0.1 openam.example.local
```

ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスし、OpenAMの画面が表示されることを確認します。

デバッグなどで起動したコンテナにログインしたい場合は、以下のコマンドでコンテナにAttachします(/bin/bashを起動します)。

```
$ docker exec -it openam /bin/bash
```

## OpenAMの初期構成

ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスします。

Default configuration / Custom configurationを選択する画面が表示されるので、Custom configurationを選択し、以下のように構成を行います。

* Step1: General
    * Default User Password
        * Default User [amAdmin]: <adminユーザーのパスワードを指定>

* Step2: Server Settings
    * Server Settings
        * Server URL: https://openam.example.local:8443
	    * Cookie Domain: .example.local
	    * Platform Locale: en_US
	    * Configuration Directory: /root/openam

* Step 3: Configuration Data Store Settings
    * First Instanceを選択
    * Configuration Store Details
        * Configuration Data Store: OpenAM
	    * SSL/TLS Enabled:
	    * Host Name: localhost
	    * Port: 50389
	    * Admin Port: 4444
	    * JMX Port: 1689
	    * Encryption Key: <デフォルトのものをそのまま利用>
	    * Root Suffix: dc=openam,dc=forgerock,dc=org

* Step 4: User Data Store Settings:
    * OpenAM User Data Storeを選択

* Step 5: Site Configuration
    * Will this instance be deployed behind a load balancer as part of a site configuration?: No

* Step 6: Default Policy Agent User
    * Policy Agent Password
        * Default Policy Agent [UrlAccessAgent]: <policy agentのパスワードを指定します。今回の構成では使用しません。>

最後に「Create Configuration」をクリックします。

「Configuration Complete!」と表示されたら、完了です。


## アプリケーション(Node.js)の作成

Expressフレームワークを使用して、SAML動作確認用アプリケーションを作成します。

まずは、express-generatorをインストールします。
(-gは、グローバル環境にモジュールをインストール。
express-generatorを使用することで、Expressを使用した雛形プログラムを生成できます。)

```
$ npm install express-generator -g
```

express-generatorを使用して、saml-sampleという名前のnode.jsアプリケーションを作成します(テンプレートエンジンはejsを指定しています)。

```
$ express saml-sample --view=ejs --git

$ cd saml-sample
```

npmモジュールをインストールします。

```
$ npm install
```

今回作成するプログラムで追加で使用するnpmモジュールをインストールします。
(--saveオプションを付与することで、package.jsonに該当npm情報が追記されます)

```
$ npm install dotenv --save
$ npm install passport --save
$ npm install passport-saml --save
$ npm install express-session --save
```

上記でインストールしたnpmモジュールの概要は以下の通りです。

* dotenv: .envファイルに環境設定情報を保存し・利用するためのモジュール
* passport、passport-saml: 認証・認可用モジュール
* express-session: expressでセッションを扱うためのモジュール


[Stack OverflowのQA](https://stackoverflow.com/questions/31259832/configure-nodejs-as-sp-with-passport-saml-and-openam-as-idp) を参考に実装したコード例です。
(express-generatorが生成したコードに、今回追加したコードをコメントで追記しています)

```
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

// 今回使用するモジュールを追加でインポート
var dotenv = require('dotenv');
var passport = require('passport');
var saml = require('passport-saml');
var session = require('express-session');

var index = require('./routes/index');
var users = require('./routes/users');

var app = express();

// .envをロード
dotenv.load();

// SAML認証で取得したユーザー情報をシリアライズしてセッションに埋め込み(req.userでアクセス可能)
// Passportに関しては、http://knimon-software.github.io/www.passportjs.org/guide/configure/を参照
passport.serializeUser(function(user, done) {
  done(null, user);
});

//　リクエストに含まれるユーザー情報をデシリアライズ
passport.deserializeUser(function(user, done) {
  done(null, user);
});

// passport-samlの設定
// process.env.xxxはdotenvでロードした情報(後述する.envに記載されている情報)
// callbackUrl: IdPでのSSOログインが行われた場合に、コールバックされるURL
// entryPoint: SSOログイン時にアクセスするIdPのURL
// issuer: IdPに提供される本アプリケーション(SP)の識別子
// logoutUr: SSOログアウト時にアクセスするIdPのURL
var samlStrategy = new saml.Strategy({
  callbackUrl: process.env.CALLBACK_URL,
  entryPoint: process.env.ENTRY_POINT,
  issuer: process.env.ISSUER,
  logoutUrl: process.env.LOGOUT_URL
}, function(profile, done) {
  var user = profile;
  return done(null, user);
});

// Expressでセッションを使用するための設定
app.use(session({
    secret: '123456', // 適切なものに変更してください
    resave: false,
    saveUninitialized: false,
    cookie: {
      maxAge: 30 * 60 * 1000
    }
}));

// passportを使用するよう構成
passport.use(samlStrategy);
app.use(passport.initialize());
app.use(passport.session());

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', index);
app.use('/users', users);

// 認証が通れば、/にリダイレクト
// 認証NGの場合は、認証失敗詳細(failureFlash: true)も含めて/にリダイレクト
app.get('/login', passport.authenticate('saml', {
  failureRedirect: '/',
  failureFlash: true
}), function(req, res) {
  res.redirect('/');
});

// IdPでのSSOログイン後、コールバックされるURL
app.post('/login/callback', passport.authenticate('saml', {
  failureRedirect: '/',
  failureFlash: true
}), function(req, res) {
  res.redirect('/');
});

// ログアウト処理
app.get('/logout', function(req, res) {
  // samlStrategy.logut uses req.user
  console.log(req.user);

  // IdPでのSSOログアウトを実施
  samlStrategy.logout(req, function(err, request) {
    console.log(err);
    console.log(request);
    if (!err) {
      res.redirect(request);
    }
  });
});

// IdPでのSSOログアウト後、コールバックされるURL
// アプリケーション(passport)のログアウト処理を実施
app.get('/logout/callback', function(req, res) {
  req.logout();
  res.redirect('/');
});

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;
```

.envファイルの内容は以下の通りです。
アプリケーションは、ローカルホスト(www.example2.local というFQDN)上で稼働させます。

```
CALLBACK_URL=http://www.example2.local:3000/login/callback
ENTRY_POINT=https://openam.example.local:8443/openam/SSORedirect/metaAlias/idp
ISSUER=http://www.example2.local:3000/
LOGOUT_URL=https://openam.example.local:8443/openam/IDPSloRedirect/metaAlias/idp
```

ログイン状態かログアウト状態かを判別できるようにするために、
ログイン時に / にアクセスした場合は、Already logged inと表示されるよう変更します。

```
$ cat routes/index.js 
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  if (req.user) {
    res.render('index', { title: 'Express(Already logged in)' });
  } else {
    res.render('index', { title: 'Express' });
  }
});

module.exports = router;
```

最後にホストOS(MAC OS)の /etc/hosts に www.example2.local のエントリーを記述します。

```
$ sudo vi /private/etc/hosts
127.0.0.1 openam.example.local
127.0.0.1 www.example2.local
```

## SPメタデータの準備

SP(アプリケーション)に関するメタ情報(識別子、コールバックURLなど)を記述した以下のXMLファイルを作成します。

AssertionCosumerServiceはログイン時のコールバック処理、SingleLogoutServiceはログアウト時のコールバック処理を規定しています。

```
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="http://www.example2.local:3000/">
  <SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">

    <SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"  Location="http://www.example2.local:3000/logout/callback"/>

    <AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://www.example2.local:3000/login/callback" index="0"/>
    <AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:browser-post" Location="http://www.example2.local:3000/login/callback" index="1"/>
    <AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="http://www.example2.local:3000/login/callback" index="2"/>
    <AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:artifact-01" Location="http://www.example2.local:3000/login/callback" index="3"/>
    <AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser" Location="http://www.example2.local:3000/login/callback" index="4"/>
   </SPSSODescriptor>
  <ContactPerson contactType="technical">
    <GivenName>Administrator</GivenName>
    <EmailAddress>noreply@example.org</EmailAddress>
  </ContactPerson>
</EntityDescriptor>
```

## OpenAMの構成

OpenAMをIdPとして構成し、上述したSPの情報を登録します。

ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスし、以下の情報を入力、ログインします。

* User Name: amAdmin 
* Password: インストール時に指定したパスワード

以下の順番にクリックしていきます。

「Top Level Realm」-「Create SAMLv2 Provider」-「Create Hosted Identity Provider」の順にクリックします。

以下の通り構成し、画面右上の「Configure」をクリックします。

* Do you have metadata for this provider?: No
* metadata
    * Name: https://openam.example.local:8443/openam
    * Signing Key: test
* Circle of Trust
    * New Circle of Trust: node(任意の文字列でOK)

「Your Identity Provider has been configured」と表示されるので、「register a service provider」をクリックします。

「Create a SAMLv2 Remote Service Provider」というページが表示されるため、以下の通り入力し、画面右上の「Configure」をクリックします。

* Where does the metadata file reside?: File
* URL where metadata is located: <上で作成したSPメタデータ>

「Service provider is configured.
You can modify the provider's profile under the Federation tab.」と表示されたら、OKをクリックします。

トップページから、画面上の「FEDERATION」を選択し、上記で指定したSPが登録されていることを確認します。

ログインテスト用のユーザーを作成するため、「Realms」をクリックし、トップページに戻ります。
「Top Level Realm」-「Subjects」の順にクリックします。

「New」をクリックし、ユーザーを作成します。

passport-samlでは、デフォルトのidentifierFormat(各ユーザーの識別子)にemailアドレスを使用します。
そのため、ユーザー情報作成時に忘れずにメールアドレスも記述する必要があります
(identifierFormatを変更した場合はその限りではありません)。

```
identifierFormat: if truthy, name identifier format to request from identity provider (default: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress)
```

本ユーザーを用いて、接続テストを実施します。

## 接続テスト

### SP initiated SSO

アプリケーションを起動します。

```
$ npm start
```

ホストOS上でブラウザを起動し、http://www.example2.local:3000/ にアクセスします。

「Express Welcome to Express」と表示されるのを確認します。

http://www.example2.local:3000/login にアクセスします。

IdPにリダイレクトされ、OpenAMのログイン画面が表示されるため、
テスト用ユーザーのIDおよびパスワードを入力、ログインします。

ログイン後、http://www.example2.local:3000/ にリダイレクトされ、トップページが表示されます。
「Express(Already logged in) Welcome to Express(Already logged in)」と表示されるのを確認します。

http://www.example2.local:3000/logout にアクセスします。
ログアウト後、http://www.example2.local:3000/ にリダイレクトされ、トップページが表示されます。
「Express Welcome to Express」と表示されるのを確認します。

### IP initiated SSO

アプリケーションを起動します。

```
$ npm start
```

ホストOS上でブラウザを起動し、`https://openam.example.local:8443/openam/idpssoinit?metaAlias=/idp&spEntityID=http://www.example2.local:3000/&binding=urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST` にアクセスします。

OpenAMのログイン画面が表示されるため、
テスト用ユーザーのIDおよびパスワードを入力、ログインします。

ログイン後、http://www.example2.local:3000/ にリダイレクトされ、トップページが表示されます。
「Express(Already logged in) Welcome to Express(Already logged in)」と表示されるのを確認します。

以下の通り、ログイン成功時のprofile情報をコンソール出力するように変更・確認してみると、
SP initiatedの場合、passport-samlのデフォルトidentifierFormatであるメールアドレス形式でnameIDがアサインされていますが、IdP initiatedの場合、そうでないことが分かります。

```
var samlStrategy = new saml.Strategy({
  callbackUrl: process.env.CALLBACK_URL,
  entryPoint: process.env.ENTRY_POINT,
  issuer: process.env.ISSUER,
  logoutUrl: process.env.LOGOUT_URL
}, function(profile, done) {
  console.log(profile); 

  var user = profile;
  return done(null, user);
});
```

IdPの場合、nameIDFormatは`urn:oasis:names:tc:SAML:2.0:nameid-format:persistent`になっていることが確認できます。

```
{ issuer: 'https://openam.example.local:8443/openam',
  sessionIndex: 's2f447daa0a7d8ad7c900703d13232df3bffdd1501',
  nameID: '8xkuCmTpy+MpSY7hMPPFz3eKTgCW',
  nameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
  nameQualifier: 'https://openam.example.local:8443/openam',
  spNameQualifier: 'http://www.example2.local:3000/',
  getAssertionXml: [Function] }
```


## うまくいかない場合は

http://www.example2.local:3000/login にアクセスした際に、HTTP 500などのエラーが発生する場合は、OpenAMのログを確認します。

以下の手順でOpenAMのデバッグログレベルを変更の上、事象を再現させ、ログを確認します。

デバッグログレベルの変更方法は以下の通りです。

ホストOS上でブラウザを起動し、https://openam.example.local:8443/openam/ にアクセスし、以下の情報を入力、ログインします。

「CONFIGURATION」-「Servers and Sites」-「Servers」-「https://openam.example.local:8443/openam」 の順にクリックします。

「Debugging」の「Debug Level」を「Error」から「Message」に変更し、「Save」をクリックします。


以下のコマンドでコンテナにAttachし、ログファイルを確認します。

```
$ docker exec -it openam /bin/bash
root@9442b727af19:/usr/local/tomcat# ls /root/openam/openam/debug/
Authentication
Configuration
CoreSystem
EmbeddedDJ
Entitlement
Federation
IdRepo
Session
org.forgerock.caf.authentication.framework.AuthenticationFramework
restAuthenticationFilter

root@9442b727af19:/usr/local/tomcat# cat Federation
...
libSAML2:11/29/2017 04:17:33:484 AM UTC: Thread[http-nio-8443-exec-4,5,main]: TransactionId[23f1c454-c09b-4f58-8315-d5bed890dfbe-1373]
WARNING: UtilProxySAMLAuthenticator.authenticate:  Issuer in Request is not valid.
<.envで指定したIssuerとSPメタデータに含まれるentityIDがミスマッチしている場合の例>
```