Azure AD との SAML 認証において、シングルサインアウト時に署名の検証に失敗するときの対処

本記事では、Azure AD が発行する SAML のログアウト リクエストについて、署名の検証に失敗する原因の一つとその対処を紹介します。

## シングル サインアウトとは
Azure AD の SAML 認証 では、[シングル サインアウト](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/single-sign-out-saml-protocol)もサポートしています。

例えば、あるアプリに SSO した状態でアクセス パネル(https://myapps.microsoft.com/) からサインアウトすると、該当アプリにもログアウト リクエストが送られます。具体的には、以下のような HTTP リダイレクト バインディングのリクエストとなります。

```
GET (Azure ポータルで設定したログアウト URL)?SAMLRequest=lZLNauMwFIVfxWgv68eSIwnHtJCNIU1hWrqY...&Signature=oiqkocdvYWfVeYuiESA7e1BvkAvWPDtQIfUOtuVj10xBHR+owC...&SigAlg=http%3a%2f%2fwww.w3.org%2f2001%2f04%2fxmldsig-more%23rsa-sha256
```

アプリは、上記リクエストを検証して、アプリ側にあるユーザーのサインイン セッションを終了させることができます。

## 失敗する例
この Azure AD のシングル サインアウトですが、SAML 用のライブラリをそのまま使っていると署名の検証に失敗する可能性があります。

例えば、Python には pysaml2 というライブラリがあります。以下は pysaml2 を使ったコードですが、署名の検証を行なう `verify_redirect_signature()` は検証に失敗し `False` を返します。

```python
import saml2.sigver

mycrypto = saml2.sigver.RSACrypto(None)

# Azure AD が発行したログアウト リクエストのクエリパラメーター
saml_msg = {'SAMLRequest': 'lZLNauMwFIVfxWgv68eSIwnHtJCNIU1hWrqY...',
            'Signature': 'oiqkocdvYWfVeYuiESA7e1BvkAvWPDtQIfUOtuVj10xBHR+owC...',
            'SigAlg': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'}
# SAML 署名証明書
cert = 'MIIC8DCCAdigAwIBAgIQX9iOst4ZVLZMGCNMhpIESzANBgkqhkiG9w0BAQsFADA0MT...'

# 署名の検証
result = saml2.sigver.verify_redirect_signature(saml_msg, mycrypto, cert)  #=> False 😥
```

その結果、ログアウト リクエストが Azure AD から発行されたということを確認できず、シングル サインアウトもできないということになります。

## 原因
上述のように署名の検証に失敗するのは、実際のログアウト リクエストの文字列と、`saml_msg` から作り直した文字列が異なってしまうからであり、それは以下の違いによるものです。

* Azure AD が発行するログアウト リクエストは小文字の 16 進数でパーセント エンコードされている
* [verify_redirect_signature()](https://github.com/IdentityPython/pysaml2/blob/master/src/saml2/sigver.py#L553) の実装では標準ライブラリの `urllib.parse.urlencode()` を使っており大文字の 16 進数でパーセント エンコードされている

パーセント エンコードについては大文字でも小文字でも間違いではないですが、[RFC3986](https://triple-underscore.github.io/rfc-others/RFC3986-ja.html#section-2.1) では、大文字で統一した方が良いという記述もあり、Azure AD 側の動作がイケてない感じはあります。。

> 大文字の 16 進数字 'A' から 'F' は、小文字の 'a' から 'f' とそれぞれ等価である。 二つの URI のパーセントエンコードされたオクテット内で使用される 16 進数字の大文字・小文字のみが異なる場合、それらは等価である。 整合性を持たせるため、URI の生成を行うもの{producers} や正規化を行うもの{normalizers} は全てのパーセントエンコーディングについて大文字の 16 進数字を使用すべきである。


## 対処
一度パーセント デコードされた値を使って署名を検証する際には、改めてパーセント エンコードするときに実際のログアウト リクエストが使っていた形式に合わせる必要があります。Azure AD に限って言えば、とりあえず小文字が使われると考えて良いと思います。

以下のように `verify_redirect_signature()` を修正し、大文字でエンコードした部分を小文字に置き換えることで、署名の検証に成功することを確認しました。

```python
import re
import base64
from urllib import parse
from saml2.sigver import SIGNER_ALGS, REQ_ORDER, RESP_ORDER, Unsupported, RSACrypto, extract_rsa_key_from_x509_cert, pem_format


def verify_redirect_signature_with_lowercase(saml_msg, crypto, cert=None, sigkey=None):
    try:
        signer = crypto.get_signer(saml_msg['SigAlg'], sigkey)
    except KeyError:
        raise Unsupported(
            'Signature algorithm: {alg}'.format(alg=saml_msg['SigAlg']))
    else:
        if saml_msg['SigAlg'] in SIGNER_ALGS:
            if 'SAMLRequest' in saml_msg:
                _order = REQ_ORDER
            elif 'SAMLResponse' in saml_msg:
                _order = RESP_ORDER
            else:
                raise Unsupported(
                    'Verifying signature on something that should not be '
                    'signed')
            _args = saml_msg.copy()
            del _args['Signature']  # everything but the signature
            string = '&'.join(
                [parse.urlencode({k: _args[k]}) for k in _order if k in _args])
            string = lower(string).encode('ascii')

            if cert:
                _key = extract_rsa_key_from_x509_cert(pem_format(cert))
            else:
                _key = sigkey

            _sign = base64.b64decode(saml_msg['Signature'])

            return bool(signer.verify(string, _sign, _key))


def lower(upper_encoded_string):
    """ 大文字のパーセント エンコードを小文字に置き換える
    """
    lower_encoded_string = upper_encoded_string

    for match in re.finditer('%', upper_encoded_string):
        idx = match.start()
        encoded_part = upper_encoded_string[idx:idx+3]
        lower_encoded_string = lower_encoded_string.replace(
            encoded_part, encoded_part.lower())

    return lower_encoded_string



if __name__ == '__main__':
    mycrypto = RSACrypto(None)

    # Azure AD が発行したログアウト リクエストのクエリパラメーター
    saml_msg = {'SAMLRequest': 'lZLNauMwFIVfxWgv68eSIwnHtJCNIU1hWrqY...',
            'Signature': 'oiqkocdvYWfVeYuiESA7e1BvkAvWPDtQIfUOtuVj10xBHR+owC...',
            'SigAlg': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'}
    # SAML 署名証明書
    cert = 'MIIC8DCCAdigAwIBAgIQX9iOst4ZVLZMGCNMhpIESzANBgkqhkiG9w0BAQsFADA0MT...'

    result = verify_redirect_signature_with_lowercase(saml_msg, mycrypto, cert)  #=> True 😁
```

## おわりに
頼りのライブラリが動かないと困るし、原因を調べるのはなかなか大変です。今回は、パーセント エンコードの大文字と小文字の違いでうまく動かないケースでした。結構気づきにくい部分だと思うので、同じ事象で困っている人の参考になれば嬉しいです。

## 参考 URL

* [シングル サインアウトの SAML プロトコル](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/single-sign-out-saml-protocol)
* [pysaml2](https://github.com/IdentityPython/pysaml2)
* [RFC3986](https://triple-underscore.github.io/rfc-others/RFC3986-ja.html)