ruby-saml を使ったOneLogin での SAML 認証の処理シーケンスを追う

OneLoginが提供している<a href="https://github.com/onelogin/ruby-saml" target="_blank" rel="nofollow noopener">ruby-saml</a>を使ったSAML認証について調査する機会があったので、備忘も兼ねて動きをまとめようと思います。

## 目的

* SAML 認証時の処理シーケンスの流れを、ruby-samlのサンプルアプリを動かしながら追いつつ理解すること

## 想定読者

* SAML認証を触り始めて間もなく、概念をざっと確認した程度の人
* 基礎的なRailsアプリを開発したことがある人

## 前提

今回はOneLoginを使ったSAML認証を確認します。

またOneLoginが提供している<a href="https://github.com/onelogin/ruby-saml" target="_blank" rel="nofollow noopener">ruby-saml</a>を使って、開発しているアプリにSAML認証の処理の流れを確認します。

今回は、こちらもOneLoginが提供している<a href="https://github.com/onelogin/ruby-saml-example" target="_blank" rel="nofollow noopener">ruby-saml-example</a>というSAML認証確認用のサンプルアプリを使って検証していきます。

## SAML認証に関する用語のおさらい

簡単に用語を最低限確認しておきます。

| 用語 | 意味 
|:-- |:-- 
| Security Assertion Markup Language(SAML) | 異なるドメイン間で認証・認可データをやり取りするための標準策定されたXMLベースのマークアップ言語 
| Identity Provider(IdP) |認証を行い、認証情報を提供する 
| Service Provider (SP) | IdPに認証を委託し、IdPの認証情報を信頼してサービス提供する
| Assertion | IdPから発行される認証情報。SAML形式で発行される
| Assertion Consumer Service (ACS) | SPでIdPから発行されたAssertionを解釈し、認可を行う

## SAML認証の処理シーケンスの概要図

こちらのSAML認証の処理シーケンスの概要図を前提に、 ruby-saml-example の動きを追っていきます。
図のクラウドサービスは、 ruby-saml-example と読み替えてください。

![](http://www.cybernet.co.jp/onelogin/images/function/saml/fig01.png)

画像引用元：<a href="http://www.cybernet.co.jp/onelogin/function/saml.html" target="_blank" rel="nofollow noopener">SAMLとは ｜クラウド型シングルサインオン・アクセスコントロール（IDaaS） OneLogin - サイバネット</a>

今回の検証では、それぞれ下記が対応します。

* SP(Service Provider): <a href="https://github.com/onelogin/ruby-saml-example" target="_blank" rel="nofollow noopener">ruby-saml-example</a>
* ユーザー： 自分自身
* IdP(ID Provider): <a href="https://support.onelogin.com/hc/en-us/articles/202673944-Use-the-OneLogin-SAML-Test-Connector" target="_blank" rel="nofollow noopener">OneLogin SAML Test Connector</a>

## 検証の準備

下記の記事がわかりやすいので、こちらを参考に検証の準備をします。ここでは詳細は割愛しますが、準備のアウトラインだけ書いておきます。

<a href="https://qiita.com/katsuhiko/items/1960f96661cdf6daf63b" target="_blank" rel="nofollow noopener">SSO を実現するための SAML2.0 の実装。まずはサンプルを動かす</a>

### IdPの準備

* トライアル用の OneLogin アカウントを取得する
* IdPとしてOneLoginに<a href="https://support.onelogin.com/hc/en-us/articles/202673944-Use-the-OneLogin-SAML-Test-Connector" target="_blank" rel="nofollow noopener">OneLogin SAML Test Connector</a>を作成する
* OneLogin SAML Test ConnectorのConfigulationで下記の通り設定する

| 変更箇所 | 値の取得場所
|:-- |:-- |
| Audience | http://localhost:3000/saml/metadata |
| Recipient | http://localhost:3000/saml/acs |
| ACS URL Validator | ^http:¥/¥/localhost:3000¥/saml¥/acs$ |
| ACS URL | http://localhost:3000/saml/acs |

### SPの準備

* <a href="https://github.com/onelogin/ruby-saml-example" target="_blank" rel="nofollow noopener">ruby-saml-example</a>をクローンし、```http://localhost:3000``` でアプリが起動できるようにしておく
* OneLogin SAML Test Connector のApp IdとIdPの証明書をアプリに設定する

## 処理の流れ

それではシーケンスに沿って ruby-saml-example アプリ内でどんな処理をしているか追っていきます。

### ① ユーザーがSPにアクセス

Webブラウザから```http://localhost:3000```とアクセスすると、「Login」 とシンプルに表示されたページが表示されます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/222030/77205fc8-bf8e-2e33-5389-5bd7b64d7e9f.png)


