事前認証が必要なアプリケーションにおける初期化処理の実装でハマった話

SCSKの畑です。9回目の投稿です。

今回は、6回目のエントリで少し言及したアプリケーションの初期化処理について、詳細について記載してみます。

アーキテクチャ概要

そろそろ食傷気味かもしれませんがいつもの図を。今回はほとんどアプリケーション側の話題ですが、一部 Amazon Cognito 認証の話が出てきます。

構築・開発中のアプリケーションアーキテクチャ概要図です。

背景

これまでのエントリで説明した通り、本アプリケーションではテーブルのステータス(編集状態)管理が重要となりますが、中でもステータスの初期化をどのタイミングで実施するかというのが特に重要となります。同エントリで言及した通り、ステータスをアプリケーションのルーティングや画面制御に使用するため、できる限り早いタイミングで初期化するのが実装上望ましいです。

このため、フレームワーク (Nuxt3) のライフサイクルを確認の上、具体的には Nuxt3 の Plugin 内で初期化するような方針としました。Nuxt3 を SSR で使用していることもあり、サーバサイドとクライアントサイド両方から実行され得るため、両対応した上でなるべく早いタイミングで実行できる方法として最適であると考えました。aws-amplify における SSR 対応についても、公式ドキュメント内では Plugin を使用しているというのも判断材料の一つになりました。

https://nuxt.com/docs/guide/going-further/hooks

問題発生

ということで、この実装においてしばらく開発を続けていたのですが、アプリケーションにおける Cognito ユーザ/グループ権限周りの機能を実装し始めたタイミングで問題が発生しました。

同機能の動作確認を実施するにあたり、当然ながら複数のユーザでログイン/ログアウトを繰り返しながら画面を操作する必要があるため、一度ログアウトしてから別ユーザでログインしたところ・・

アプリケーションメニューのエラー画面です。

あれ、左ペインのメニューが空になっている。本来は赤枠部分にメンテナンス対象のテーブルが一覧表示されるはず・・ということでブラウザのコンソールを見てみたところ、以下のようなエラーが出ていました。(当時は開発真っ最中だったこともあり、エラー出力用の汎用コンポーネントなどはまだ用意していませんでした)

UserUnAuthenticatedException: User needs to be authenticated to call this API.

見た通り、AppSync API が Unauthorized で叩けないというエラーで、これがステータス初期化関数内での AppSync API 実行時に出力されていたのが本事象の原因でした。ただ、ステータス初期化処理や Cognito 認証周りの実装は変更していなかったので、最初は何故こういうエラーが発生したのか分かりませんでした。

しかも、この画面でリロードすると正常に画面表示されるんですよね。そうだよね、実装変えてないんだから問題ないはずよね・・というところまで確認したところでようやく気づきました。あ、Plugin 内でステータス初期化されるタイミングで、まだユーザが Cognito 認証されていないのでは、と・・

2回目のエントリで AppSync API の認可方式を説明した通り、本アプリケーションにおいては IAM を使用しています。では、この IAM 権限はどこから付与されるのかというと、Cognito ID プールの「認証されたアクセス」を通してとなります。つまり、当然ですが Cognito 未認証の時点(=未ログイン時点)ではこの AppSync API を叩ける権限が付与されていません。Plugin が実行されるタイミングは Nuxt.js のライフサイクルにおけるアプリケーションの作成時であり、Cognito の認証画面の表示前に実行されるため、上記のエラーが出力されたという流れになります。

Nuxt3入門(第7回) - Nuxt3のプラグイン・ミドルウェアを使う | 豆蔵デベロッパーサイト
前回はNuxt3のエラーハンドリングについて見てきました。今回はプラグインとミドルウェアを見ていきます。両方とも必須という訳ではありませんが、うまく使えばアプリケーション開発を効率化できます。プラグインはNuxtアプリケーション初期化時に実...

ただ、アプリケーションの実装として plugin で初期化する考え自体は先般の通り間違っていないと考えていたため、未ログイン(未認証)の場合はログイン直後に初期化するようなロジックを追加しようと考えました。ログイン(認証)した状態でアプリケーションを使用することが前提である以上、ログイン直後のタイミングで初期化できていれば問題ないだろうという判断です。

