Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみた

以前、OpenID Connect によるシングルサインオン環境を構築しました。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

OpenID Connect以外でシングルサインオン環境を構築する方法として SAML がありますが、今までさわってきませんでした。

 
そんな中、書籍「SAML入門」を読む機会がありました。

書籍では

  • SAMLの認証フロー
  • 認証フローのリクエスト・レスポンスの中身を掲載
  • Dockerを使って実際にSAML認証を試す
  • SAMLの仕様へのリンクやお役立ちツール

などが分かりやすく記載されており、とてもためになりました。ありがとうございました。

 
本を読んでみて気持ちが盛り上がり、自分でもSPとIdPの環境を構築してみたくなりました。

そこで、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみたので、メモを残します。

 
目次

 

環境

  • WSL2
  • SP
    • Python 3.11.7
    • Flask 3.0.2
    • pysaml2 7.5.0
  • IdP
    • Keycloak 23.0.6

 
なお、ソースコードは必要に応じて記載しているものの、全部は記載していません。

詳細はGithubリポジトリを確認してください。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example

 

やらないこと

あくまで「SAMLの認証フローを体験する」がメインなので、以下は行いません。

  • 本番運用に即した、Keycloakやpysaml2の設定
  • セキュリティまわりを真剣に考えること

 
また、SPとIdPの両方を自作すると完成が遅くなりそうでした。

そのため、SAML入門同様、今回はIdPにKeycloakを使うことにして、IdPの自作は行いません。

 

SP向けのライブラリについて検討

今回はPythonで書いてみようと考え、SAML2関連のライブラリを探したところ、以下の2つがありました。

両方とも同じくらいのstarだったため、どんな違いがあるのか調べたところ、2016年のstackoverflowに情報がありました。
single sign on - Python SSO: pysaml2 and python3-saml - Stack Overflow

python3-saml のauthorのコメントだったものの、python3-saml が良さそうに感じました。

ただ、

ということから、今回は pysaml2 でSPを作ることにしました。

 
ライブラリは決まったものの、 pysaml2 を使ってゼロから作るのは大変そうです。

サンプルコードを探したところ、oktaにてサンプルコードが公開されていました。

そこで、これをベースに作っていくことにしました。

 
なお、上記oktaのサンプルだと Flask-Login を使うことでログインまわりをきちんと作っています。

ただ、今回は必要最低限の実装にするので、ログインまわりについては

  • Flask-Login は使用しない
  • その代わり、SAML認証成功時にセッションへデータをセットする
    • セッションにデータがあればログイン成功とみなす

とします。

 

SAML用のChrome拡張について調査

SAMLのリクエスト・レスポンスをChromeで確認できると便利です。

調べた見たところ、SAML-tracer がありました。
SAML-tracer

この拡張ですが、

ということから、今回使ってみることにしました。

この拡張を使うことで、SAMLのパラメータを見たり、

 
実際のリクエストで使われるSAMLを見れたりと、開発をする上で便利になりました。

 

Keycloakのセットアップ

Keycloakは、公式のDockerイメージが quay.io で提供されています。
https://quay.io/repository/keycloak/keycloak?tab=tags

今回は、最新バージョンの docker compose で Keycloak をたてることにしました。

そこで、以下の compose.yaml を用意しました。

ちなみに、ポート 8080 はよく見かけるため、 18080 へと変更しています。

services:
  keycloak:
    image: quay.io/keycloak/keycloak:23.0.6
    # dockerコマンドのitオプションと同様にするため、 ttyとstdin_openを付けておく
    tty: true
    stdin_open: true
    ports:
        - 18080:8080
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    command:
      - start-dev

 
準備ができたので、起動します。

$ docker compose up -d

 
続いて、公式ドキュメントの手順に従い、Keycloakの設定を行います。
Docker - Keycloak

  • ポートを変更しているので、以下のURLにアクセス
  • realmを作る
    • nameを myrealm とする
    • それ以外はデフォルト
  • ユーザー作る
    • myrealm に切り替える
    • 以下を入力
      • Username: myuser
      • First Name: Foo
      • Last Name: Bar
  • ユーザーにパスワードを設定する
    • Credentials タブを開く
    • 以下を入力
      • Password: baz
      • Password confirmation: baz
      • Temporary: Off
  • 作成したユーザー myuser でKeycloakへログインしてみる
  • Realm settings の Endpoints をクリックし、エンドポイント情報を確認しておく

 

pysaml2を使って、SPを作る

各ライブラリのインストール

今回、WSL2上にSPをたてます。

はじめに、pysaml2のREADMEにある通り、 xmlsec1 をインストールします。

