SAML2.0でのシングルサインオン実装と戦うあなたに(.NET編)

この記事は[Sansan Advent Calendar 2018](https://adventar.org/calendars/3423)の7日の記事になります。

日本全国に役に立つ人が100名いるか、いないか解らないぐらいニッチなネタですが、ある日突然SAMLと戦う必要が出た人に捧げます。
というか自分がやって半年後に覚えてるか自信が無いので備忘録に残します。

自分が .NET での実装をしたので後半に .NET で実装してハマった部分など書いておきますが、前半はSAMLの説明なので他言語でも役に立つかもしれません。

## そもそも SAML って何よ?

Security Assertion Markup Language の略です。
これを見てもさっぱりですが、 XML関連の標準化団体OASIS（Organization for the Advancement of Structured Information Standards）がシングルサインオンやID連携に利用するために定めた XML によるフレームワークです。
2002年に策定され、2005年にバージョン2.0となっており以降の更新は特にないので2018/12現在ではこの2.0が主流です。

## SSO で出てくる用語
- Idp(IdentityProvider)
	- IDを管理して認証情報を他サービスに渡す。
	- よく知られた製品として MS の AD(ActiveDirectory) の情報をやりとりする ADFS(ActiveDirectoryFederationService)など。
- Sp(ServiceProvider)
	- Idp から認証情報を受け取って認証を行う。
	- B2B の SaaS はこちら側。 まれに Idp も兼ねる SaaS もある。(Salesforceなど)

## SAMLの構成要素

- 以下はSAMLの主要な構成要素の図です。([technical overview](https://www.oasis-open.org/committees/download.php/27819/sstc-saml-tech-overview-2.0-cd-02.pdf)より)
![20181207-193612.jpg](https://qiita-image-store.s3.amazonaws.com/0/11493/3f69236a-c04a-ded6-a122-c7b8b1f9c016.jpeg)

- Assertions
	- 認証情報、認可情報やその他の属性情報などセキュリティコンテキストにかかわる情報をXMLで表現したもの。
- Protocols
	- Assertion をやりとりするための request/response の仕様。
- Bindings
	- ProtcolをどうやってHttpやSOAPなどの通信プロトコルに埋め込むかの方法を規定したもの。
- Profiles
	- 一般的なシナリオで使われる Bindings, Protocols, Assertion の組み合わせ方を規定したもの。
- Authentication Context
    - Sp が Idp に認証のタイプや認証の強度などの詳細情報が必要な場合に使うもの。Assertion 内に含まれたり参照されたりする。
- Metadata
    - Idp と Sp などの登場人物間で設定を共有するために使うもの。

## Http Redirect/Post binding による Sp-Initiated SingleSignOn

- 構成要素も眺めて何となく用語が解った気になれたかもしれないので実際にSSOのシナリオをどうやって実現するかを記述していきたいかと思います。
- 今回は Sp-Initiated つまり Sp 側から Idp に要求する形で Http Redirect/Post binding によるSSOを取り上げたいと思います。
- ブラウザを経由するため直接 Idp と Sp との通信が必要ないため、よく SaaS ではこの方法でSSOしてるかと思います。
- なお Technical Overview に記載のあるものを訳してます。

![20181207-193651.jpg](https://qiita-image-store.s3.amazonaws.com/0/11493/f70bd70c-cb1c-c279-cf2b-4560bd9c6f61.jpeg)

- 1.　SPにユーザがアクセスします。ここではユーザはログインされた状態ではないものとします。
- 2.　SPはブラウザに Redirect の http status(302/303) を返します。
 Httpヘッダには Sign-On Service の destination URI と query string として _SAMLRequest_ という名前のencodeされたXMLを含みます。

```xml
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="identifier_1" Version="2.0" IssueInstant="2004-12-05T09:21:59Z" AssertionConsumerServiceIndex="1">
　　<saml:Issuer>https://sp.example.com/SAML2</saml:Issuer>
　　<samlp:NameIDPolicy AllowCreate="true" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient"/>
</samlp:AuthnRequest>
```
query string は _DEFLATE_ で encode します。ブラウザは redirect レスポンスを処理し、IdP の Single Sign-On Service に _SAMLRequest_ のquery string と共に HTTP GET リクエストを発行します。
local state情報(例えばログイン後に参照したいページのUrlなどの事)も _RelayState_ として次のように query string に含んでHTTPレスポンスにエンコードします。
`https://idp.example.org/SAML2/SSO/Redirect?SAMLRequest=request&RelayState=token`

- 3.　Single Sign-On Service はユーザが(既定の/AuthenRequestで要求された)認証ポリシーの要件を満たすログインコンテキストをIdpにすでに持ってるかを判断します。
もしなければ、ユーザに正しいクレデンシャル(ID/Passwordなど)を提供するようにブラウザを通して試みます。

- 4.　ユーザは正しいクレデンシャルを入力し、Idp でそのユーザに対する local logon security context が作成されます。
- 5.　IdP の Single Sign-On サービスはユーザの logon security context を表す SAML Assertion を作成します。
POST binding 利用時は、 Assertion はデジタル署名されて SAML _Response_ メッセージ内に置かれます。 _Response_ メッセージは HTML Form の hidden項目に _SAMLResponse_ として配置されます。
もし Idp が _RelayState_ を SP から受けた場合、 _RelayState_ という名前の hidden 項目に値を変更せずに返す必要があります。
Single Sign-On Service は HTTPレスポンス内に HTML form を含んでブラウザに返します。通常この HTML form には自動的に destination に送信するようなスクリプトが付随しています。

```:html
<form method="post" action="https://sp.example.com/SAML2/SSO/POST" ...>
  <input type="hidden" name="SAMLResponse" value="response" />
  <input type="hidden" name="RelayState" value="token" />
  ...
  <input type="submit" value="Submit" />
</form>
```

_SAMLResponse_ はbase64でエンコーディングされており、以下のような値を持ちます。

```
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="identifier_2" InResponseTo="identifier_1" Version="2.0" IssueInstant="2004-12-05T09:22:05Z" Destination="https://sp.example.com/SAML2/SSO/POST">
  <saml:Issuer>https://idp.example.org/SAML2</saml:Issuer>
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>
  <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="identifier_3" Version="2.0" IssueInstant="2004-12-05T09:22:05Z">
    <saml:Issuer>https://idp.example.org/SAML2</saml:Issuer>
    <!-- a POSTed assertion MUST be signed -->
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">...</ds:Signature>
    <saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">3f7b3dcf-1674-4ecd-92c8-1544f346baf8</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData InResponseTo="identifier_1" Recipient="https://sp.example.com/SAML2/SSO/POST" NotOnOrAfter="2004-12-05T09:27:05Z"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2004-12-05T09:17:05Z" NotOnOrAfter="2004-12-05T09:27:05Z">
      <saml:AudienceRestriction>
    	  <saml:Audience>https://sp.example.com/SAML2</saml:Audience>
    	</saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2004-12-05T09:22:00Z" SessionIndex="identifier_3">
    	<saml:AuthnContext>
    	  <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
    	</saml:AuthnContext>
    </saml:AuthnStatement>
  </saml:Assertion>
</samlp:Response>
```

- 6.　ブラウザは(ユーザの動作/自動submitスクリプト)で form を SP の Assertion Consumer Service に送信するHTTPリクエストを発行します。

```
POST /SAML2/SSO/POST HTTP/1.1
Host: sp.example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: nnnn
SAMLResponse=response&RelayState=token
```

_SAMLResponse_ と _RelayState_ の値は 5. の HTMLform から取得されます。SPの Assertion Comsumer Service は _Response_ メッセージをHTML form から取得します。
まずデジタル署名された SAML Assertion の内容を検証して SP の local logon security context を作成する必要があります。
これが完了すると SP は _RelayState_ から local state 情報を読み取り、当初リクエストされた resource の url を呼び出します。

- 7.　ユーザが resource にアクセスする正しい権限があるかチェックを行います。これがパスすれば resource はブラウザに返されます。

## SAML認証の実装を実際にやってみてはまった事
自分はSP側として実装する側だったのですが、いくつかハマった事があるので記載していきます。(ほとんどXML署名についてですが...)
.NETでの実装だったので主に.NETにおけるお話になります。

### ネストした複数のXML署名が Verify できない
SAMLでは[profilesの仕様](https://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf) の `4.1.4.3 <Response> Message Processing Rules` にある以下の記述の通り Assertion または Response にあるXML署名を改ざんされてない事を保証するために Verfiy する必要があります。
> Regardless of the SAML binding used, the service provider MUST do the following: Verify any signatures present on the assertion(s) or the response.

なのでXML署名をVerifyする必要があるのですが、SAMLは [Core の仕様]の `2.3.3 Element <Assertion>` にある `<ds:Signature> [Optional]`
 という記載と `3.2.1 Complex Type RequestAbstractType` にある `<ds:Signature> [Optional]` の通り Assertion 側と Response 側どちらでの Signature の存在を仕様上は認めています。
そのためIdP(またはその設定)によっては両方にXML署名が存在する場合があります。

.NET で Verify する方法でメジャーな手法としては[MSの記事](https://docs.microsoft.com/en-us/dotnet/standard/security/how-to-sign-xml-documents-with-digital-signatures)にある通り SinedXml を使う事だと思うのですが、この SignedXml は2018/12現在 [この Issue](https://github.com/dotnet/corefx/issues/32526) にある通り複数のネストした Signature を扱うと Verify できないバグがあります。(.NET Core の Issue だけど .NET Framework でも同じ問題を抱えてる)

なぜこうなってしまうかというとまずXML署名の仕組みを知る必要があります。XML署名の検証は次の2ステップを踏みます。

1. Signature の値を事前にIdpから渡された公開鍵を使って複合し、同梱されている Digest の値と同じ値になる事を検証する
2. XML署名の対象となっているXMLのnodeに対し指定のアルゴリズムで正規化、およびハッシュ化して Digest を作成し、その Digest の値と同梱している Digest の値が同一である事を検証する

SAML は以下の仕様上 enveloped つまり XML署名のSignatureを同梱しておく必要があります。
> Unless a profile specifies an alternative signature mechanism, any XML Digital Signatures MUST be enveloped.

この場合は 2 を行う正規化の際にSignatureを取り除く必要があります。
でないとSignatureがある状態の XML Node に対してハッシュ化する事になり元の Digest がずれる。これは Xml署名のTransformする仕様として [Enveloped Signature Transform](Enveloped Signature Transform) に定義されてます。
ネストしている場合は外側のSignatureのみ取り除く必要があり内部のものはそのまま残しておく必要があります。
しかし SignedXml はネストしてしまうと、その取り除くSignatureを管理する値がおかしくなってしまう事があるようです。

これに対しては workaround な対応として Issue を挙げている Anders Abel 氏の[自作SAMLライブラリでの実装](https://github.com/Sustainsys/Saml2/blob/master/Sustainsys.Saml2/XmlHelpers.cs#L368)が非常に参考になりました。

### Whitespace を保持したままのXML署名が Verify できない

.NET では Xml を扱う場合に XmlDocument を利用する事が多いと思います。
この XmlDocument は初期値では [PreserveWhitespace](https://docs.microsoft.com/en-US/dotnet/api/system.xml.xmldocument.preservewhitespace?view=netframework-4.7.2) が false なため、xml に含まれる whitespace を全部取り除いてしまい、やはり Digest がずれてしまいます。
これは単に XmlDocuement を利用する際に PreserveWhitespace を単に true にするだけで解決します。
が、場合によっては PreserveWhitespace が false の場合のみ署名が Verify されるものもあったので、true で失敗した場合は false で再度検証するとよいかと思います。


## 所感

SAMLは日本語での資料があまりなく、英語の仕様書と睨めっこする事になるかと思います。また仕様書も複数のPDFを行ったり来たりして解釈する事になります。
順番としては TechnicalOverview をざっと眺めてどの Profile か選択してから Profile, binding, core と見ていくといいかと思います。

どこかで「SAMLは誰かの1秒のために3日間デバッグする仕事」と見ましたが、まさしくその通りだと思います。しかしその誰かが1000人ぐらいいて毎日使ってくれるのだったら、その3日間は無駄にならないと思います。

最後に .NET界のSAML masterこと Anders Abel 氏の issue に何度となく助けられました!本当に感謝の言葉しかないです。

それでは皆さんよい SAML Life を！!

## 参考
- https://www.oasis-open.org/committees/download.php/27819/sstc-saml-tech-overview-2.0-cd-02.pdf
- https://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf
- https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
- https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
- https://www.w3.org/TR/xmldsig-core1/