AWSのCognitoとAPIGatewayを連携させ、アプリクライアント別にAPIアクセスを管理してみた

# はじめに
API Gatewayは、各メソッドにオーソライザーと呼ばれる認証機能を設定することができます。
この設定により、APIGatewayへのリクエストの際に、IAMやCognito、Lambdaでの認証を必要とすることができます。

Cognitoを認証方法に選択する場合、Cognitoのアプリクライアントごとにメソッドに対する認可を個別設定するべきだと思います。

Cognitoではアプリクライアントごとにカスタムスコープを設定することができるため、アプリクライアントごとに認可スコープを設定し、API Gateway側でスコープを検証することができそうでした。

![cap1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/88f7a5af-b0b4-c8eb-46cb-91abcecc9d50.png)


実際に設定し、NextJsとNextAuth.jsを使用し動作確認してみました。

参考：

https://repost.aws/ja/knowledge-center/cognito-custom-scopes-api-gateway

### 1. API Gatewayリソースを作成する
API Gatewayでリソース、メソッドを作成します。
Cognitoオーソライザーはまだ設定できないので、特別な作業は行いません。

![cap2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/012b1b15-71fa-8000-50c9-1c5587961cfa.png)

デフォルトでは「認可：None」になっていることが分かります。

### 2. Cognitoでユーザープール、リソースサーバー、アプリクライアントを作成する
###### ユーザープールの作成
ユーザープールを作成します。
特別な設定は不要ですが、今回はホストされたUIを使用するので、エンドポイント、ドメインの設定を行います。

![cap3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/a7339838-f646-c31c-e86e-6aefeee036d8.png)


###### ユーザーの作成
以下の記事を参考に、AWSCLIでユーザーを作成します。

![cap14.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/b68a5fe7-71a0-c6d9-c34c-7cbb4069ce3d.png)

https://zenn.dev/longbridge/articles/56678cbb919d61

###### リソースサーバーの設定
リソースサーバーを設定します。
リソースサーバー識別子は任意の値で良いらしいのですが、**ステージURLを設定したところ、「/」を含むのがよくないのか、動作しなかった**ためAPI Gatewayのドメインのみを指定しています。

![cap5.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/0b30c476-5afc-0594-6a8d-a684adce6dff.png)

また、カスタムスコープに2つのスコープを設定しています。2つのアプリケーションクライアントにそれぞれ認可するスコープです。

###### アプリケーションクライアントの設定
アプリケーションクライアントを設定します。

![cap6.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/ff37b8fb-bdca-416e-3783-a6d89aac3bf7.png)

NextAuthは認証にSRPの手法をとるので、SRP認証を許可します。

![cap4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/d95b3648-1c9f-c91c-5f5a-8e3027571d09.png)

NextAuthは認証にクライアントシークレットを必要とするので、クライアントシークレットを発行するよう設定します。

![cap7.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/d8651cea-2593-b9d1-ecb4-90f3789d1c91.png)