2回目のエントリで記載したCognito ID プールのゲストアクセスを使用して、ゲストアクセス経由で付与される IAM ロールにステータス初期化で使用する graphql query のみ許可するような IAM 権限を割り当てることで、未ログイン時でもステータス初期化を行えるようにする、というのも今振り返ると一つの手ではあったと思います。ただ当時はそこまで発想が及ばなかったというのが正直なところです。

また、その場合は以下項目について考慮・検討しておく必要があったと考えています。

  • IAM ポリシーにおいて resource 句内の type や field を同クエリのみ許可するよう正確に設定する
  • cognito 未認証(≒本アプリケーションを使用する権限の無いユーザも含む)がどこまでアプリケーション内部で情報を取得できてよいのかの検討

試行錯誤その1

ではログイン画面のロジックを変更して問題解決・・と一筋縄にはいきませんでした。理由は、ログイン画面の実装でお馴染みの Amplify UI モジュールを使用していたためです。こちらも改めて説明の必要がないくらいには便利なモジュールですが、自前で実装していない以上簡単に変更できない&変更できたとしてもモジュールを使用する以上保守の観点からソースコード自体を変更したくありませんでした。

そこで公式ドキュメントを改めて調べたところ、ログイン直後に任意の処理を実行するような「Override Function Calls」という機能がありました。正に今回のようなシチュエーションで欲しかった機能であり、さすがに広く使われているモジュールだなあと感心したのですが・・

https://ui.docs.amplify.aws/vue/connected-components/authenticator/customization

この機能、私が試した限りでは Nuxt3 (Vue3) では動作しませんでした。。。

文字通り、対象の関数をオーバーライドするような仕組みだったので、動かした結果何かしらのエラーが出るのであればデバッグのしようもあるなと思ったのですがうんともすんとも言わず、ほぼサンプルコード通りの実装でも NG。同機能を使ってみたような事例自体は WEB 上からいくつか見つかったのですがほぼ Next.js (React) だったので、ひょっとするとこれ Nuxt.js (Vue) で動作した実績が少ないのではないかと邪推して、この機能を使うのは一旦諦めました。

なお、本件について試行したのは数ヶ月前のことになるため、もし現時点で正常に動作するという情報をお持ちの方がいればご教示頂けますと幸いです。

試行錯誤その2

それでは仕方がないので、ログイン画面を自前で実装するしかないか・・とも考えたのですがこちらは割とすぐに諦めました。理由は明快で、Amplify UI の作りというか機能性が単純に優れていたためです。

単純な Cognito の認証画面であれば作れると思っていたのですが、実際の認証フローを想定すると、メールアドレスの検証やパスワード変更・リセットのようなケースについても対応する必要があるため、それらの画面なりロジックを用意するとなると一から作るのはそれなりのコストがかかりそうだなと。元々ログイン画面は Amplify UI に任せるつもりで実装の計画を立てていたこともあり、やはり公式のライブラリ/モジュールを使用しておくのがベターと判断しました。

解決

とは言え、先述の通り Amplify UI の機能で解決することもできそうにないし、どうしたものか・・ということで、一時は完全にスタックしていたのですが、方向性自体は先般の通り「ログイン直後のタイミングで初期化する」が正しいと考えていたため、それを実現するための方法をあれこれ考えた結果、最終的に以下のように解決することができました。

  • ログイン直後に実行したい処理を実行するための、画面なしページを用意
  • 未ログイン時は必ず同ページにリダイレクトするように middleware でルーティング(ルートミドルウェアを使用)
  • ログインして同ページ内の処理を実行後、トップページに遷移

まず前提として、Amplify UI & Nuxt3 (Vue3) における Cognito 認証は app.vue に以下のようなコードを実装することで実現しています。

<Authenticator> タグで囲われている要素が Cognito 認証されていないと見えなくなり、代わりにログイン画面が表示されます。以下実装例の場合は実質的にアプリケーション上の全ページ/コンポーネントが認証の対象となります。そして、ログインに成功するとアクセスしている URL に対応したページに遷移するというような挙動となります。このため、少なくとも以下実装例においては、いわゆるログインページのような固有の URL パスは存在しません。

