Edited at

AWS Cognitoにサインインしないと見れないLambdaを作る

前回は、AWS Cognitoを使って、GoogleアカウントとLINEアカウントからログインできるようにCognitoユーザープールを設定しました。

AWS CognitoにGoogleとLINEアカウントを連携させる

今度は、サインイン状態をAWS API Gateway経由でLambdaに引き継いでみます。

つまりは、サインインしていないと表示できないページを作ります。


とりあえずのLambda関数を作成

とりあえず、Lambdaを作成してから、後で、それを呼び出すようにAPI Gatewayのエンドポイントを作成します。

Lambdaの管理コンソールを開きます。

https://ap-northeast-1.console.aws.amazon.com/lambda/home?region=ap-northeast-1

右上の「関数の作成」ボタンを押下します。

image.png

今回は、ほぼ何も処理をしないシンプルな関数を用意します。

「一から作成」を選択し、適当な名前を入力します。例えば、「test-cognito-lambda」とします。

ランタイムは、Node.js 8.10 としました。

ロールは既存のロールを選択してもよいですし、新規に作成してもよいです。

例えば、ロールとして「テンプレートから新しいロールを作成」とし、ポリシーテンプレートは「シンプルなマイクロサービスのアクセス権限」を選択します。必要最低限のアクセス権限といえます。

image.png

それでは非常に単純なLambda関数を作ってみます。

API Gatewayからは、Lambda関数のexports.handlerに、引数(event, context, callback)が付いた形で呼び出されます。

今回の実装は、単純に引き渡されるeventとcontextの中身をそのままクライアントに返すようにしてみます。

以下のようなコードにしてください。

exports.handler = async (event, context, callback) => {

console.log('Lambda called OK!!');

const response = new Response();
response.set_body({
"event" : event,
"context" : context
});
callback(null, response);
};

class Response{
constructor(){
this.statusCode = 200;
this.headers = {'Access-Control-Allow-Origin' : '*', 'Content-Type' : 'application/json; charset=utf-8'};
this.body = "";
}

set_error(error){
this.body = JSON.stringify({"err": error});
}

set_body(content){
this.body = JSON.stringify(content);
}

get_body(){
return JSON.parse(this.body);
}
}


API Gateway関数の作成

外部からWeb API呼び出しを受け付けるAPI Gatewayのエンドポイントを作成します。

API Gatewayの管理コンソールを開きます。

https://ap-northeast-1.console.aws.amazon.com/apigateway/home?region=ap-northeast-1

image.png

「APIの作成」ボタンを押下します。

image.png

適当な名前を入力します。この名前は、外部に公開するWeb APIのURL名には関係ないので、本当に適当で大丈夫です。

例えば、「新しいAPI」、API名として「TestApi」とします。

まずは、特に階層は作らず、ルートにメソッド「GET」を作成します。もちろんPOSTでも構いません。

チェックマークの小さな丸いボタンを押さないと、適用されないので、忘れずに押します。

Lambdaにつなぐので、統合タイプに「Lambda関数」、Lambdaプロキシ統合の使用のチェックをOn、Lambda関数に先ほど作成した「test-cognito-lambda」を入力します。

Lambdaプロキシの統合は、Lambdaを使うのであれば、Onにしたほうが楽ちんです。

ついでに「アクション」→「CORSの有効化」を選択して、以降の選択ではデフォルトのまま進めます。

以上で、ルートの下に、「GET」と「OPTIONS」ができているかと思います。

最後に、デプロイすることで、このWeb APIが外部に公開されます。

「アクション」→「APIのデプロイ」を選択し、デプロイされるステージとして[新しいステージ]を選択し、ステージ名を例えば「prod」としてデプロイを行います。

デプロイが完了するとステージの画面に自動的に切り替わります。

青帯で囲まれた「URLの呼び出し」にあるURLがこのAPI GatewayのAPIにつながっています。


https://[RestApi-Id].execute-api.ap-northeast-1.amazonaws.com/[ステージ名]


では早速、上記URLをブラウザで表示しみます。

たくさんの要素を持ったJSONが表示されました。

最上位は2つの要素からなっており、eventとcontextです。Lambdaの実装の中で、この名前でそれぞれの変数の内容が返るようにしていたためです。

このページには認証制限はかけていないため、認証されていなくても、アクセス可能です。


API GatewayのAPIにオーソライザを設定

さきほどは、認証状態にかかわらず、誰でも参照できるAPI GatewayのAPIを作成しました。

これから、認証された状態でなければ、参照できないAPIを作成します。

API Gatewayでは、誰が認証状態で呼び出されたのかを判別するために、IDトークンを要求します。

IDトークンは、OpenID Connect用語ですが、サインインユーザが誰なのかを認証プロバイダが証明するためのトークンです。

まずはAPI GatewayのAPIにオーソライザーを作成します。

オーソライザーは、どうやってIDトークンを受け取り、どのアプリ向けのIDトークンを受け付けるのかを指定します。

新しいオーソライザーの作成ボタンを押下します。

名前には、適当な名前を付けます。たとえば、「test-cognito-authorizer」とします。

タイプは、今回の目的の対象であるCognitoを選択します。

