SAML IdPと連携したCognitoの動作確認用途でサーバーレスアプリケーションを作る

2021.03.17

以前作ったADFS+Cognitoの動作確認用途としてサーバーレスアプリケーションを作ってみました。

構成

今回用意する構成は以下の通りです。

サーバーレスアプリケーションはAWSのチュートリアルを参考に手順「RESTful API のデプロイ」まで進めていきます。(作成方法は割愛します)

サーバーレスのウェブアプリケーションを構築

ADFSの設定

まずは、CognitoユーザープールとADFSを連携していきます。EC2インスタンスにアクセスしてADFS 証明書利用者の信頼およびクレームを追加します。次にユーザーをログアウトさせる為のログアウトエンドポイントを設定します。作成した証明書利用者信頼をダブルクリックしてプロパティを開きます。

エンドポイントタブで「SAMLの追加」をクリックします。

エンドポイントの種類をSAMLログアウト、バインディングをPOST、信頼されたURLにCognitoのログアウトエンドポイントを入力して「OK」をクリックします。SAMLIdPのログアウトエンドポイントはURLが異なるので注意してください。

ログアウトエンドポイント

ログアウトエンドポイントのクエリパラメータとしてclient_idとlogout_uriが必須です。

https://Cognitoドメイン.auth.リージョン.amazoncognito.com/logout?client_id=アプリクライアントID&logout_uri=アプリクライアントのサインアウトURL

ステップ 4.SAML ID プロバイダーを使用したサインインをユーザープールに追加する (オプション)

https://Cognitoドメイン.auth.リージョン.amazoncognito.com/saml2/logout ←今回はこちら

SAMLログアウトエンドポイントが追加されていることを確認して「OK」をクリックします。

ログアウトを処理するCognitoが提供するログアウトリクエスト署名用証明書を設定する必要があるのでSAML IdP設定後に追加で設定します。

メタデータの取得とSAML IdP設定