<template>
    <Authenticator :hide-sign-up="true">
        <NuxtLayout>
            <NuxtPage />
        </NuxtLayout>
    </Authenticator>
</template>

<script setup lang="ts">
import { Authenticator } from "@aws-amplify/ui-vue";
import "@aws-amplify/ui-vue/styles.css";
</script>
Authenticator | Amplify UI for Vue
Authenticator component adds complete authentication flows to your application with minimal boilerplate.

このため、未ログイン状態でも事実上全ての URL にアクセスすること自体はできてしまい、「ログイン直後に特定の処理を実行する」ような実装が難しい原因となっていたのですが、それを middleware を使用したルーティングにより解決することができました。同処理のためのページに画面は不要なので、そのままトップページにリダイレクトするような実装としています。

最後にそれぞれ実装例を示して本エントリを終わりたいと思います。

ログイン直後の処理実行用ページ (login.vue)

先述の通り、本ページにおける処理内容はシンプルです。updateTableStatusDict() でテーブルステータスの初期化を実施した後、実行後にトップページにリダイレクトするような処理となっています。なお、実案件事例では他情報の初期化なども合わせて実施していますが、説明のため省略します。

また、Nuxt3 の Layout で各ページのレイアウトを制御していますが、本ページにはメニューバーやナビゲーションバーのような共通表示コンポーネントも不要なため、専用レイアウトを適用して非表示としています。

<template></template>
<script setup lang="ts">
definePageMeta({ layout: 'login' })
const { updateTableStatusDict } = useTableStatus()

onMounted(async () => {
    await updateTableStatusDict()
    return navigateTo('/')
})
</script>

middleware によるルーティング (auth.global.ts)

こちらも実装例に落とすと内容はシンプルです。ルートミドルウェアの定義が defineNuxtRouteMiddleware に対応します。引数の to が遷移先のルート(パス)、 from が遷移元のルート(パス)を示しており、他の情報(ユーザのアプリケーション権限や、テーブルのステータスなど)と組み合わせてルーティングの制御を行っています。

ただ、この短いコードの中で躓いたポイントが実は2つほどあったため、以下にまとめました。

  • Cognito ユーザ認証済みかどうかを直接確認するメソッドは aws-amplify/auth には存在しないようです。このため、未認証時に実行した場合例外がスローされるようなメソッド(以下実装例の場合は getCurrentUser())を実行し、その例外を catch することでユーザ認証済みかどうかの判定をしています。
  • error を catch した場合は Cognito ユーザ未認証のため、上記で説明したログイン直後の処理実行用ページに対応するルート(以下実装例の場合は /login)に遷移させたいのですが、ルートミドルウェアはページの遷移ごとに呼び出されるため、単純に実装すると未認証時のルーティングが無限ループしてしまいます。このため、遷移先が /login でない場合のみ遷移するようにすることで、無限ループを抑止しています。
export default defineNuxtRouteMiddleware( async(to, from) => {
    try {
        if ( await useNuxtApp().$Amplify.Auth.getCurrentUser() ) {
            // Cognitoログイン済みの場合
            //// 以下、ユーザのアプリケーション権限に応じたルーティングを制御 ////
        }
    } catch (error) {
        // Cognito未ログインの場合
        // ルーティングの無限ループを防ぐため、ログインページへの遷移は一度だけにする
        if (to.path !== '/login') {
            return navigateTo('/login')
        }
    }
}

まとめ

今振り返ると意外とあっさり解決したように思えるのですが、開発中はあれこれ思い悩んでいた記憶があります。一時は AppSync の認可方式から見直すことも考えていましたが、先述したような ID プールのゲストアクセスを使用する発想には至らず。最悪 Lambda に変更すれば力業でどうにかなるんじゃないかと思ったりもしましたが、さすがに筋が悪すぎであろうと言うことで結局脳内で却下しました。

というか、アプリケーション開発においてこういう話って正直あるあるというか、真っ先に考えておかないといけないことの一つではないかと思い至り、改めて私自身の開発経験不足を実感した次第です。最終的に当初の方向性で解決策に辿りつけたこと自体は良かったと思うのですが、もっと精進したいですね。。

本投稿がどなたかの役に立てば幸いです。

タイトルとURLをコピーしました