【NextAuth.js/認可】IAM認証されたAPI GatewayにIdTokenを使ってアクセスする

2023/09/22に公開

はじめに

ウェブアプリケーションにおいて、ユーザーごとの権限を変えたい時ってありますよね?例えば、特定の権限を持ったユーザーにのみ特定のAPIを叩けるようにしたい、などです。

そんな時に役立つのがCognitoのIDプールです。Cognitoのユーザープールは認証を、IDプールは認可を担当しています。詳しい概念などは以下を参照してください。

https://dev.classmethod.jp/articles/get-aws-temporary-security-credentials-with-cognito-id-pool-by-aws-cli/

やりたいこと

とあるウェブアプリケーションへサインインしたユーザーごとに叩けるAPIを制限することです。この時、APIのエンドポイントにはAPI Gatewayを利用しています。

対象読者

  • Cognitoを使っている
  • サインインしたユーザーごとに権限を付与したい
    • 特にアクセスするAPIにIAM制限を付与したい

動作イメージ

CognitoのIDプールを使って、ユーザーに応じた一時クレデンシャルキーを発行します。この一時クレデンシャルキーはCognitoのグループに紐づくIAM Roleから選択されます。IAM Roleにはadminとuserが存在し、実行できるエンドポイントのパスが異なります。


adminで/adminへアクセス可能な一時キーを取得しアクセス

adminロールは、ユーザープールのadminグループに紐づいています。加えてadminロールは/adminのエンドポイントのみアクセス可能なため、adminユーザーは/adminのみアクセスできるというわけです。

また、adminとほぼ同じですが、userの認可付与取得の動きは次の通りです。


userで/userへアクセス可能な一時キーを取得しアクセス

デプロイ環境

  • macOS: 13.5
  • Next.js: 13.4
  • AWS SDK: 3.413
  • AWS CDK: 2.96.2

コード

コードは以下のリポジトリにおいてあります。本記事では紹介しないAPI Gatewayの構築なども可能です。

https://github.com/gsy0911/zenn-nextjs-authjs-cognito/tree/v2

インフラ

Cognitoグループに紐づくIAM Roleを作成します。ここで作成した権限をサインインユーザーは引き受けることが可能です。そのため実行可能にしたいAPI Gatewayをリソース単位で指定しておきます。

以下はadminに関するコードのみですが、userに関してもほぼ同様のコードになります。

infrastructure/lib/Cognito.ts(一部)
(前略)
const adminOnlyApiGwResource = (
  accountId: string,
  apigwRestApiId: string,
): string[] => {
  return [
    `arn:aws:execute-api:ap-northeast-1:${accountId}:${apigwRestApiId}/v1/GET/admin`,
  ];
};
(中略)
    // IdentityPoolからassumeできるIAM Role
    const federatedPrincipal = new aws_iam.FederatedPrincipal(
      "cognito-identity.amazonaws.com",
      {
        StringEquals: {
          "cognito-identity.amazonaws.com:aud": params.idPool.idPoolId,
        },
        "ForAnyValue:StringLike": {
          "cognito-identity.amazonaws.com:amr": "authenticated",
        },
      },
      "sts:AssumeRoleWithWebIdentity",
    );

    const adminRole = new aws_iam.Role(this, "admin-role", {
      roleName: `${prefix}-api-gateway-admin-role`,
      assumedBy: federatedPrincipal,
      inlinePolicies: {
        executeApi: new aws_iam.PolicyDocument({
          statements: [
            new aws_iam.PolicyStatement({
              effect: aws_iam.Effect.ALLOW,
              resources: adminOnlyApiGwResource(
                accountId,
                params.idPool.apigwRestApiId,
              ),
              actions: ["execute-api:Invoke"],
            }),
          ],
        }),
      },
    });
(後略)

Cognitoグループを作成し、上で作成したAdmin用のIAM Roleを紐付けます。これでサインインユーザーがadminという名前を含むCognitoグループに属している場合にIDプールを通してこのIAM Roleの一時キーを発行するという設定です。

infrastructure/lib/Cognito.ts(一部)
(前略)
    // CognitoのAdminグループ作成
    new aws_cognito.CfnUserPoolGroup(this, "admin-group", {
      userPoolId: userPool.userPoolId,
      description: "description",
      groupName: "admin",
      precedence: 0,
      roleArn: adminRole.roleArn,
    });
    const adminRMR: RoleMappingRule = {
      claim: "cognito:groups",
      claimValue: "admin",
      mappedRole: adminRole,
      matchType: RoleMappingMatchType.CONTAINS,
    };
(後略)

最後にIDプールを作成します。上で作成したroleMappingRuleを指定しています。

infrastructure/lib/Cognito.ts(一部)
(前略)
    new IdentityPool(this, "identity-pool", {
      identityPoolName: `${prefix}-identity-pool`,
      allowUnauthenticatedIdentities: false,
      authenticatedRole: userRole,
      authenticationProviders: {
        userPools: [
          new UserPoolAuthenticationProvider({
            userPool,
            userPoolClient: privateClient,
          }),
        ],
      },
      roleMappings: [
        {
          providerUrl: IdentityPoolProviderUrl.userPool(
            `cognito-idp.ap-northeast-1.amazonaws.com/${userPool.userPoolId}:${privateClient.userPoolClientId}`,
          ),
          useToken: false,
          mappingKey: "userpool",
          resolveAmbiguousRoles: false,
          rules: [adminRMR, userRMR],
        },
      ],
    });