Cognitoユーザープールの入力のところには、対象のCognitoユーザプールの名前(例えば「test-pool」)を指定します。

そして、トークンのソースに「Authorization」、トークンの検証にはどのアプリ向けのIDトークンを受け取るのかを指定します。もし複数のアプリから接続を受けるなど、接続元のアプリを特定しない場合にはトークンの検証には指定せず、Lambda側で判別します。

IDトークンには、認証プロバイダがどのアプリ向けに発行したのかどうか示すaudというフィールドがあります。Cognitoとの連携では、認証プロバイダから見てログイン連携先であったCognitoユーザプールのアプリクライアントの「アプリクライアントID」に該当します。

ちょっと横道にそれて、IDトークンの中身を見てみて、audフィールドやその他フィールドを確認してみます。

前回作成したnode.jsのアプリケーションフォルダで、いくつかファイルを追加します。(auth.htmlとstart.js)


.
├── cert
│ ├── server.crt
│ ├── server.csr
│ └── server.key
├── index.js
├── node_modules
├── package.json
└── public
├── auth.html
└── js
└── start.js

まず、auth.htmlです。


<HTML lang="ja">
<HEAD>
<META http-equiv="Content-Type" content="text/html; charset=utf-8">
<META http-equiv="Content-Security-Policy" content="default-src * 'unsafe-inline'; style-src * 'unsafe-inline'; media-src * blob:; img-src *; script-src * 'unsafe-inline';">
<TITLE>テスト</TITLE>
</HEAD>
<BODY onload="proc_load()">
<H1><a href="">テスト</a></H1>

<a href="https://<ドメイン名>.auth.ap-northeast-1.amazoncognito.com/login?response_type=token&client_id=<アプリクライアントID>&scope=openid%20email&redirect_uri=<リダイレクト先URL>
"
>サインイン</a>
<a href="https://<ドメイン名>.auth.ap-northeast-1.amazoncognito.com/logout?client_id=<アプリクライアントID>&logout_uri=<リダイレクト先URL">サインアウト</a>
<BUTTON id="secret_call" onclick="secret_call()">secret呼び出し</BUTTON>
<DIV id="message"></DIV>
<DIV id="content"></DIV>

<SCRIPT src="js/start.js"></SCRIPT>
</BODY>
</HTML>

start.jsは後述します。

リダイレクト先URLと付記していますが、今回のサンプルではこのページ自体をリダイレクト先とします。

で、このサインインURLには見覚えがありますでしょか。

前回のCognitoへのサインインのURLは以下でした。


https://<ドメイン名>.auth.ap-northeast-1.amazoncognito.com/login?response_type=code&client_id=<アプリクライアントID>&redirect_uri=<リダイレクト先URL>


今度のURLでは、response_type=tokenとし、scope=openid%20emailというのが増えています。

これまでは認証プロバイダから認可コードを取得していました。その認可コードからIDトークンを取得する順番なのですが、Implicit GrantではいきなりIDトークンを取得することができます。(もちろん、response_type=codeとして以前のトークンを取得するやり方でも構いません。)

openidはIDトークンを、emailはEメールアドレスが認証プロバイダから欲しい、ということを示しています。

Web管理コンソールのCognitoの設定画面から、アプリクライアントの設定でImplicit Grantがまだ有効にしていないようでしたら、有効にしてください。

ちなみに、サインインのリダイレクト先URLは、「Cognitoユーザープール」→「アプリの統合」→対象アプリクライアント→コールバックURLと一致させます。おそらくこのhtmlはまだ設定していないと思いますので、「,(カンマ)」区切りで追加するか、置き換えます。

また、サインアウトのリダイレクト先URLは、「Cognitoユーザープール」→「アプリの統合」→対象アプリクライアント→サインアウトURLのことです。これもまだ設定していないと思いますので、「,(カンマ)」区切りで追加するか、置き換えます。

詳細は以下を参照してください。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/login-endpoint.html

それでは、サインインURLのリンクをクリックしてみます。。。。とその前に、start.jsも使いますので、とりあえず、盲目的にファイルを作成しておきます。

start.jsです。


var hashs = {};

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

var elem = document.getElementById("message");
elem.innerText = 'id_token=' + hashs.id_token;
}

function parse_url_vars(param){
if( param.length < 1 )
return {};

var hash = param;
if( hash.slice(0, 1) == '#' || hash.slice(0, 1) == '?' )
hash = hash.slice(1);
var hashs = hash.split('&');
var vars = {};
for( var i = 0 ; i < hashs.length ; i++ ){
var array = hashs[i].split('=');
vars[array[0]] = array[1];
}

return vars;
}

function secret_call(){
var elem = document.getElementById("message");
var content = document.getElementById("content");

return do_post('https://[RestApi-Id].execute-api.ap-northeast-1.amazonaws.com/prod/secret', [], hashs.id_token)
.then(result =>{
elem.innerText = 'secret_call OK';
content.innerText = JSON.stringify(result);
})
.catch(error=>{
elem.innerText = 'secret_call Error:' + error;
});
}

