AWS Cognitoの画面遷移しないサインインページを作る

久しぶりの、Cognitoネタです。
Cognitoのサインインは、OpenID Connectに準拠しており、サインインは認証エンドポイントにGETを呼び出して画面遷移する必要があります。SPAにおいては画面がリロードされることを意味し、保持していたセッション情報が破棄されることにつながります。

そこで今回は、OpenID Connectでのログインとしつつ、画面リロードを伴わないサインインを実現します。

# 大まかな流れ

元のページのほかに、ログイン専用のページを作成し、元のページから、ログイン専用ページをロードします。
ログイン専用ページは、別タブや別ウィンドウやポップアップとして開き、ログイン専用ページでログインした結果を元のページに戻します。

```
(元のページ)→(ログイン専用ページ)(別ウィンドウ)
　　　　　　認証エンドポイント呼び出し        →（認証プロバイダのサインイン画面）
　　　　　　　　　　　　　　　　　　　      　サインイン
　　　　　　(ログイン専用ページ)(画面リロード)← 認可コードを返却
(元のページ)←認可コードを返却(別ウィンドウをクローズ)
トークンエンドポイントを呼び出して、トークンを取得
```

後で説明しますが、認可コードを元のページに戻すとき、元のページがログイン専用ページと同じオリジンの場合と、異なるオリジンの場合で多少異なります。

ちなみに、Cognitoのもろもろについては以下を参考にしてください。
　[AWS CognitoにGoogleとYahooとLINEアカウントを連携させる](https://qiita.com/poruruba/items/189945dc64edfe1f2464 )

作成したソースコードは以下のGitHubに挙げておきました。
　https://github.com/poruruba/openid_server 
　※\public\proxy のところです。

# Cognitoでアプリクライアントを作成

まずは、AWS Cognitoから、アプリクライアントを作成しておきましょう。
今回はresponse_typeとしてAuthorization code grantを使おうと思いますので、クライアントシークレットを生成 はOnにしておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5af64677-cece-ec5b-15d4-a56842bd577c.png)

次に、作成したアプリクライアントで、Authorization code grantを許可しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/728f8871-ea6a-df1d-a32a-54375ab58884.png)

コールバックURLには、これから作成するログイン専用ページのURLを指定しておきます。
また、許可されているOAuthフローには、Authorization code grantを選択しておきます。
許可されているOAuthスコープには、許可するスコープを指定します。とりあえず、email、openid、profileを選択しておきましょうか。

# 元のページからログイン専用ページをロード

HTML上は特に必要な記載はなく、以下のJavascriptのみを付記しておきます。重要なところだけ抜粋します。

```js:start.js
const REDIRECT_URL = 'ログイン専用ページのUｒL';
const CLIENT_ID = 'アプリクライアントID';

var new_win;

        start_login: function(){
            var params = {
                origin : location.origin, // 同じオリジンの場合はコメントアウト
                state: this.state,
                client_id: CLIENT_ID,
                scope: 'openid profile'
            };
            new_win = open(REDIRECT_URL + to_urlparam(params), null, 'width=400,height=750');
        },
```

REDIRECT_URLが、ログイン専用ページです。
もし、ログイン専用ページと元のページが同じオリジンの場合は、param.originの指定は不要です。
ログイン専用ページのウィンドウサイズは適当に変更してください。ちなみに、ウィンドウサイズはWindowsやMacOSの場合に有効で、AndroidやiOSの場合には、結局は画面いっぱいに表示されます。

# ログイン専用ページ

こちらも、Javascriptのみが重要です。

```js:start_login.js
'use strict';

const REDIRECT_URL = 'このページのURL';
const COGNITO_URL = 'https://[ドメイン名].auth.ap-northeast-1.amazoncognito.com';

//var vConsole = new VConsole();

var encoder = new TextEncoder('utf-8');
var decoder = new TextDecoder('utf-8');

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',
        
    },
    computed: {
    },
    methods: {
    },
    created: function(){
    },
    mounted: function(){
        proc_load();
        
		if( searchs.code ){
			var state = JSON.parse(hex2str(searchs.state));
			console.log(state);
			var message = {
				code : searchs.code,
				state: state.state
			};
			if( state.origin ){
                            window.opener.postMessage(message, state.origin);
			}else{
                            window.opener.vue.do_token(message);
	                }
                        window.close();
		}else{
			var state = {
				origin: searchs.origin,
				state: searchs.state
			};
			auth_location(searchs.client_id, searchs.scope, str2hex(JSON.stringify(state)));
	    }
    }
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );

function auth_location(client_id, scope, state){
	var params = {
		client_id: client_id,
		redirect_uri: REDIRECT_URL,
		response_type: 'code',
		state: state,
		scope: scope
	};
	window.location = COGNITO_URL + "/login" + to_urlparam(params);
}


function str2hex(str){
    return byteAry2hexStr(encoder.encode(str));
}

function hex2str(hex){
    return decoder.decode(new Uint8Array(hexStr2byteAry(hex)));   
}

function hexStr2byteAry(hexs, sep = '') {
    hexs = hexs.trim(hexs);
    if( sep == '' ){
        var array = [];
        for( var i = 0 ; i < hexs.length / 2 ; i++)
            array[i] = parseInt(hexs.substr(i * 2, 2), 16);
        return array;
    }else{
        return hexs.split(sep).map((h) => {
            return parseInt(h, 16);
        });
    }
}

function byteAry2hexStr(bytes, sep = '', pref = '') {
    if( bytes instanceof ArrayBuffer )
        bytes = new Uint8Array(bytes);
    if( bytes instanceof Uint8Array )
        bytes = Array.from(bytes);

    return bytes.map((b) => {
        var s = b.toString(16);
        return pref + (b < 0x10 ? '0'+s : s);
    })
    .join(sep);
}

function to_urlparam(qs){
    var params = new URLSearchParams();
    for( var key in qs )
        params.set(key, qs[key] );
    var param = params.toString();

    if( param == '' )
        return '';
    else
        return '?' + param;
}
```