(後略)

フロントエンド

Next.jsのAPI Routesに以下の2つの関数を作成します。

1つ目の関数は、IdTokenからAWSのcredentialsを取得する関数です。この時に、サインイン中のユーザーが所属しているCognitoグループに紐づくIAM RoleからIDプールを使って一時クレデンシャルキーを取得します。

frontend/zenn/pages/api/v1(一部)
import type { NextApiRequest, NextApiResponse } from "next";
import axios from "axios";
import qs from "query-string";
import {
  GetIdCommandInput,
  GetIdCommand,
  GetCredentialsForIdentityCommandInput,
  GetCredentialsForIdentityCommandOutput,
  GetCredentialsForIdentityCommand,
  CognitoIdentityClient,
} from "@aws-sdk/client-cognito-identity";
import { SignatureV4 } from "@aws-sdk/signature-v4";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { QueryParameterBag } from "@aws-sdk/types";
import { Sha256 } from "@aws-crypto/sha256-universal";

const getCredentialsFromIdToken = async (
  idToken: string,
): Promise<GetCredentialsForIdentityCommandOutput> => {
  const client = new CognitoIdentityClient({
    region: process.env.COGNITO_REGION,
  });
  const loginsKey = `cognito-idp.${process.env.COGNITO_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}`;
  const getIdCommandInput: GetIdCommandInput = {
    AccountId: process.env.ACCOUNT_ID,
    IdentityPoolId: process.env.COGNITO_IDENTITY_POOL_ID,
    Logins: { [loginsKey]: idToken },
  };
  const identityId = await client.send(new GetIdCommand(getIdCommandInput));

  const getCredentialsForIdentityCommandInput: GetCredentialsForIdentityCommandInput =
    {
      IdentityId: identityId.IdentityId,
      Logins: { [loginsKey]: idToken },
    };
  return await client.send(
    new GetCredentialsForIdentityCommand(getCredentialsForIdentityCommandInput),
  );
};
(後略)

2つ目の関数は、取得したCredentialsからSigV4署名ヘッダを作成する関数です。この関数で得られたヘッダを付与してAPI Gatewayのエンドポイントへアクセスします。そうすることで、IAM認証をかけることができます。

frontend/zenn/pages/api/v1(一部)
(前略)
const getSignedHeaders = async (
  credentials: GetCredentialsForIdentityCommandOutput,
  apiUrl: URL,
  query?: QueryParameterBag,
) => {
  const signatureV4 = new SignatureV4({
    service: "execute-api",
    region: process.env.COGNITO_REGION,
    credentials: {
      accessKeyId: credentials.Credentials?.AccessKeyId || "",
      secretAccessKey: credentials.Credentials?.SecretKey || "",
      sessionToken: credentials.Credentials?.SessionToken || "",
    },
    sha256: Sha256,
  });
  console.log(`${apiUrl.hostname}, ${apiUrl.pathname}`);
  const httpRequest = new HttpRequest({
    headers: {
      "content-type": "application/json",
      host: apiUrl.hostname,
    },
    hostname: apiUrl.hostname,
    method: "GET",
    path: apiUrl.pathname,
    query,
  });
  const signedRequest = await signatureV4.sign(httpRequest);
  return signedRequest.headers;
};
(後略)

使い方としては、idTokenを付与してcredentialsを取得し、credentialsと実行したいAPI GatewayのURLを指定するだけです。

呼び出しに必要な箇所だけを抜き出したコードは以下のようになっています。

import axios from "axios";

const credentials = await getCredentialsFromIdToken(
  idToken,
);
const signedHeaders = await getSignedHeaders(
  credentials,
  new URL(`${process.env.BACKEND_API_ENDPOINT}/v1/user`),
);

const BackendApiClient = axios.create({
  baseURL: `${process.env.BACKEND_API_ENDPOINT}/v1`,
});

const options = {
  method: "GET",
  headers: signedHeaders,
  url: "/user",
};
const result = await BackendApiClient(options)

動作確認

Cognitoでadmin@example.com, user@example.comのユーザーをコンソールから作成します。その後に、それぞれのユーザーでログインして各種APIを実行してみます。

adminユーザーから試してみます。わかりにくいですが、/adminへのアクセスは成功し、/userへは失敗しています。


adminで/adminへアクセス:成功


adminで/userへアクセス:失敗

次にuserユーザーを試してみます。わかりにくいですが、/adminへのアクセスは失敗し、/userへは成功しています。


userで/adminへアクセス:失敗


userで/userへアクセス:成功

以下のリポジトリからも、Cognito/API Gatewayをデプロイすれば実行・確認できます。
https://github.com/gsy0911/zenn-nextjs-authjs-cognito

おわりに

CognitoのIDプールを使って、IAM制限されたAPI Gatewayへアクセスしてみました。誰かの参考になれば幸いです。

参考記事

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/cognito-identity/command/GetIdCommand/

GitHubで編集を提案

Discussion