$ sudo apt install xmlsec1

続いて、Flaskとpysaml2をインストールします。

$ pip install pysaml2 flask

 

Flaskアプリの作成

ミニマムな saml_client_for メソッドへと変更

oktaのサンプルコードを一部改変し、ミニマムな実装にします。

まず、今回はHTTP通信だけ使うので、変数 asc_urlSAML Requestのみの

acs_url = url_for(
    'saml_request',
    _external=True)

とします。

変数 settings については、

  • endpoint は以下の2つ分を定義
    • SAML Requestのときの HTTP Redirect Binding
    • SAML Responseのときの HTTP POST Binding
  • 各種 signed は False
  • allow_unsolicitedTrue
    • 未設定だと saml2.response.UnsolicitedResponse: Unsolicited response: id-*** エラーが発生する
      • Keycloak側の設定不足かもしれない
  • metadata には remote を追加
    • これがないと、 saml2.client_base.SignOnError: {'message': 'No supported bindings available for authentication', 'bindings_to_try': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'], 'unsupported_bindings': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']} エラーが発生する

とします。

関数全体は以下の通りです。

def saml_client_for():
    acs_url = url_for(
        'saml_request',
        _external=True)
    
    settings = {
        'entityid': 'flask',
        'metadata': {
            'remote': [
                {'url': 'http://localhost:18080/realms/myrealm/protocol/saml/descriptor'},
            ],
        },
        'service': {
            'sp': {
                'endpoint': {
                    'assertion_consumer_service': [
                        (acs_url, BINDING_HTTP_REDIRECT),
                        (acs_url, BINDING_HTTP_POST),
                    ]
                },
                'allow_unsolicited': True,
                'authn_requests_signed': False,
                'want_assertions_signed': False,
                'want_response_signed': False,

            }
        }
    }

    spConfig = Saml2Config()
    spConfig.load(settings)
    spConfig.allow_unknown_attributes = True
    saml_client = Saml2Client(config=spConfig)
    return saml_client

 

SAML Requestを送信する関数を作成

今回のSAML Request は HTTP Redirect Binding とするため、oktaのサンプルコードとほぼ同じです。

なお、今回は SP-initiated フローでの認証のみ動作確認することから、メソッド名を sp_initiated から saml_request へと変更しています。

def saml_request():
    # SAMLクライアントを生成する
    saml_client = saml_client_for()
    
    # 認証準備をする
    _reqid, info = saml_client.prepare_for_authenticate()

    # HTTP Redirect Binding のリダイレクト先はLocationヘッダに保存されているため、
    # その値を redirect 関数に渡す
    redirect_url = None
    # Select the IdP URL to send the AuthN request to
    for key, value in info['headers']:
        if key == 'Location':
            redirect_url = value
    response = redirect(redirect_url, code=302)
    # NOTE:
    #   I realize I _technically_ don't need to set Cache-Control or Pragma:
    #     http://stackoverflow.com/a/5494469
    #   However, Section 3.2.3.2 of the SAML spec suggests they are set:
    #     http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
    #   We set those headers here as a 'belt and suspenders' approach,
    #   since enterprise environments don't always conform to RFCs
    response.headers['Cache-Control'] = 'no-cache, no-store'
    response.headers['Pragma'] = 'no-cache'
    return response

 

SAML ResponseをPOSTで受け付ける関数を作成

元々のサンプルコードでは idp_initiated 関数でしたが、 IdP-initiated フロー向けと誤解しそうなので、関数名を saml_response へと変更しました。

また、 authn_response.parse_assertion() をしないと、 get_identity()get_subject() で値が取得できなかったことから、修正を加えています。

他に、セッションの中にSAML入門で確認していた各値を設定し、ブラウザ上で表示できるようにしておきます。

なお、セッションの各値についてはpysaml2のドキュメントでは示されていなかったため、デバッガを使って一つ一つどこにあるかを確認しました。

@app.route('/saml/response/keycloak', methods=['POST'])
def saml_response():
    saml_client = saml_client_for()
    authn_response = saml_client.parse_authn_request_response(
        request.form['SAMLResponse'],
        entity.BINDING_HTTP_POST)

    # parse_assertion()してからでないと、get_identity()やget_subject()で値が取れない
    authn_response.parse_assertion()
    user_info = authn_response.get_subject()

    session['saml_attributes'] = {
        'name_id': user_info.text,
        'name_id_format': user_info.format,
        'name_id_name_qualifier': user_info.name_qualifier,
        'name_id_sp_name_qualifier': user_info.sp_name_qualifier,
        'session_index': authn_response.assertion.authn_statement[0].session_index,
        'session_expiration': authn_response.assertion.authn_statement[0].session_not_on_or_after,
        'message_id': authn_response.response.id,
        'message_issue_instant': authn_response.response.issue_instant,
        'assertion_id': authn_response.assertion.id,
        'assertion_not_on_or_after': authn_response.assertion.issue_instant,
        'relay_status': 'NOT_USED',
        'identity': authn_response.get_identity()
    }

    return redirect('/')

 