function do_post(url, body, idtoken) {
const headers = new Headers( { "Content-type" : "application/json", 'Authorization': idtoken } );
return fetch(url, {
method : 'POST',
body : JSON.stringify(body),
headers: headers
})
.then((response) => {
return response.json();
});
}

image.png

で改めてサインインのリンクをクリックします。

image.png

今回も期待通りのサインイン画面がでてきました。

サインインが成功すると、以下のように、id_token=XXXXXX という長ーい文字列が表示されたのではないでしょうか。これがIDトークンです。

image.png

仕掛けは、

<BODY onload="proc_load()">

の部分と、

function proc_load() {

hashs = parse_url_vars(location.hash);

var elem = document.getElementById("message");
elem.innerText = 'id_token=' + hashs.id_token;
}

の部分です。

proc_load()は、onloadに指定されているため、本ページがロードされたときに呼び出されます。

では、いったい何をしているかというと、フラグメント識別子からid_tokenを取り出しているように見えます。見えますというより、その通りです。

無事に、Cognitoによるサインイン認証が完了すると、リダイレクト先URLにリダイレクトされます。そう、同じ本ページを再度読み直しています。ただし、ブラウザのURL入力欄を見ていただけるとわかりますが、auth.htmlの後に、#マークに続けて、非常に長い文字列があります。

まさに、この中に、id_token(IDトークン)があるわけです。

ではなぜ、Javascriptでlocation.hashからIDトークンを取り出しているかというと、実は#マーク以降はフラグメント識別子と言って、リダイレクト先URLのサーバには通知されずに、ブラウザだけに保持されます。

そこで、リダイレクトURLが呼び出されて戻ってきたときに、proc_load()を呼び出して、フラグメント識別子からIDトークンを取り出したわけです。

これでやっと、IDトークンを取得できました。早速中身を見てみます。

以下のサイトが有用です。

https://jwt.io/

image.png

IDトークンは、JSON Web Token(JWT)でエンコードされており、こちらのサイトでは、JWTに関して有用な情報やツール類を提供してくれています。

少し下の方に行くと、「Debugger」というのがあり、Encodedのテキストエリアに、さきほど取得したIDトークン(id_token=以降の文字列)を張り付けてみます。

この中で注目すべきは、Decodedの、”aud”、"cognito:groups"、”token_use”、”email”、”cognito:username”です。それぞれ以下の内容になっているはずです。


  • aud:CognitoのアプリクライアントID

  • cognito:groups:[CognitoのプールID]

  • token_use:"id"

  • email:サインインユーザのEメールアドレス

  • cognito:username:サインインユーザのユーザ名

このaudがまさにAPI Gatewayのオーソライザーで指定したトークンの検証であり、これと一致しているかどうかで、API Gatewayは間違ったIDトークンを扱わないようにしています。

ちなみに、Cognitoの検証用公開鍵は以下から取得できます。


https://cognito-idp.[リージョン名].amazonaws.com/[ユーザープールID]/.well-known/jwks.json


ついでに、secret_call()とdo_post()も見てみます。

secret_callは、これから認証付き呼び出し対象に設定しようとしているAPI GatewayのAPIを呼び出す関数です。実際には、POST呼び出しをしており、do_postがそれを行っています。

大事なのは以下の部分です。

    const headers = new Headers( { "Content-type" : "application/json", 'Authorization': idtoken } );

呼び出し時のヘッダーに、Authorizationとしてフラグメント識別子で取得したid_tokenを渡しています。

API Gatewayのオーソライザーのトークンのソースで、Authorizationと指定したのは、このヘッダでAuthorizationを指定したかったためです。


API GatewayのAPIに、認証必須を指定

それでは、API Gateway APIに認証必須条件を指定します。

まずは対象のリソースを作成します。今回は、「/secret」という名前にしてみます。

メソッドは、「POST」を選択します。

とび先のLambda関数は、せっかくサインイン専用ページにするので、「test-cognito-lambda」とは別に「test-cognito-secret-lambda」というLambda関数を作ります。中身は同じでよいです。

以下のように出来上がりました。

image.png

さらに、POSTを選択します。

メソッドリクエストを選択します。

ここで、設定→認証のところで、さきほど作成したオーソライザー「test-cognito-authorizer」を選択し、チェックボタンを押下します。

忘れずに、APIのデプロイをしておきます。


動作確認

では早速、このリソースにアクセスしてみます。

auth.htmlをブラウザで開きます。

サインイン状態だと思うので、サインアウトのURLをクリックして、いったんサインインしていない状態にします。

image.png

いざ、secret呼び出しボタンを押下してみます。

エラーとなりました。サインインしていない状態なので、エラーとなるのは正しい動作です。

image.png

では、サインインURLをリンクしてサインイン状態にします。

image.png

この状態で、secret呼び出しボタンを押下すると、無事に成功しました。

image.png

Lambda側で受け取った引数eventを見ると、新しくevent.requestContext.authorizerというのが増えているかと思います。

この中に、IDトークンの中身で見た、”aud”、"cognito:groups"、”token_use”、”email”、”cognito:username”があるのがわかります。

以上です。(ちょっと絵が少なくて説明文が多すぎましたかね。。。)