### ② SPはユーザーのリクエストを確認し、SAML認証要求を作成 / ③ SAML認証のリクエストとともにIdPにリダイレクト

「Login」をクリックすると、設定した OneLoginの認証画面にリダイレクトされます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/222030/07a0fb62-d0d9-bce6-1822-6548e7a739d0.png)

図の②、③を担っているのは、<a href="https://github.com/onelogin/ruby-saml-example/blob/master/app/controllers/saml_controller.rb#L8-L13" target="_blank" rel="nofollow noopener">SamlController#sso</a>アクションです。ざっくり何をやっているか確認するとこんな感じです。

* ```settings = Account.get_saml_settings(get_url_base)```でOneLoginとの連携に関する各種設定を行う
* ```request = OneLogin::RubySaml::Authrequest.new``` でOneLoginへの認証リクエストを作成する
* ```redirect_to(request.create(settings))``` で、```settings``` の情報を基にOneLoginの認証画面へリダイレクトする

### ④ OneLoginでSAML認証リクエストを解析・認証 / ⑤ OneLoginの認証画面でユーザー認証

SPからのSAML認証リクエストに応答する形で、OneLogin の認証画面が表示されます。
OneLogin の認証画面で ID/PASS を入力し、ログインします。ログイン処理は OneLogin 側で行います。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/222030/6d5792d8-0274-61b4-2edf-c3032e0d9021.png)


### ⑥ OneLoginがSAML認証レスポンスを作成 / ⑦ SAML認証レスポンスとともにSPにリダイレクト

OneLoginでのログインが成功すると、OneLoginからSPの```ACS``` のURLにリダイレクトされます。

* SAML認証のレスポンスは ```Parameters: {"SAMLResponse"=>"XXXXXXXXXXXXXXXXXXXX..."}``` のように暗号化された状態でリダイレクト先に送信されます。
* リダイレクト先は、OneLogin SAML Test Connector で指定したACS URLです。今回の場合は```http://localhost:3000/saml/acs```です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/222030/073b31d7-4bde-0154-5d06-2921761f7b2c.png)

### ⑧ SPがSAML認証レスポンスを検証し、ユーザーログインを許可

ACSでSAML認証レスポンスを解析・認可の判断をします。

図の⑧を担っているのは、<a href="https://github.com/onelogin/ruby-saml-example/blob/master/app/controllers/saml_controller.rb#L20-L36" target="_blank" rel="nofollow noopener">SamlController#acs</a>アクションです。コードを抜粋します。

``` ruby
  def acs
    settings = Account.get_saml_settings(get_url_base)
    response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], :settings => settings)


    if response.is_valid?
      session[:nameid] = response.nameid
      session[:attributes] = response.attributes
      @attrs = session[:attributes]
      logger.info "Sucessfully logged"
      logger.info "NAMEID: #{response.nameid}"
      render :action => :index
    else
      logger.info "Response Invalid. Errors: #{response.errors}"
      @errors = response.errors
      render :action => :fail
    end
  end
```

```response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], :settings => settings)``` でSAML認証のレスポンスを解析してます。
解析されたレスポンスは、この行の下に```logger.debug  response.document
```と書いて、ログに書き出したりすることで、SAML形式の認証情報を確認できます。

参考：<a href="https://www.samltool.com/generic_sso_res.php" target="_blank" rel="nofollow noopener">SAML Response Examples - SAML Assertion Example | SAMLTool.com</a>