メタデータURL(https://ADFSのFQDN/federationmetadata/2007-06/federationmetadata.xml)にアクセスし、メタデータを取得します。

CognitoユーザープールでIDプロバイダからSAMLを選択し、メタデータファイルのアップロードし、任意のプロバイダ名を入力ます。IdPサインアウトフローを有効化をチェックすると署名付きのログアウトリクエストを SAML IdP に送信してログアウトできるようになります。プロバイダを作成します。

プロバイダを追加したらデジタル署名証明書の文字列をコピーします。

EC2インスタンスでCER形式(例:adfs.cer)で保存します。AD FSの管理画面から証明書利用者信頼をダブルクリックし、署名タブから「追加」をクリックして保存した証明書を追加します。

ダブルクリックすると発行元、発行者がユーザープールIDとなっていればOKです。

操作をAWSに戻ります。ユーザープールの属性emalとSAML属性(http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress)をマッピングします。

アプリクライアントの設定で作成したSAML IdPを有効なIDプロバイダとしてチェックします。コールバックURLはSAML認証後にアクセスするURLを入力します。チュートリアルのサーバーアプリケーションでは、ride.htmlが認証後のURIである為、https://amplifyID.amplifyapp.com/ride.htmlを入力します。サインアウトURLは、https://amplifyID.amplifyapp.com/ride.htmlを入力します。ログアウトするとサインイン画面が表示されます。OAuth2.0では、Implicit grantemailopenid、をチェックします。

以上でADFSとCognitoユーザープールの設定は完了です。

アプリケーションの改修

認可エンドポイントの追加

作成したチュートリアルのアプリケーションにはSAML IdPによるサインインができません。なのでSAML IdPでサインインするリダイレクトURLのリンクにアクセスできるようにします。かなり雑ですがsignin.htmlの47行目に追加でtbn要素でリンクを埋め込みました。identity_provider=SAMLIDプロバイダ名をクエリパラメータに追加するとHostedUIのリダイレクトを省略できます。

<a class="btn btn-primary" href="https://Cognitoドメイン.auth.リージョン.amazoncognito.com/oauth2/authorize?response_type=token&identity_provider=ADFS&client_id=アプリクライアントID&redirect_uri=アプリクライアントのサインアウトURL&scope=email+openid" role="button">SAML</a>

ログアウトエンドポイントの追加

ride.htmlの31行目にサインアウト時のリンクを設定できるので修正します。

<li><a id="signOut" href="https://Cognitoドメイン.auth.リージョン.amazoncognito.com/logout?client_id=アプリクライアントID&logout_uri=アプリクライアントのサインアウトURL">Sign out</a></li>

ride.js、config.jsの変更

SAML IdPでアプリケーションを利用できるようにride.js、cognito.jsを修正していきます。

js/config.js


    window._config = {
    cognito: {
        userPoolId: 'ユーザープールID',
        userPoolClientId: 'アプリクライアントID',
        region: 'リージョン',
        authDomainPrefix: 'Cognitoドメイン',
        redirect_uri:'アプリクライアントに設定したサインインURL',
        samlname:'Cognito SAML IDプロバイダ名'
    },
    api: {
        invokeUrl: 'APIエンドポイントURL'
    }
};

js/ride.js


/*global WildRydes _config*/

var WildRydes = window.WildRydes || {};
WildRydes.map = WildRydes.map || {};

(function rideScopeWrapper($) {
    var authToken = getParameterByName("id_token");

    var cognitoError = getParameterByName("error");
    if (cognitoError != null && cognitoError != "") {
        displayUpdate(getParameterByName("error_description"));
    } else {
        if (authToken == null || authToken == "") {
            redirectToLogin();
        } else {
            $("#refresh").prop("disabled", false);
        }
    }

    function refresh() {
        $.ajax({
            method: 'POST',
            url: _config.api.invokeUrl + '/ride',
            headers: {
                Authorization: authToken
            },
            data: JSON.stringify({
                PickupLocation: {
                    Latitude: pickupLocation.latitude,
                    Longitude: pickupLocation.longitude
                }
            }),
            contentType: 'application/json',
            success: completeRequest,
            error: function ajaxError(jqXHR, textStatus, errorThrown) {
                console.error('Error requesting ride: ', textStatus, ', Details: ', errorThrown);
                console.error('Response: ', jqXHR.responseText);
                alert('An error occured when requesting your unicorn:\n' + jqXHR.responseText);
            }
        });
    }

    function getParameterByName(name, url) {
        if (!url) url = window.location.href;
        console.log("url: " + window.location.href);
        name = name.replace(/[\[\]]/g, "\\$&");
        var regex = new RegExp("[?#&]" + name + "(=([^&#]*)|&|#|$)"),
            results = regex.exec(url);
        console.log("results: " + results);
        if (!results) return null;
        if (!results[2]) return '';
        return decodeURIComponent(results[2].replace(/\+/g, " "));
    }


    function requestUnicorn(pickupLocation) {
        $.ajax({
            method: 'POST',
            url: _config.api.invokeUrl + '/ride',
            headers: {
                Authorization: authToken
            },
            data: JSON.stringify({
                PickupLocation: {
                    Latitude: pickupLocation.latitude,
                    Longitude: pickupLocation.longitude
                }
            }),
            contentType: 'application/json',
            success: completeRequest,
            error: function ajaxError(jqXHR, textStatus, errorThrown) {
                console.error('Error requesting ride: ', textStatus, ', Details: ', errorThrown);
                console.error('Response: ', jqXHR.responseText);
                alert('An error occured when requesting your unicorn:\n' + jqXHR.responseText);
            }
        });
    }

    function completeRequest(result) {
        var unicorn;
        var pronoun;
        console.log('Response received from API: ', result);
        unicorn = result.Unicorn;
        pronoun = unicorn.Gender === 'Male' ? 'his' : 'her';
        displayUpdate(unicorn.Name + ', your ' + unicorn.Color + ' unicorn, is on ' + pronoun + ' way.');
        animateArrival(function animateCallback() {
            displayUpdate(unicorn.Name + ' has arrived. Giddy up!');
            WildRydes.map.unsetLocation();
            $('#request').prop('disabled', 'disabled');
            $('#request').text('Set Pickup');
        });
    }

    function redirectToLogin() {
        if (!window._config.cognito.authDomainPrefix ||
            !window._config.cognito.userPoolClientId ||
            !window._config.cognito.region) {
                $('#noCognitoMessage').show();
                return;
            }

        loginUri = "https://" +
                   window._config.cognito.authDomainPrefix +
                   ".auth." +
                   window._config.cognito.region +
                   ".amazoncognito.com" +
                   "/oauth2/authorize?response_type=token&identity_provider=" + window._config.cognito.samlname +
                   "&client_id=" + window._config.cognito.userPoolClientId +
                   "&redirect_uri=" + window._config.cognito.redirect_uri

        console.log("Redirecting to: " + loginUri);

        window.location = loginUri;
    }

    // Register click handler for #request button
    $(function onDocReady() {
        $('#request').click(handleRequestClick);
        $(WildRydes.map).on('pickupChange', handlePickupChanged);

        authToken.then(function updateAuthMessage(token) {
            if (token) {
                displayUpdate('You are authenticated. Click to see your auth token.');
                $('authToken').text(token);
            }
        });

        if (!_config.api.invokeUrl) {
            $('#noApiMessage').show();
        }
    });

    function handlePickupChanged() {
        var requestButton = $('#request');
        requestButton.text('Request Unicorn');
        requestButton.prop('disabled', false);
    }

    function handleRequestClick(event) {
        var pickupLocation = WildRydes.map.selectedPoint;
        event.preventDefault();
        requestUnicorn(pickupLocation);
    }

    function animateArrival(callback) {
        var dest = WildRydes.map.selectedPoint;
        var origin = {};

        if (dest.latitude > WildRydes.map.center.latitude) {
            origin.latitude = WildRydes.map.extent.minLat;
        } else {
            origin.latitude = WildRydes.map.extent.maxLat;
        }

        if (dest.longitude > WildRydes.map.center.longitude) {
            origin.longitude = WildRydes.map.extent.minLng;
        } else {
            origin.longitude = WildRydes.map.extent.maxLng;
        }

        WildRydes.map.animate(origin, dest, callback);
    }

    function displayUpdate(text) {
        $('#updates').append($('<li>' + text + '</li>'));
    }
}(jQuery));

全ての修正が終わったらサーバーレスアプリケーションのリポジトリにpushします。

動作確認

https://amplifyID.amplifyapp.com/signin.htmlにアクセスしてSAMLをクリックします。

HostedUIが省略されてADFSのサインイン画面が表示されるのでユーザー、パスワードを入力し、サインインします。

サインインできたら成功です。

今度はサインアウトしてみます。

コールバックURLで指定したsignin.htmlが表示されていればサインアウトもできています。SAMLをクリックするとサインインを求められます。

まとめ

アプリ側に実装(と言えるほどの内容ではありませんが。。)してみることで全体の挙動が理解できるようになってきました。認証認可は奥が深いので動作確認しながら深堀りしていきたいと思います。