Amazon Echo(Alexa) にログイン機能を付ける - Authorization Code Grant編

前回は "Implicit Grant" でのアカウントリンクをお伝えしました。

xp-cloud.jp

今回は "Authorization Code Grant" でのアカウントリンクの方法をお伝えします。

スキルを使うユーザーとしては"Authorization Code Grant"も"Implicit Grant"も同じ感覚で使えるとは思いますが 開発者としてはだいぶん違うので、その辺りを注意して見ていきましょう。

全体的なフローは次のようになります。

Amazon Echo(Alexa) にログイン機能を付ける フロー

 

自分で用意する物は次の3つです。
・Alexaスキル
Echoから呼出すカスタムスキル

・ログイン画面
ユーザーID、パスワード等を入力し認証するWEBアプリケーションで、 最終的にコードという文字列を作成する必要があります。
コードとはトークンとほぼ同じ意味で、コードがkeyでトークンがvalueのペアになった物だと思えばよいです。
Alexaはコードを覚えるだけなので、トークンに有効期限を設けて、有効期限切れになってもコードから最新のトークンを引きにいきます。

トークン発行Rest API
AmazonサービスからPOSTで叩かれるAPIです。
コードが送られてくるのでトークンを発行して返してやれば良いです。
POSTで飛んでくるデータは"application/x-www-form-urlencoded"形式ですが 返すのは"json"形式なところが注意です。

スキルの処理をAWS Lambda、ログイン画面をAWS S3、トークン発行APIAPI Gatewayに作った場合のスキル設定画面は次のようになります。

Amazon Echo(Alexa) スキルの処理

 

"プライバシーポリシーURL"はひとまず何でもいいので入力しておけば良いです。

"Amazon Alexa"アプリで実際に動かすと次のようになります

 

"認証URL"のサイトがURLパラメータ付きで開きます

Amazon Alexa

 

URLパラメータに"redirect_url"があるので、認証が終わったらそこへ遷移します。
URLは次の書式になっています。
URLパラメータのredirect_url + "?state=" + URLパラメータのstate + "&code=" + コード

 

画面には出てきませんが、この後、API Gatewayで作成したトークン発行APIがPOSTで呼ばれます。
トークン発行APIJSON形式で"access_token"を返した場合のみ、正常にアカウントリンクが終了します。

正常にアカウントリンクが終了

 

後は"Implicit Grant"の時と同じでスキルにトークンが渡ってくるので処理してやれば大丈夫です。

参考のためS3に置いたログイン画面のソース、API Gatewayの内容、トークン発行Rest APIのソースを載せておきます。

 

S3のログイン画面"AuthCodeGrant1.html"

<!doctype html>
<html>
<head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>AuthCodeGrant1</title>
    <script>
    var codedb = {
        "user1":{"pass1":"code1"},
        "user2":{"pass2":"code2"},
        "user3":{"pass3":"code3"}
    };
    window.onload = function() {
        var urlParams = getUrlParams()

        document.getElementsByTagName('form')[0].onsubmit = function() {
            var userId = document.getElementById('userId').value;
            var password = document.getElementById('password').value;

            var pass = codedb[userId];
            if (!pass) {
                alert("UserIdが違うよ");
                return false;
            }

            var code = pass[password];
            if (!code) {
                alert("Passwordが違うよ");
                return false;
            }

            docCookies.setItem('userId', userId);
            docCookies.setItem('password', password);

            window.location.href = decodeURIComponent(urlParams.redirect_uri) +
                '?state=' + urlParams.state +
                '&code=' + code;
            return false;
        };

        document.getElementById('userId').value = docCookies.getItem('userId');
        document.getElementById('password').value = docCookies.getItem('password');
    }

    function getUrlParams() {
        var arg = new Object;
        var pair=location.search.substring(1).split('&');
        for(var i=0;pair[i];i++) {
            var kv = pair[i].split('=');
            arg[kv[0]]=kv[1];
        }
        return arg;
    }

    var docCookies = {
        getItem: function (sKey) {
            if (!sKey || !this.hasItem(sKey)) { return null; }
            return unescape(document.cookie.replace(new RegExp("(?:^|.*;\\s*)" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*((?:[^;](?!;))*[^;]?).*"), "$1"));
        },
        setItem: function (sKey, sValue, vEnd, sPath, sDomain, bSecure) {
            if (!sKey || /^(?:expires|max\-age|path|domain|secure)$/i.test(sKey)) { return; }
            var sExpires = "";
            if (vEnd) {
                switch (vEnd.constructor) {
                    case Number:
                        sExpires = vEnd === Infinity ? "; expires=Tue, 19 Jan 2038 03:14:07 GMT" : "; max-age=" + vEnd;
                        break;
                    case String:
                        sExpires = "; expires=" + vEnd;
                        break;
                    case Date:
                        sExpires = "; expires=" + vEnd.toGMTString();
                        break;
                }
            }
            document.cookie = escape(sKey) + "=" + escape(sValue) + sExpires + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "") + (bSecure ? "; secure" : "");
        },
        removeItem: function (sKey, sPath) {
            if (!sKey || !this.hasItem(sKey)) { return; }
            document.cookie = escape(sKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT" + (sPath ? "; path=" + sPath : "");
        },
        hasItem: function (sKey) {
            return (new RegExp("(?:^|;\\s*)" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=")).test(document.cookie);
        },
        keys: /* optional method: you can safely remove it! */ function () {
            var aKeys = document.cookie.replace(/((?:^|\s*;)[^\=]+)(?=;|$)|^\s*|\s*(?:\=[^;]*)?(?:\1|$)/g, "").split(/\s*(?:\=[^;]*)?;\s*/);
            for (var nIdx = 0; nIdx < aKeys.length; nIdx++) { aKeys[nIdx] = unescape(aKeys[nIdx]); }
            return aKeys;
        }
    };
</script>
</head>
<body style="background-color:#ccc">
    <div style="width:300px">
    <form>
        <p>userId</p>
        <input type="text" id="userId" style="width:100%"/>
        <p>password</p>
        <input type="password" id="password" style="width:100%"/>
        <button>OK</button>
    </form>
    </div>
</body>
</html>

 

API Gatewayの設定

API Gatewayの設定

 

"統合リクエスト"以外は特に変更なしです。
ポイントは"Content-Type"に"application/x-www-form-urlencoded"を指定しているところです。
これが無いとHTTP500番代のエラーになってLambdaすら起動しません。

テンプレートの内容

{
 "headers" : {
#foreach( $key in $input.params().header.keySet() )
 "$key" : "$input.params().header.get($key)"#if( $foreach.hasNext ),#end
#end
 },
 "queryParameters" : {
#set( $tmpstr = $input.body )
#foreach( $keyandvaluestr in $tmpstr.split( '&amp;' ) )
#set( $keyandvaluearray = $keyandvaluestr.split( '=' ) )
 "$keyandvaluearray[0]" : "$keyandvaluearray[1]"#if( $foreach.hasNext ),#end
#end
 },
 "stage" : "$context.stage",
 "sourceIp" : "$context.identity.sourceIp",
 "userAgent" : "$context.identity.userAgent"
}

 

トークン発行Rest API

const tokendb = {
    "code1":"token1",
    "code2":"token2",
    "code3":"token3"
};

exports.handler = (event, context, callback) =&gt; {
    console.log('AuthCodeGrant2 called');
    var token = tokendb[event.queryParameters.code];
    if (!token) {
        callback("codeが不正です")
        return;
    }
    callback(null,{
        "access_token":token,
        "token_type":"bearer",
        "expires_in":3600,
        "refresh_token":token
    });
};

 

 

 

AWS相談会