```
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Destination="http://localhost:3000/saml/acs" ID="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" InResponseTo="_XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" IssueInstant="2018-06-02T04:48:03Z" Version="2.0">
   <saml:Issuer>https://app.onelogin.com/saml/metadata/xxxxxx</saml:Issuer>
   <samlp:Status>
      <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
   </samlp:Status>
   <saml:Assertion xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ID="XXXXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" IssueInstant="2018-06-02T04:48:03Z" Version="2.0">
      <saml:Issuer>https://app.onelogin.com/saml/metadata/xxxxxx</saml:Issuer>
      <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
         <ds:SignedInfo>
            <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
            <ds:Reference URI="#XXXXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX">
               <ds:Transforms>
                  <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                  <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
               </ds:Transforms>
               <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
               <ds:DigestValue>XXXXXXXXXXXXXXXXXXXXXXXXXXX</ds:DigestValue>
            </ds:Reference>
         </ds:SignedInfo>
         <ds:SignatureValue>XXX</ds:SignatureValue>
         <ds:KeyInfo>
            <ds:X509Data>
               <ds:X509Certificate>XXX</ds:X509Certificate>
            </ds:X509Data>
         </ds:KeyInfo>
      </ds:Signature>
      <saml:Subject>
         <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">example@example.com</saml:NameID>
         <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
            <saml:SubjectConfirmationData InResponseTo="_XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" NotOnOrAfter="2018-06-02T04:51:03Z" Recipient="http://localhost:3000/saml/acs" />
         </saml:SubjectConfirmation>
      </saml:Subject>
      <saml:Conditions NotBefore="2018-06-02T04:45:03Z" NotOnOrAfter="2018-06-02T04:51:03Z">
         <saml:AudienceRestriction>
            <saml:Audience>http://localhost:3000/saml/metadata</saml:Audience>
         </saml:AudienceRestriction>
      </saml:Conditions>
      <saml:AuthnStatement AuthnInstant="2018-06-02T04:48:02Z" SessionIndex="XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" SessionNotOnOrAfter="2018-06-03T04:48:03Z">
         <saml:AuthnContext>
            <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
         </saml:AuthnContext>
      </saml:AuthnStatement>
   </saml:Assertion>
</samlp:Response>
```

この認証情報を```response.is_valid?``` でバリデーションチェックし、問題なければユーザーにログインを許可します。

### ⑨ ユーザーがSPにログインできる

ACSのチェックでログインが許可されれば、ログイン完了後の画面に無事遷移します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/222030/9f6f014e-7bde-6c05-230c-90aecc7737d1.png)

## 検証でハマった点

これでログインするまでの一通りの流れは追えたのですが、検証中ハマった点をまとめておきます。

### ```response.is_valid?``` のバリデーションエラーでハマった

検証の最初の方で、OneLogin SAML Test Connectorの ```Audience``` と ```Recipient``` を空欄のままにしていたのですが、そうすると```http://localhost:3000/saml/acs``` でやっている```response.is_valid?``` でバリデーションエラーが出ました。

#### ```Audience```を空にした場合のバリデーションエラー

