前回は "Implicit Grant" でのアカウントリンクをお伝えしました。
今回は "Authorization Code Grant" でのアカウントリンクの方法をお伝えします。
スキルを使うユーザーとしては"Authorization Code Grant"も"Implicit Grant"も同じ感覚で使えるとは思いますが 開発者としてはだいぶん違うので、その辺りを注意して見ていきましょう。
全体的なフローは次のようになります。
自分で用意する物は次の3つです。
・Alexaスキル
Echoから呼出すカスタムスキル
・ログイン画面
ユーザーID、パスワード等を入力し認証するWEBアプリケーションで、 最終的にコードという文字列を作成する必要があります。
コードとはトークンとほぼ同じ意味で、コードがkeyでトークンがvalueのペアになった物だと思えばよいです。
Alexaはコードを覚えるだけなので、トークンに有効期限を設けて、有効期限切れになってもコードから最新のトークンを引きにいきます。
・トークン発行Rest API
AmazonサービスからPOSTで叩かれるAPIです。
コードが送られてくるのでトークンを発行して返してやれば良いです。
POSTで飛んでくるデータは"application/x-www-form-urlencoded"形式ですが 返すのは"json"形式なところが注意です。
スキルの処理をAWS Lambda、ログイン画面をAWS S3、トークン発行APIをAPI Gatewayに作った場合のスキル設定画面は次のようになります。
"プライバシーポリシーURL"はひとまず何でもいいので入力しておけば良いです。
"Amazon Alexa"アプリで実際に動かすと次のようになります
"認証URL"のサイトがURLパラメータ付きで開きます
URLパラメータに"redirect_url"があるので、認証が終わったらそこへ遷移します。 URLは次の書式になっています。 URLパラメータのredirect_url + "?state=" + URLパラメータのstate + "&code=" + コード
画面には出てきませんが、この後、API Gatewayで作成したトークン発行APIがPOSTで呼ばれます。
トークン発行APIがJSON形式で"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>
"統合リクエスト"以外は特に変更なしです。 ポイントは"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( '&' ) ) #set( $keyandvaluearray = $keyandvaluestr.split( '=' ) ) "$keyandvaluearray[0]" : "$keyandvaluearray[1]"#if( $foreach.hasNext ),#end #end }, "stage" : "$context.stage", "sourceIp" : "$context.identity.sourceIp", "userAgent" : "$context.identity.userAgent" }
const tokendb = { "code1":"token1", "code2":"token2", "code3":"token3" }; exports.handler = (event, context, callback) => { 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 }); };