```js:vue_utils.js
var hashs = {};
var searchs = {};

function proc_load() {
  hashs = parse_url_vars(location.hash);
  searchs = parse_url_vars(location.search);
}

function parse_url_vars(param){
	var searchParams = new URLSearchParams(param);
	var vars = {};
	for (let p of searchParams)
		vars[p[0]] = p[1];
	
	return vars;
}
```

REDIRECT_URLは、自身のログイン専用ページのURLです。
COGNITO_URLは、Cognitoのエンドポイントです。

こんな感じにCognito User Poolに割り当てられているかと思います。
　https://＜ドメイン名＞.auth.ap-northeast-1.amazoncognito.com

大事なのは、

    mounted: function(){

のところです。Vueを使っています。

最初にログイン専用ページがロードされたときには、まだ認可コードがないため、auth_location()を呼び出す分岐に入ります。

stateという変数を作っていますが、これは、Cognitoのエンドポイント呼び出し後に自身がリロードされ、元のページから取得していた情報を忘れてしまうため、Cognitoのエンドポイント呼び出し時のstateパラメータに埋め込んでしまっています。要は手抜きです。

auth_location() は、まさにCognitoのログインエンドポイントの呼び出しです。
呼び出しパラメータ等については、以下が参考になります。

　[AWS Cognitoのエンドポイントを使いこなす](https://qiita.com/poruruba/items/a284b9f522d8d6e1e3f9 )


mountedは、Vueの規定で、画面ロード後に自動的に呼び出されます。
したがって、すぐに、Cognitoのログイン画面が表示されたかと思います。

例えばこんな感じです。実際には、Cognito User Poolに組み込んでいる認証プロバイダの種類によって見え方が異なります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f6c995d2-5264-fdd7-1b72-da6b65d88f19.png)

ログインが完了すると、REDIRECT_URLで指定したURL、すなわち、再度自身の画面がロードされます。
そのときに、認可コードが渡ってきていますので、今度は別の分岐に入ります。

その中で以下の分岐があります。

　if( state.origin ){

これは、ログイン専用ページと元のページが同じオリジンの処理とするか、異なるオリジンの処理とするかの分岐になります。
同じオリジンの場合には、元のページでparam.originをコメントアウトしていたかと思います。

■同じオリジンの場合

元のページのJavascript関数do_token()を直接呼び出しています。

■異なるオリジンの場合

JavascriptのPostMessage機能を使っています。

(参考情報) Window.postMessage
　https://developer.mozilla.org/ja/docs/Web/API/Window/postMessage 

いずれも、認可コードと元のページから取得したstateパラメータを戻しています。
上記処理後、自身のログイン専用ページをcloseしています。


#元のページに戻る

今一度元のページを見てみましょう。
同じオリジンの場合と異なるオリジンの場合で、ログイン専用ページからの戻りを取得する処理が異なります。

■同じオリジンの場合

do_tokenがログイン専用ページから直接呼び出されています。

```js:start.js

const CLIENT_SECRET = 'アプリクライアントシークレット';
const COGNITO_URL = 'https://[ドメイン名].auth.ap-northeast-1.amazoncognito.com';

        do_token: function(message){
        	if( this.state != message.state ){
        		alert('state is mismatch');
        		return;
        	}
            var params = {
                grant_type: 'authorization_code',
                client_id: CLIENT_ID,
                redirect_uri: REDIRECT_URL,
                code: message.code
            };
            var url = COGNITO_URL + '/oauth2/token';
            return do_post_basic(url, params, CLIENT_ID, CLIENT_SECRET)
            .then(json =>{
                console.log(json);
                this.token = json;
            });
        }

function do_post_basic(url, params, client_id, client_secret){
    var data = new URLSearchParams();
    for( var name in params )
        data.append(name, params[name]);

    var basic = 'Basic ' + btoa(client_id + ':' + client_secret);
    const headers = new Headers( { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization' : basic } );
    
    return fetch(url, {
        method : 'POST',
        body : data,
        headers: headers
    })
    .then((response) => {
        if( !response.ok )
            throw 'status is not 200';
        return response.json();
    })
}
```

渡されたstateが、ログイン専用ページ呼び出し時の値と同じであることを確認します。
そして、同じく渡された認可コードを使って、Cognitoのトークンエンドポイントを呼び出して、トークンを取得します。

※念のためですが、アプリクライアントシークレットをブラウザ上で処理してしまっています。漏洩しますので、本番ではサーバ側で秘匿に処理してください。

■異なるオリジンの場合

postMessageで送信された情報は、以下のところで取得されます。

```js:start.js
window.addEventListener("message", (event) =>{
    console.log(event);
    if( event.origin != location.origin ){
    	alert('origin mismatch');
    	return;
    }
    vue.do_token(event.data);
}, false);
```

まずは、自身のオリジンと同じかどうかを確認します。param.originで指定したものとなっているはずです。
そして、同じオリジンの場合と同様、do_token()を呼び出します。

こんな感じで、認証およびトークン取得が成功し、画面に表示されるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/49a4534a-6fd7-fd86-5f63-c396456d0d99.png)

以上