Amplify Gen1の構築方法(その2)

前回、Amplify Gen1 で、デフォルトの認証モードである「APIキー」を使って、DynamoDBへの登録と読み込みを試してみた。ただ「APIキー」が漏れてしまうとデータの全操作が可能になるリスクがあるため、早々に認証モードを変更する必要がある。その場合「IAM」か「Cognito UserPool」のどちらかに変更するのが一般的である(他もあるが今回は割愛)。ちなみに両方ともCognitoの導入が前提となる。

今回、認証モードを変更する際、かなり手こずったため、そこでわかったことをまとめる。今回の前提は、「amplify add api」「amplify add auth」の両方を実行し、バックエンドが用意されていること。

噛み砕いた認証方式

認証戦略を噛み砕く

データモデルを構築する際、以下の schema.graphql ファイルを編集後、バックエンドに反映させるため”amplify push”を行う。

/next-blog4/amplify/backend/api/nextblog4/schema.graphql

# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules
input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY!

type Todo @model {
  id: ID!
  name: String!
  description: String
}

繰り返しになるが、デフォルトの認証方式が(有効期限が切れると使えない)「APIキー」のため、まずはこの認証方式を早急に変更する必要がある。ここでは「IAM」か「Cognito UserPool」のどちらかに変更する手順をまとめる(厳密には他にもあるが今回は割愛)。

以下のドキュメントを参照しつつ進める。

■Customize authorization rules – Next.js – AWS Amplify Documentation
https://docs.amplify.aws/nextjs/build-a-backend/graphqlapi/customize-authorization-rules/

初期 schema.graphql の「input AMPLIFY」はコメントアウトし、テーブルに対して認証方式を設定することとする。

まず最初に困ったのが、下記マトリクスの理解が難しいこと。書かれている以外の組み合わせもあるけどその場合はどうなるの?という感じ。

わかりづらい認証戦略

試行錯誤した結果、マトリクスでまとめたのが以下の表である。

噛み砕いた認証方式

具体的には、以下のようなシーンで使うのではないかと推測している。

各認証方式の利用イメージ

認証方式:IAM

IAM認証方式とは

「IAM」認証方式とは、Amazon Cognito IDプールにおいて、「認証されたロール」「ゲストロール」に対して、IAMポリシーを許可する方式のこと。ややこしいのが、もう一方の認証方式が「userPools」のため、「IAM」は一見Cognitoとは関係がない別の方式であるように見えること。「IAM」認証方式は同じくCognitoで制御される。

初期時点のAmazon Cognito IDプール(「ユーザーアクセス」タブ)は以下のようになっている。「ゲストアクセス」は非アクティブとなっている。

Amazon Cognito IDプール

「認証されたロール」「ゲストロール」共に権限は割り当たっていない。

認証されたロール
ゲストロール

上記がどのように変わっていくのか見ていく。

デフォルトの認証方式を「IAM」、追加の認証モードを「Cognito User Pool」に変更

AppSyncを見ると以下の通り、デフォルトの認証方式は「API Key」となっている。

AWS AppSync デフォルトの認証モード

“amplify update api” を実行し、デフォルトの認証方式を「IAM」に変更する。追加の認証モードは「Amazon Cognito User Pool」に設定しておく。

amplify update api

   ╭────────────────────────────────────────────────────────────╮
   │                                                            │
   │                     Update available:                      │
   │   Run amplify upgrade for the latest features and fixes!   │
   │                                                            │
   ╰────────────────────────────────────────────────────────────╯

? Select from one of the below mentioned services: GraphQL

General information
- Name: nextblog4
- API endpoint: https://xxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql

Authorization modes
- Default: API key expiring Tue Feb 20 2024 15:00:00 GMT+0900 (GMT+09:00): da2-xxx
- IAM

Conflict detection (required for DataStore)
- Disabled

? Select a setting to edit Authorization modes
? Choose the default authorization type for the API IAM
? Configure additional auth types? Yes
? Choose the additional authorization types you want to configure for the API Amazon Cognito User Pool
Cognito UserPool configuration
Use a Cognito user pool configured as a part of this project.
✅ GraphQL schema compiled successfully.

AWS AppSyncを確認すると、IAM+Cognitoに変更されている。

IAM + Amazon Cognito

「ゲストロール」を有効化

Amazon Cognito ID プールにおいて、デフォルトで「ゲストロール」は非アクティブとなっている(未ログインユーザーは一切アクセス不可)ため「アクティブ化」しておく。

