CognitoxAlexaアカウントリンクが大変だった話


Cognitoの認証をつかって、公開できるレベルのスキルを開発するのが思いの外大変だったので共有します。
なんとかはなったのですが、イマイチなのでもっといいやり方があるよ!という方はぜひご教授ください

前提

  • とあるAlexaスキルを開発した
  • そのスキルは既存のアプリをAlexaで使えるようにするスキル
  • 既存アプリはCognitoを利用して認証していた
  • 既存アプリのユーザIDはCognitoのフェデレーテッドアイデンティティから取得するidentityId

ということで、アカウントリンクをつかってCognitoで認証する必要がありましたが、どのように実現するかで紆余曲折しました。

案1:Implicit Grantで認証してみよう!

サンプルがたくさんあり、簡単だったので試してみましたがダメでした・・・。
なぜかと言うと、Implicit Grantではトークンを更新できないからです。

Implicit Grantは更新トークンをサポートしていないため、トークンの期限が切れると、エンドユーザーはアカウントを再びリンクする必要があります。

引用:アクセストークンとリフレッシュトークンについて

トークンが切れるたびにユーザにログインさせるスキルはありえないので却下。

案2:Authorization Code Grantを使ってアカウントリンクとCognitoを直接連携させよう!

結論としては、この案も要件を満たすことができませんでした。
その理由は次の通り。

国際化はまだしも、アプリのユーザIDであるidentityIDが取得できないのは致命的なため案2も断念しました。

採用した案

以上のことからAlexaとCognitoを直接連携させることを断念し、認証のための仲介アプリを実装することにしました。

仲介アプリで実現したいこと

今回実現したいことは次の3点です。
(1) CognitoでAuth Code Grant認証を実現する(トークンを更新したい)
(2) アプリのユーザIDであるidentityIDをスキル実行時に参照できるようにしたい
(3) 日本語のログイン画面をユーザに提供したい

(1)について
Auth Code Grantを利用するにあたり、Auth Codeを使って、CognitoとAlexaのアカウントリンクを直接連携する設定がわかりませんでした。
そこで、③トークン取得アプリを作成してAlexaサービスからcodeを受取り、CognitoからアクセストークンをもらってまたAlexaサービスに渡すことにしました・・・

(2)について
identityIDを取得するためには、Cognitoにログインした際に発行されるid_tokenが必要となります。
このため、ログイン画面から呼び出される①認証アプリで、ログイン処理をした後にidentytyIDを取得し、ユーザプールのカスタム属性にidentityIDを保存することにしました。
ユーザプールのに保存されている属性は、スキル実行時にAlexaから渡されるアクセストークンのみで参照することが可能です。

(3)について
①ログイン画面を作成することで実現しました。

③トークン取得アプリはもしかするとアカウントリンクでうまく設定すればできるのかもしれませんが、アクセストークン取得の場合と更新の場合の条件分岐のやり方やBodyに必要な項目を設定する方法などがわからず断念しました。。

仲介アプリのイメージは次のとおりです。

図1:アカウントリンクのシーケンス図

図1の緑の部分を実装することにしました。(実際にはAPIGatewayを経由してlambdaを呼び出しています)

設定

採用した案の設定を晒します

Cognitoの設定

  1. Cognitoのユーザプール作成(ユーザプールがない場合)
  2. クライアントシークレットありのアプリクライアントを作成する・・・(A)
    • ユーザプールの設定から、[全体設定]ー[アプリクライアント]を選択して「別のアプリクライアントの追加」ボタンをクリックし、下記の通り設定します
    • ① アプリクライアント名:任意
    • ② トークンの有効期限を更新(日):任意
    • ③ クライアントシークレットを作成:ON(デフォルトのまま)
    • その他の項目:デフォルトのまま
  3. アプリケーションクライアントが作成されます
  4. (A)で作成したアプリクライアントを設定します
    • ユーザプールの設定から、[全体設定]ー[アプリクライアント]
  5. アプリクライアントの設定画面で下記の通り設定します
  • ① 有効なIDプロバイダ:"Cognito User Pool "にチェックを入れます
  • ② コールバック URL:スキルのアカウントリンク設定画面でアカウントリンクを有効にして「Authorization Grant種別」を"Auth Code Grant"に設定すると画面下部に表示される"リダイレクトURL"のどれか
  • ③ サインアウト URL: "https://alexa.amazon.com/spa/index.html" 固定
  • ④ 許可されている OAuth フロー :"Authorization code grant"をチェック
  • ⑤ 許可されている OAuth スコープ:"aws.cognito.signin.user.admin"をチェック

スキルの設定

次にスキルの設定を行います。
スキルの[ビルド]タブから[アカウントリンク]メニューを選択します。

アカウントリンクの設定画面が表示されるので、トグルボタンをクリックしてアカウントリンクを有効にします。
また、以下の項目を設定します。

  • ① Authorization Grant種別:"Auth Code Grant"を選択
  • ② 認証画面のURI:S3に配置したログイン画面のURLを設定
  • ③ アクセストークンのURI:前述の「トークン取得アプリ」URL
  • ④ クライアントID:Cognitoで作成した「アプリクライアントID」
  • ⑤ クライアントシークレット:Cognitoで作成した「アプリクライントのシークレット」
  • ⑥ クライアントの認可方法:"HTTP Basic認証(推奨)"(デフォルト値)
  • ⑦ スコープ:"aws.cognito.signin.user.admin"(Cognitoのアプリクライアントで「許可されているOAuthスコープ」に設定した値)

実装

