AWS
lambda
cognito
APIGateway
openidconnect

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トークンを取得する順番なのですが、AWS CognitoではいきなりIDトークンを取得することができます。openidはIDトークンを、emailはEメールアドレスが認証プロバイダから欲しい、ということを示しています。

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

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