Amazon Cognito ID プールの「ゲストアクセス」を「アクティブ化」

「認証されたロール」「ゲストロール」共に権限が割り当たった(詳細は割愛)。

認証されたロール
ゲストロール

戦略:公開されたブログ記事(Todoテーブルを読み取り専用に)

まずは、公開されたブログ記事をReadOnlyで表示するよう設定する。

schema.graphqlの編集

「IAM」認証に変更するため、以下のようにschma.graphql を編集する。意図としては、Todoテーブルを全公開するものの、読み取り専用として公開する、というもの。

/next-blog4/amplify/backend/api/nextblog4/schema.graphql

# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules
#input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY!

type Todo 
  @model 
  @auth(rules: [
    { allow: public, provider: iam, operations: [read] }, # Non-logged in user cannot write.
  ]) {
  id: ID!
  name: String!
  description: String
}

“amplify push”。まったりと3分ほど待つとデプロイ完了。

バックエンド側のAppSync・DynamoDB・Amazon Cognitoが更新される。GraphQLでデータ操作をするのは取り扱いがしやすい反面、セキュリティリスクが常に伴うため、上記のようにテーブルごとに権限を制限する必要がある。

公開データが改ざんされるとまずいので、テーブルごとにどの権限を持ったユーザーが更新できるのかを厳密に定義することで、仕組みとして改竄できない仕組みとなっている。裏を返せばこのあたりの設定を熟知した上で構築しないとセキュリティが担保されないため認証方式については学習が必須となっており、理解した開発者のみが扱える。

サンプルコードの実装

前回の通り以下をコピペする。

■Connect API and database to the app – Next.js – AWS Amplify Documentation
https://docs.amplify.aws/nextjs/start/getting-started/data-model/#create-a-form-for-submitting-the-todos

以下のワーニングのみ対応するため、<li>に「key={todo.id}」を追加しておく。

Warning: Each child in a list should have a unique "key" prop.
Check the top-level render call using <ul>. See https://reactjs.org/link/warning-keys for more information.
    at li
src/app/page.tsx

import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import * as mutations from '@/graphql/mutations'
import * as queries from '@/graphql/queries'

import config from '@/amplifyconfiguration.json'

const cookiesClient = generateServerClientUsingCookies({
    config,
    cookies
})

async function createTodo(formData: FormData) {
    'use server'
    const { data } = await cookiesClient.graphql({
        query: mutations.createTodo,
        variables: {
            input: {
                name: formData.get('name')?.toString() ?? ''
            }
        }
    })

    console.log('Created Todo: ', data?.createTodo);

    revalidatePath('/')
}

export default async function Home() {
    const { data, errors } = await cookiesClient.graphql({
        query: queries.listTodos
    })
    
    const todos = data.listTodos.items
    
    return (
        <div
            style={{
                maxWidth: '500px',
                margin: '0 auto',
                textAlign: 'center',
                marginTop: '100px'
            }}
        >
            <form action={createTodo}>
                <input name="name" placeholder="Add a todo" />
                <button type="submit">Add</button>
            </form>

            {(!todos || todos.length === 0 || errors) && (
                <div>
                <p>No todos, please add one.</p>
                </div>
            )}

            <ul>
                {todos.map((todo) => {
                return <li key={todo.id} style={{ listStyle: 'none' }}>{todo.name}</li>;
                })}
            </ul>
        </div>
    )
}

UnauthorizedException

“npm run dev” → http://localhost:3000 で画面を確認すると、さわやかに以下のエラー画面が表示された。

Unhandled Runtime Error

ブラウザのコンソールを確認すると以下のエラーとなっている。申し訳ないのだが意味はよくわからない。

The above error occurred in the <NotFoundErrorBoundary> component:
    at NotFoundErrorBoundary (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/not-found-boundary.js:76:9)
:

デバッグしてみると、以下のクエリ実行時に落ちているようである。

:
    const { data, errors } = await cookiesClient.graphql({
        query: queries.listTodos
    })
:

ターミナルを見ると以下のエラーが発生していたが、この際は認証方式を「IAM」に変更していなかった(API Keyのままだった)。

 ⨯ {
  data: { listTodos: null },
  errors: [
    {
      path: [Array],
      data: null,
      errorType: 'Unauthorized',
      errorInfo: null,
      locations: [Array],
      message: 'Not Authorized to access listTodos on type Query'
    }
  ]
}
 ⨯ [Error: Error: [object Object]] { digest: '193452068' }