SAML Requestを送信するためのリンクやセッションの中身を表示するindexを用意

テンプレートを描画するだけです。

@app.route('/')
def index():
    return render_template('index.html')

 
テンプレートはこんな感じで、セッションの値の有無により表示を分岐しています。

{% if session['saml_attributes'] %}
    {% set s = session['saml_attributes'] %}

    <h1>KeyCloak Status</h1>
    <table>
        <thead>
            <tr>
                <th>Attribute</th>
                <th>Value</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>Name ID</td>
                <td>{{ s['name_id'] }}</td>
            </tr>
            <tr>
                <td>Name ID Format</td>
                <td>{{ s['name_id_format'] }}</td>
            </tr>
            <tr>
                <td>Name ID Name Qualifier</td>
                <td>{{ s['name_id_name_qualifier'] }}</td>
            </tr>
            <tr>
                <td>Name ID SP Name Qualifier</td>
                <td>{{ s['name_id_sp_name_qualifier'] }}</td>
            </tr>
            <tr>
                <td>Session Index</td>
                <td>{{ s['session_index'] }}</td>
            </tr>
            <tr>
                <td>Session Expiration</td>
                <td>{{ s['session_expiration'] }}</td>
            </tr>
            <tr>
                <td>Message ID</td>
                <td>{{ s['message_id'] }}</td>
            </tr>
            <tr>
                <td>Message Issue Instant</td>
                <td>{{ s['message_issue_instant'] }}</td>
            </tr>
            <tr>
                <td>Assertion ID</td>
                <td>{{ s['assertion_id'] }}</td>
            </tr>
            <tr>
                <td>Assertion NotOnOrAfter</td>
                <td>{{ s['assertion_not_on_or_after'] }}</td>
            </tr>
            <tr>
                <td>Relay Status</td>
                <td>{{ s['relay_status'] }}</td>
            </tr>
            <tr>
                <td>Identity</td>
                <td>{{ s['identity'] }}</td>
            </tr>
        </tbody>
    </table>

{% else %}
    <h1>Login</h1>
    <ul>
      <li><a href="/saml/login/keycloak">KeyCloak</a></li>
    </ul>
{% endif %}

 
以上で、SP側の実装は完了です。

 

Keycloakへ設定を追加

続いて、公式ドキュメントを参考にしつつ、SPの情報をKeycloakへ設定します。
Creating a SAML client | Server Administration Guide

  • Create Client でクライアントを作成
    • Client Typeは SAML
    • Client IDは任意の値
      • 今回は flask
      • ただし、本番運用の場合は重複しないような値のほうが良さそう
    • Valid Redirect URIsは http://localhost:15000/saml/response/keycloak
    • NameID Formatは username
    • Force POST bindingは On
  • clientから flask を選択
  • Keyタブを選択
    • Client signature requiredOff にする
  • Client scopes タブから、 flask-dedicated を選択
    • デフォルトで作成されている
  • Scopeタブを選択
    • Full scope allowed を Off にする
  • Mappersタブを選択
    • Configure a new mapper をクリック
    • Nameで User Attribute をクリック
      • Name, User Attribute, Friendly Name, SAML Attribute Name のいずれも username
      • SAML Attribute NameFormatは Basic (デフォルト)
      • Aggregate attribute valuesは Off (デフォルト)
  • Advancedタブを選択
    • Assertion Consumer Service POST Binding URL に http://localhost:15000/saml/response/keycloak を設定
      • SAML Response の送信先
      • これがないと、Keycloak上で Invalid Request エラーが表示されてしまう

 

動作確認

ここまでで環境構築が完了したので、実際に動作を確認してみます。

http://localhost:15000/ にアクセスすると、Keycloakでログインするためのリンクが表示されます。

 
リンクをクリックすると、Keycloak上のログイン画面が表示されるので、ログインユーザーとパスワードを入力します。

 
ログインに成功するとSPに戻り、SAML Responseの内容が表示されます。

 
SAML-tracerの状態も確認します。

SAML Request の時はこんな感じでした。

 
2回目のSAML Response の場合はこんな感じです。

 
以上で、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証ができるようになりました。

 

その他の参考資料

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example/pull/1