アプリケーションクライアントへの認可を設定します。
NextAuthは認証後、コールバックURLとして「/api/auth/callback/${NextAuthで設定する任意のID}」を指定するので、「http:*/*/localhost:3000/api/auth/callback/${NextAuthで設定する任意のID}」を設定します。

![cap8.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/9b9219fd-e4d1-c7ad-be7f-4cca8c8e786a.png)

認証もCognitoユーザープールで行うので、IdPはCognitoとします。
また、NextAuthはデフォルトでopenidスコープを受け取ろうとする（？）ため、OpenIDをスコープに設定します。（NextAuth以外は不要）
最後に、今回の検証の肝要な部分であるカスタムスコープを設定します。
「リソースサーバー識別子/リソースサーバーで設定したスコープ」の形式で選択できます。

以上の要領で、異なるカスタムスコープを持つ2つのアプリケーションクライアントを作成しました。

![cap9.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/e18c1dfd-d4a5-dac7-2c11-e93c20e1d5de.png)

![cap10.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/e4f683dc-474b-3deb-1801-67ba298a0176.png)

![cap11.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/70c6f334-26eb-b8b5-354c-167459366745.png)

### 3. API GatewayリソースにCognitoオーソライザーを設定する
###### オーソライザーの作成
Cognitoを利用するオーソライザーを作成します。

![cap13.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/b12b404f-005a-979f-75e1-385e361405c2.png)

###### メソッドの認証機能を設定
メソッドリクエストの設定画面から認証設定を行います。
「認可」にCognitoオーソライザーを指定し、「Authorization Scopes」に先ほど作成したカスタムスコープの片方を設定します。

![cap12.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/1661d3e5-ef2c-c6a8-4e8a-52c5a98e709f.png)

### 4. NextAuth.jsで動作確認
###### npm install
```
npm install next-auth
npm install jwt-decode
```

Cognitoから取得するTokenの内容を確認するために、Tokenをデコードするライブラリもinstallしています。

###### 認証APIを設定
今回記事のテーマ外なので詳細は明記しませんが、以下のような要領で、2つのCognitoアプリケーションクライアントを使用してログインできるよう設定します。

```src/app/api/auth/[...nextauth]/route.tsx
import NextAuth from "next-auth"
import CognitoProvider from "next-auth/providers/cognito";
import { jwtDecode } from "jwt-decode";

const handler = NextAuth({
    providers: [
        CognitoProvider({
            // providers内で一意なidを設定する。
            // 設定したidはNextAuthの認証後のリダイレクトURLに使用される
            // 認証後のリダイレクトURLは「http://localhost:3000/api/auth/callback/${id}」
            // Cognitoアプリクライアント側でも、同じ値をコールバックURLとして設定しておくこと
            id: "cognito1",

            // https://cognito-idp.ap-northeast-1.amazonaws.com/${ユーザープールID(ap-northeast-1_XXXXXX)}
            issuer: process.env.COGNITO_ISSUER || "",

            // アプリケーションクライアントのクライアントID
            clientId: process.env.COGNITO_CLIENT_ID || "",

            // アプリケーションクライアントのクライアントシークレット
            clientSecret: process.env.COGNITO_CLIENT_SECRET || "",

            authorization: {
                // 任意のスコープを受け取る
                // 今回の検証ではopenidは不要だが、設定しないとNextAuthがエラーするため設定
                params: { scope: `openid ${process.env.RESOURCE_SERVER_ID}/sample-scope` },
            }
        }),
        CognitoProvider({
            id: "cognito2",
            issuer: process.env.COGNITO_ISSUER || "",
            clientId: process.env.COGNITO_CLIENT_ID2 || "",
            clientSecret: process.env.COGNITO_CLIENT_SECRET2 || "",
            authorization: {
                params: { scope: `openid ${process.env.RESOURCE_SERVER_ID}/not-sample-scope` },
            }
        })
    ],
    session: {
        strategy: "jwt",
    },
    callbacks: {
        // useSession()関数でCognitoから受け取ったTokenを参照するための設定
        async jwt({ token, account }) {
            if (account) {
                if (account["access_token"]) {
                    token.accessToken = account["access_token"]

                    // デバッグ用に検証
                    const decoded = jwtDecode(token.accessToken);
                    console.log("decoded", decoded);
                }
            }
            return token
        },
        async session({ session, token }) {
            if (token.accessToken) {
                session.user!.accessToken = token.accessToken
            }
            return session
        }
    },
})

export { handler as GET, handler as POST }
```

https://next-auth.js.org/getting-started/client#usesession

また、ログイン後、APIGatewayへリクエストを行うコードもテスト用に追加しています。
```src/app/home/page.tsx
const onClick = async () => {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_GATEWAY_ENDPOINT}/example`, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "Authorization": session!.user!.accessToken
        },
        body: JSON.stringify({}),
        cache: "no-store"
    });
    console.log("response", await response.json())
}
```


###### 検証！
実際にテストしてみます。

![cap15.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/7050ba11-2376-06a7-dabf-d359d043820c.png)

NextAuthのログイン画面に設定したCognitoへの導線が表示されます。
1つ目のボタンをクリックすると、Cognitoのホストされたサインインページへ遷移します。

![cap16.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/2b7e8858-e184-f1b4-ba1a-6b2aa9749f19.png)

ログインが完了後、以下のコードが実行されます
```src/app/api/auth/[...nextauth]/route.tsx
// 49行~
// デバッグ用に検証
const decoded = jwtDecode(token.accessToken);
console.log("decoded", decoded);
```

Cognitoから受け取ったTokenの中に、カスタムスコープが定義されていることが確認できました。

![cap17.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/c13130f7-9e0e-a58b-6a94-8c270ad6510a.png)

続いて、ヘッダーにAccessTokenを付与してAPI Gatewayへリクエストを行ってみます。
```src/app/home/page.tsx
headers: {
    "Content-Type": "application/json",
    "Authorization": session!.user!.accessToken
},
```

![cap18.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/255ae13e-223c-e3ee-40a5-c033b9e3e78f.png)

ステータス200が返却されました！

認可されていないアプリケーションクライアントでの動作も確認します。
同様にCognitoのホストUIからログインすると、異なるカスタムスコープが定義されていることが確認できます。
画像では隠していますが、client_idも異なる値が返却されます。

![cap19.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/b998600f-7917-525e-46ff-bfe9c1c604e1.png)

ヘッダーにAccessTokenを付与してAPI Gatewayへリクエストを行ってみます。

![cap20.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3654730/7b4b75d2-5fda-52c0-32b1-1b7a306a6955.png)

ステータス401 Unauthorizedが返却されました！

無事、Cognitoオーソライザーを使用しつつ、アプリケーションクライアント別にAPI Gatewayへのアクセスを管理できました。

# おわりに
軽い気持ちで手を出したのですが、凄く疲れました・・・。
疲れた気持ちで記事を書くと、内容が簡素になってよくないなという気づきを得られました。

Outputは今後も続けていきたいので、備忘として小刻みに残していく、などの工夫をしていければな、と思います。