認証方式を「IAM」に変更しても動かない。以下のエラーが発生した。

⨯ {
  data: {},
  errors: [
    UnauthorizedException: Unknown error
        at buildRestApiError (webpack-internal:///(rsc)/./node_modules/@aws-amplify/api-rest/dist/esm/utils/serviceError.mjs:87:26)
        at parseRestApiServiceError (webpack-internal:///(rsc)/./node_modules/@aws-amplify/api-rest/dist/esm/utils/serviceError.mjs:30:16)
        at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
        at async job (webpack-internal:///(rsc)/./node_modules/@aws-amplify/api-rest/dist/esm/utils/createCancellableOperation.mjs:31:23)
        at async GraphQLAPIClass._graphql (webpack-internal:///(rsc)/./node_modules/@aws-amplify/api-graphql/dist/esm/internals/InternalGraphQLAPI.mjs:234:44)
        at async runWithAmplifyServerContext (webpack-internal:///(rsc)/./node_modules/aws-amplify/dist/esm/adapterCore/runWithAmplifyServerContext.mjs:23:24)
        at async Home (webpack-internal:///(rsc)/./src/app/page.tsx:49:30) {
      recoverySuggestion: `If you're calling an Amplify-generated API, make sure to set the "authMode" in generateClient({ authMode: '...' }) to the backend authorization rule's auth provider ('apiKey', 'userPool', 'iam', 'oidc', 'lambda')`
    }
  ]
}

authModeを指定してもダメ。

:
    const { data, errors } = await cookiesClient.graphql({
        query: queries.listTodos,
        authMode: 'iam'
    })
:

試行錯誤の結果、以下のエラーが発生することも(何をどうしたのか覚えていないのだが・・)。

Error: No current user
    at GraphQLAPIClass._headerBasedAuth (webpack-internal:///(rsc)/./node_modules/@aws-amplify/api-graphql/dist/esm/internals/InternalGraphQLAPI.mjs:85:27)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async GraphQLAPIClass._graphql (webpack-internal:///(rsc)/./node_modules/@aws-amplify/api-graphql/dist/esm/internals/InternalGraphQLAPI.mjs:184:35)
    at async runWithAmplifyServerContext (webpack-internal:///(rsc)/./node_modules/aws-amplify/dist/esm/adapterCore/runWithAmplifyServerContext.mjs:23:24)
    at async Home (webpack-internal:///(rsc)/./src/app/page.tsx:55:34)

権限エラー、もしくはユーザーがいないというエラーであるが、以下の対応は間違いなく完了しているだけに納得がいかない。

  • AWS AppSyncの認証方式は「IAM」に変更されている。
  • Amazon Cognito ID プールのゲストアクセスはアクティブ化されている
  • Amazon Cognito ID プールのゲストロール(未ログインユーザー)に権限が当たっている

UnauthorizedException解決方法

同じように悩んでいた方もおられるのではないだろうか?サンプル通りに実装しているのに動かないのはなんでだろうと。私の場合、約一週間の試行錯誤の結果、ようやく解決した。こういうトラブルシュートに時間がかかるのはつらいが、解決できたときの達成感は大きい。解決するには、Amplify.configure(config) の追記が必要だった。

:
import config from '@/amplifyconfiguration.json'

import { Amplify } from 'aws-amplify'
Amplify.configure(config)

const cookiesClient = generateServerClientUsingCookies({
  config,
  cookies
})
:

詳細はわかっていないのだが、Amplify.configure(config) を実行することにより、Amplifyの内部で設定が読み込まれ、generateServerClientUsingCookies() 実行時にその読み込まれた設定を元に処理を行っている・・など推測される。パラメータにconfigを渡しているのだから動作してほしいものだが。

うまく動作すると以下のように表示される。

成功するとめでたく表示される

ReadOnly属性で設定しているため、Todoを追加しようとすると以下のエラーが発生する。予測通りでありOKである(本来であればtry-catchすべきだが割愛する)。

 ⨯ {
  data: { createTodo: null },
  errors: [
    {
      path: [Array],
      data: null,
      errorType: 'Unauthorized',
      errorInfo: null,
      locations: [Array],
      message: 'Not Authorized to access createTodo on type Mutation'
    }
  ]
}

次回はIAM認証で書き込み権限を付与する。