![image.png](https://qiita-image-store.s3.amazonaws.com/0/222030/8457907e-fb8e-74ef-1385-eaf8fd8423fb.png)

```
http://localhost:3000/saml/metadata is not a valid audience for this Response - Valid audiences: {audience}
```

#### ```Recipient``` を空にした場合のバリデーションエラー

![image.png](https://qiita-image-store.s3.amazonaws.com/0/222030/c7f850e9-3830-780f-f249-26858ab656cb.png)

```
The response was received at {recipient} instead of http://localhost:3000/saml/acs
```

#### バリデーションエラーの原因

ruby-saml の ```is_valid?``` で実施しているバリデーションでコケてしまっているのが原因でした。

https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/response.rb#L346-L383

##### ```Audience```が空の場合

バリデーション```validate_audience```で、```Audience```が ```settings.issuer``` と合ってないぞ！と怒られている

https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/response.rb#L586-L601

空の場合はSAML認証レスポンスの ```Audience``` に ```{audience}```という値が入ってくる

```
# 設定した場合
<saml:Audience>http://localhost:3000/saml/metadata</saml:Audience>

# 空の場合
<saml:Audience>{audience}</saml:Audience>
```

##### ```Recipient```が空の場合

バリデーション```validate_destination```で、```Destination```が ```settings.assertion_consumer_service_url```と合ってないぞ！と怒られている

https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/response.rb#L603-L625

空の場合はSAML認証レスポンスの ```Destination``` に ```{recipient}```という値が入ってくる

```
# 設定した場合
<samlp:Response Destination='http://localhost:3000/saml/acs'  ...>

# 空の場合
<samlp:Response Destination='{recipient}' ...>
```

#### 対応方法

上記バリデーションをパスするには、下記のような設定をする必要がありました。

* ```Audience``` の設定値は、 ```saml_settings``` の ```settings.issuer``` と一致させる（今回は```http://localhost:3000/saml/metadata```）
* ```Recipient``` の設定値は、 SP の ACS URL と一致させる（今回は```http://localhost:3000/saml/acs```）

#### なぜこのバリデーションが必要なのか考察

<a href="https://support.onelogin.com/hc/en-us/articles/202673944-Use-the-OneLogin-SAML-Test-Connector" target="_blank" rel="nofollow noopener">OneLogin SAML Test Connectorのドキュメント</a>だと、```Audience``` と ```Recipient```は Not Requiredとなっています。なのにそれらを空欄にすることで、ruby-saml でバリデーションが失敗するのには疑問がありました。

何故なのかあれこれ理由を考えてみたのですが、ruby-samlの```is_valid?``` では、 **レスポンスの返答先の信頼性を必ず確認したい** からなのかな、と個人的には考えています。

そもそも OneLogin　における　```Audience``` と ```Recipient``` は何なのでしょうか。<a href="https://support.onelogin.com/hc/en-us/articles/202673944-Use-the-OneLogin-SAML-Test-Connector" target="_blank" rel="nofollow noopener">OneLogin SAML Test Connectorのドキュメント</a>から引用して確認してみます。

> The Recipient will tell you exactly who the SAML response is for, but the Audience will tell you, at a broader level, where the response should go. So for example, the Recipient could be Yankee Stadium, while the Audience could be New York City.
>
> （意訳）Recipient は SAMLレスポンスの返答先を正確に示しますが、　Audience は広範なレベルで、レスポンスがどこに向かうべきかを示します。 例えば、Recipientはヤンキースタジアム、Audience はニューヨーク市と捉えることが出来ます。

つまり、```Audience``` と ```Recipient```はSAMLレスポンスの返答先を定めるような役割があるということがわかります。

こちらを踏まえ、```SamlController#acs```のアクションで ```response.is_valid?``` をつけない場合を考えてみます。この場合、OneLogin SAML Test Connector の Configuration で、```ACS URL```のみ指定してさえすれば、OneLoginのログイン成功後、 ```ACS URL```にリダイレクトされ、そのままログインが出来てしまいます。

しかしその場合、アプリのACS URLに飛んでくるOneLoginからのSAML認証レスポンスが、どこをレスポンス返答先として送られたものなのか、という検証がアプリ側でできなくなります。もしかしたら別のアプリに向けたレスポンスが飛んできたとしても、アプリ側でそれを検知する術がなくなってしまうということになります。

ですので、 ```Audience``` と ```Recipient``` を指定することで、正しいSPへのレスポンスであるかどうかも検証することで、セキュリティレベルを担保しているのかなと思いました。

ちなみに```Audience``` には SPエンティティIDとも呼ばれる、SPのmetadata を返すURLを指定するようです。

今回の場合```http://localhost:3000/saml/metadata``` ですが、こちらにアクセスすると下記のような XML ファイルが表示されます。

```entityID``` や、```Location``` などが明示されていることがわかります。

```
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" ID="_XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" entityID="http://localhost:3000/saml/metadata">
   <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" AuthnRequestsSigned="false" WantAssertionsSigned="false">
      <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3000/saml/logout" ResponseLocation="http://localhost:3000/saml/logout" />
      <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
      <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3000/saml/acs" isDefault="true" index="0" />
   </md:SPSSODescriptor>
</md:EntityDescriptor>
```

参考：https://github.com/onelogin/ruby-saml/issues/325

## 終わりに

長くなりましたが以上です。OneLogin の SAML 認証は奥が深いですね。。

## 参考

* <a href="https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language" target="_blank" rel="nofollow noopener">Security Assertion Markup Language - Wikipedia</a>
* <a href="https://en.wikipedia.org/wiki/Identity_provider" target="_blank" rel="nofollow noopener">Identity provider - Wikipedia</a>
* <a href="https://qiita.com/katsuhiko/items/1960f96661cdf6daf63b" target="_blank" rel="nofollow noopener">SSO を実現するための SAML2.0 の実装。まずはサンプルを動かす</a>