認証アプリ

まずは認証アプリを実装します。
CognitoにログインやdentityID取得、ユーザプールの属性変更などはサンプルが豊富にあり、すんなり出来ました。しかしAuth Codeを取得するサンプルがなかなか見つけられず苦労しました。
唯一たどり着けたのがStackOverflowの以下の投稿。
amazon web services - AWS Cognito Authorization code grant flow without using the hosted UI - Stack Overflow

REDIRECT_URIの形式に注意してください。自分の環境で動作したサンプルを貼っておきます。

※上記で設定したCognitoのアプリクライアント設定で②コールバックに設定したURLと、REDIRECT_URLを同じにする必要があります
コメント欄もご参照ください

auth.sh
#!/usr/bin/env bash -e

#===============================================================================
# SET AUTH DOMAIN
#===============================================================================
AUTH_DOMAIN="[COGNITO_DOMAIN].amazoncognito.com"

#===============================================================================
# AUTH CODE/IMPLICIT GRANTS, WITH PKCE, WITH CLIENT SECRET
#===============================================================================

## Set constants ##
CLIENT_ID="[COGNITO_CLIENT_ID]"
RESPONSE_TYPE="code"
#RESPONSE_TYPE="token"
REDIRECT_URI="https://pitangui.amazon.com/spa/skill/account-linking-status.html?vendorId=[VENDOR_ID]"
SCOPE="aws.cognito.signin.user.admin"

USERNAME=$1
PASSWORD=$2

## Create a code_verifier and code_challenge ##
CODE_CHALLENGE_METHOD="S256"
# code_verifier = random, 64-char string consisting of chars between letters,
#                 numbers, periods, underscores, tildes, or hyphens; the string
#                 is then base64-url encoded
code_verifier="$(cat /dev/urandom \
                   | tr -dc 'a-zA-Z0-9._~-' \
                   | fold -w 64 \
                   | head -n 1 \
                   | base64 \
                   | tr '+/' '-_' \
                   | tr -d '='
               )"
# code_challenge = SHA-256 hash of the code_verifier; it is then base64-url
#                  encoded
code_challenge="$(printf "$code_verifier" \
                   | openssl dgst -sha256 -binary \
                   | base64 \
                   | tr '+/' '-_' \
                   | tr -d '='
               )"

# Get CSRF token from /oauth2/authorize endpoint ##
curl_response="$(
   curl -qv "https://${AUTH_DOMAIN}/oauth2/authorize?response_type=${RESPONSE_TYPE}&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=${SCOPE}&code_challenge_method=${CODE_CHALLENGE_METHOD}&code_challenge=${code_challenge}" 2>&1
)"
curl_redirect="$(printf "%s" "$curl_response" \
                   | awk '/^< Location: / {
                       gsub(/\r/, ""); # Remove carriage returns
                       print $3;       # Print redirect URL
                   }')"

csrf_token="$(printf "%s" "$curl_response" \
               | awk '/^< Set-Cookie:/ {
                   gsub(/^XSRF-TOKEN=|;$/, "", $3); # Remove cookie name and semi-colon
                   print $3;                        # Print cookie value
               }')"

## Get auth code or tokens from /login endpoint ##
curl_response="$(
   curl -qv "$curl_redirect" \
       -H "Cookie: XSRF-TOKEN=${csrf_token}; Path=/; Secure; HttpOnly" \
       -d "_csrf=${csrf_token}" \
       -d "username=${USERNAME}" \
       -d "password=${PASSWORD}" 2>&1
)"

curl_redirect="$(printf "%s" "$curl_response" \
                   | awk '/^< Location: / {
                       gsub(/\r/, ""); # Remove carriage returns
                       print $3;       # Print redirect URL
                   }'
               )"

auth_code="$(printf "%s" "$curl_redirect" \
               | awk '{
                   sub(/.*code=/, ""); # Remove everything before auth code
                   print;              # Print auth code
               }')"

echo 'auth_code='$auth_code

lambdaをNodeで実装していたため、本当はシェルをNodeに置き換えたかったのですが、このサンプルコードではシークレットなしのCognitoアプリクライアントでは動作せず、また、シークレットありのCognitoアプリクライアントではJSで動作しないため、期間的な制約もあり断念し、シェルをNodeから実行させることにしました。

Amazon Cognito JavaScript SDK はアプリのクライアントシークレットを使用しません。アプリのクライアントシークレットとともにユーザープールのアプリのクライアントを設定する場合は、SDK が例外をスローします。

引用:チュートリアル: JavaScript アプリのユーザープールを統合する - Amazon Cognito

参考:認証エンドポイント - Amazon Cognito

ログイン画面

Cognitoへのログインに必要な項目をユーザに入力させる画面を作成しました。
ログインボタンで認証アプリを呼び出します。

トークン取得アプリ

スキルの設定で、「③ アクセストークンのURI」を設定しました。このURIに設定したトークン取得アプリは、次の2つの機能があります。
1. Cognitoにcodeを渡してアクセストークンを取得する
2. Cognitoにリフレッシュトークンを渡してアクセストークンを更新する

こちらはCognitoの開発者ガイドに仕様がありますので、それを参考に実装しました。

参考:トークンエンドポイント - Amazon Cognito

まとめ

ざっくりですが、以上の方法でアカウントリンクの認証をCognitoのAuthorization Code Grantで実現することができました。
とても疲れました・・・
もしかするとPythonだったらもっとすんなりいったのかな・・・?
もっとよい方法があれば是非コメントください!
よろしくお願いいたします。