📚

未認証ユーザーおよびソーシャルサインインユーザーによるAPIアクセスの方法

2021/03/13に公開

はじめに

本記事は サインインしたユーザーによる操作を想定するが未認証ユーザーでもある程度の操作が出来るようなAPI(GraphQL) をAWSを使って構築する方法を紹介することが目的です。
サインインにはソーシャル・サインインのみを許可しGoogleを使用します。

未認証のユーザーは読み込みだけ、認証済みのユーザーは読み書きができるように認可を行います。これはエンドポイント単位で個別に認可することも、ワイルドカードを使って全ての読み込みまたは全ての書き込みといった粒度で認可することも可能です。これらの認可はクライアントに一時的なAWS認証情報を与えることで行われています。

認証済み・未認証ユーザーいずれの場合でも、一律で同じ権限ではなくポリシー変数を利用して個別の権限を付与が可能です。(例: 未認証ユーザーAはA用のS3バケットに、BはB用のS3バケットにアクセスができるようにする)

前提

おもに下記のサービスを使用します。よって 使用可能なAWSアカウントおよびGCPプロジェクトを所持している必要 があります。

  • Amazon Cognito User Pool
    • ソーシャルサインインしない場合のユーザーのサインアップおよびサインイン
    • シングルサインオンを実現するためのサインイン用Webページ
    • Google アカウントとのフェデレーション
  • Amazon Cognito Identity Pool
    • Cognito User Pool およびフェデレーションされたユーザーに一時的なAWS認証情報(IAM)の発行
    • 未認証ユーザーに一時的なAWS認証情報(IAM)の発行
  • AWS AppSync
    • IAM認証によるGraphQLエンドポイントの提供
  • Google Cloud OAuth2.0
    • ソーシャルサインインの提供

また、Node.js・Yarnを使用します。端末にasdfanyenvなどでインストールされている必要があります。

リポジトリ

作成したコードは下記のリポジトリで公開しています。ぜひ、記事と合わせて見て頂ければと思います。

https://github.com/intercept6/appsync-with-google-account

構成図

GoogleアカウントのOAuthクライアントを発行

Googleアカウントでログインする際にアプリケーションにGoogleアカウントへのアクセスを認可する際の同意画面を設定します。

新規GCPプロジェクトのAPIとサービスの認証情報から同意画面を構成をクリックします。

User TypeはGCPプロジェクトが所属するドメインのユーザーのみに使わせたい、つまりGoogle Workspaceアカウントでサインインしたい社内システムなどの場合は内部、パブリック公開したいサービスの場合は外部に設定します。今回はパブリック公開を想定して外部に設定します。

アプリ名を任意で設定し、ユーザーサポートメールとデベロッパーの連絡先にメールアドレスを登録します。

発行されたアクセストークンを使ってアクセスできるリソースを定義します。今回はprofileとopenidを設定しました。

このまま次へ、次へと決定して同意画面の作成を完了します。

続いてクライアントIDを発行します。

今回はクライアントはReactによるSPA、RFC6749でいうのuser-agent-based applicationhttps://tools.ietf.org/html/rfc6749#section-2.1なのでウェブ アプリケーションを選択します。ちなみにRFC6749でいうweb applicationでも同様にウェブ アプリケーションを選択すると思われます。

作成したらクライアントIDとクライアントシークレットをメモしておきます。

後ほど 承認済みのリダイレクトURI を追加するので、作成したクライアントのリンクをクリックして編集できる状態にしてブラウザのタブかウインドウを保持しておいてください。

リポジトリの初期セットアップ

npm、

package.jsonを生成します。

yarn init -yp

create-react-appで作成したReactプロジェクトは各パッケージのバージョン指定が厳密です。この問題に対応するためにnohoistpackage.jsonに設定します。合わせてnpm scriptsなども設定します。

package.json

{
  "name": "appsync-with-google",
  "private": true,
  "version": "0.0.1",
  "license": "MIT",
  "engines": {
    "node": "14.15.4"
  },
  "workspaces": {
    "packages": [
      "packages/*"
    ],
    "nohoist": [
      "**/react",
      "**/react/**",
      "**/react-scripts",
      "**/react-scripts/**",
      "**/react-dom",
      "**/react-dom/**",
      "**/jest",
      "**/jest/**",
      "**/eslint",
      "**/eslint/**"
    ]
  },
  "devDependencies": {
  },
  "scripts": {
    "deploy": "yarn workspace infra deploy",
    "codegen": "amplify codegen",
    "start": "yarn workspace frontend start"
  }
}

各Workspaceを格納するディレクトリを作成します。

mkdir -p packages/backend

プロジェクトで必要なCLIツールなどをインストールします。

yarn add -DW \
  aws-cdk \
  @aws-amplify/cli \
  @types/node \
  typescript

コードの初期生成を行います。

cd packages/backend
yarn -s cdk init backend --typescript
cd ../
yarn create react-app frontend --typescript

以上でリポジトリの初期セットアップは完了です。

バックエンドAPIの構築

AWS CDKを使ってインフラとバックエンドAPIを構築しました。先ほど取得したクライアントIDとクライアントシークレットを環境変数に設定します。
私はこのようなプロジェクト固有の環境変数を設定するときはdirenvを設定しています。

export GOOGLE_CLIENT_ID=${YOUR_CLIENT_ID}
export GOOGLE_CLIENT_SECRET=${YOUR_CLIENT_SECRET}

エントリーポイントからGoogle OAuth2.0のクライアント IDとクライアントシークレットを設定します。
何個もCfnOutputしているのは、JSON形式で生成されたAWSリソースのIDのパラメーターをエクスポートして、フロントエンド側でパラメーターをインポートするためです。JSON形式でエクスポートするためのオプションはnpm script側で付与しています。

packages/backend/bin/infra.ts

#!/usr/bin/env node
import "source-map-support/register";
import { App } from "@aws-cdk/core";
import { InfraStack } from "../lib/infra-stack";

const app = new App();
new InfraStack(app, "InfraStack", {
  googleClientId: process.env.GOOGLE_CLIENT_ID!,
  googleClientSecret: process.env.GOOGLE_CLIENT_SECRET!,
});

1つのCloudFormationスタックで全てのAWSリソースを作成します。1つのファイルに書いてしまうと管理しにくいのでCDK Constructとして切り出すことでファイルを分割しています。

packages/backend/lib/infra-stack.ts

import { CfnOutput, Construct, Stack, StackProps } from "@aws-cdk/core";
import { CognitoUserPool } from "./cognito-user-pool";
import { CognitoIdp } from "./cognito-idp";
import { AttributeType, Table } from "@aws-cdk/aws-dynamodb";
import { AppSync } from "./app-sync";

export interface InfraStackProps extends StackProps {
  googleClientId: string;
  googleClientSecret: string;
}

export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props: InfraStackProps) {
    super(scope, id, props);

    const domainPrefix = "appsync-with-google";

    const table = new Table(this, "table", {
      partitionKey: {
        type: AttributeType.STRING,
        name: "id",
      },
    });

    const { userPool, userPoolClient } = new CognitoUserPool(
      this,
      "user-pool",
      {
        googleClientId: props.googleClientId,
        googleClientSecret: props.googleClientSecret,
        domainPrefix,
      }
    );

    const { graphqlApi, graphqlUrl } = new AppSync(this, "app-sync", { table });

    const { idp } = new CognitoIdp(this, "idp", {
      userPool,
      userPoolClient,
      graphqlApi,
    });

    new CfnOutput(this, "graphql-endpoint", { value: graphqlUrl });
    new CfnOutput(this, "user-pool-id", { value: userPool.userPoolId });
    new CfnOutput(this, "user-pool-client-id", {
      value: userPoolClient.userPoolClientId,
    });
    new CfnOutput(this, "identity-pool-id", { value: idp.ref });
    new CfnOutput(this, "region", { value: this.region });
    new CfnOutput(this, "domain", {
      value: `${domainPrefix}.auth.${this.region}.amazoncognito.com`,
    });
  }
}

Cognito User Poolを作成します、UserPoolIdentityProviderUserPoolClientより先に作成する必要はあるがパラメーターを参照していないので普通に設定するとデプロイが失敗してしまう場合があります。解決策としてaddDependencyメソッドで依存関係を明示することで対処します。

packages/backend/lib/cognito-user-pool.ts

import { Construct, RemovalPolicy } from "@aws-cdk/core";
import {
  IUserPool,
  IUserPoolClient,
  OAuthScope,
  ProviderAttribute,
  UserPool,
  UserPoolClientIdentityProvider,
  UserPoolIdentityProviderGoogle,
} from "@aws-cdk/aws-cognito";

export interface CognitoUserPoolProps {
  googleClientId: string;
  googleClientSecret: string;
  domainPrefix: string;
}

export class CognitoUserPool extends Construct {
  public readonly userPool: IUserPool;
  public readonly userPoolClient: IUserPoolClient;

  constructor(scope: Construct, id: string, props: CognitoUserPoolProps) {
    super(scope, id);

    const userPool = new UserPool(this, "user-pool", {
      removalPolicy: RemovalPolicy.DESTROY,
    });
    userPool.addDomain("domain", {
      cognitoDomain: {
        domainPrefix: props.domainPrefix,
      },
    });
    const userPoolClient = userPool.addClient("client", {
      oAuth: {
        flows: { authorizationCodeGrant: true },
        scopes: [
          OAuthScope.PHONE,
          OAuthScope.EMAIL,
          OAuthScope.COGNITO_ADMIN,
          OAuthScope.PROFILE,
        ],
        callbackUrls: ["http://localhost:3000/"],
        logoutUrls: ["http://localhost:3000/"],
      },
      supportedIdentityProviders: [UserPoolClientIdentityProvider.GOOGLE],
    });

    const googleIdp = new UserPoolIdentityProviderGoogle(
      this,
      "google-provider",
      {
        userPool,
        clientId: props.googleClientId,
        clientSecret: props.googleClientSecret,
        scopes: ["openid", "email", "profile"],
        attributeMapping: {
          email: ProviderAttribute.GOOGLE_EMAIL,
          profilePicture: ProviderAttribute.GOOGLE_PICTURE,
          nickname: ProviderAttribute.GOOGLE_NAME,
        },
      }
    );
    if (googleIdp) {
      userPoolClient.node.addDependency(googleIdp);
    }

    this.userPool = userPool;
    this.userPoolClient = userPoolClient;
  }
}

GraphQLのスキーマを定義します。簡素にQueryとMutationが行えれば十分なので最低限で定義しました。

schema.graphql

input Message {
    message: String!
}

type MessageTemplate {
    id: String!
    message: String!
}

type Query {
    listMessages: [MessageTemplate]!
}

type Mutation {
    addMessage(input: Message): MessageTemplate!
}

スキーマをインポートしてAppSyncを作成します。クライアントはCognito Identity Poolから受け取った一時的なAWS認証情報を使いリクエストを行うのでIAM認証に設定します。
また今回はデモアプリなのでDynamoDBに対してScan操作を行っていますが、DynamoDBに対して極力Scanは行わないべきです、このコードをプロダクトにそのまま使わないでください。

package/infra/app-sync.ts

import { Construct } from "@aws-cdk/core";
import {
  AuthorizationType,
  FieldLogLevel,
  GraphqlApi,
  IGraphqlApi,
  MappingTemplate,
  PrimaryKey,
  Schema,
  Values,
} from "@aws-cdk/aws-appsync";
import { resolve } from "path";
import { ITable } from "@aws-cdk/aws-dynamodb";

export interface AppSyncProps {
  table: ITable;
}

export class AppSync extends Construct {
  public readonly graphqlApi: IGraphqlApi;
  public readonly graphqlUrl: string;

  constructor(scope: Construct, id: string, props: AppSyncProps) {
    super(scope, id);

    const graphqlApi = new GraphqlApi(this, "graphql", {
      name: "graphql",
      logConfig: {
        excludeVerboseContent: true,
        fieldLogLevel: FieldLogLevel.ALL,
      },
      schema: Schema.fromAsset(resolve("../../schema.graphql")),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: AuthorizationType.IAM,
        },
      },
      xrayEnabled: true,
    });

    const dynamoDbDataSource = graphqlApi.addDynamoDbDataSource(
      "DdbDataSource",
      props.table
    );
    dynamoDbDataSource.createResolver({
      typeName: "Mutation",
      fieldName: "addMessage",
      requestMappingTemplate: MappingTemplate.dynamoDbPutItem(
        PrimaryKey.partition("id").auto(),
        Values.projecting("input")
      ),
      responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
    });
    dynamoDbDataSource.createResolver({
      typeName: "Query",
      fieldName: "listMessages",
      requestMappingTemplate: MappingTemplate.dynamoDbScanTable(),
      responseMappingTemplate: MappingTemplate.dynamoDbResultList(),
    });

    this.graphqlApi = graphqlApi;
    // IGraphApi doesn't have graphqlUrl method😭
    this.graphqlUrl = graphqlApi.graphqlUrl;
  }
}

Cognito Identity Poolを作成し未承認ユーザーおよび承認ユーザーに割り当てるIAMポリシーを定義します。今回は未承認ユーザーはクエリーだけ、承認ユーザーはクエリーとミューテーションができるように設定しています。

下記のドキュメントを参考にすることでアクセス対象をS3バケットのキーやDynamoDBテーブルのパーティションキー単位で詳細に制御ができます。
IAM ロール - Amazon Cognito

バックエンド側、主にLambdaに権限を持たせずフロント側に権限を持たせるのは少なくともAWSに置いてはまだ未開拓な領域の認識です。安定を取るならばIAMポリシーではどのクエリー・ミューテーション・サブスクリプションにアクセスを認可するまでにしておいて詳細な認可はLambdaやマッピングテンプレートで行うのが良いでしょう。

packages/backend/cognito-idp.ts

import { Construct, Stack } from "@aws-cdk/core";
import {
  CfnIdentityPool,
  CfnIdentityPoolRoleAttachment,
  IUserPool,
  IUserPoolClient,
} from "@aws-cdk/aws-cognito";
import {
  Effect,
  WebIdentityPrincipal,
  PolicyDocument,
  PolicyStatement,
  Role,
} from "@aws-cdk/aws-iam";
import { IGraphqlApi } from "@aws-cdk/aws-appsync";

export interface CognitoIdpProps {
  userPool: IUserPool;
  userPoolClient: IUserPoolClient;
  authenticatedPolicyDocument?: PolicyDocument;
  unauthenticatedPolicyDocument?: PolicyDocument;
  graphqlApi: IGraphqlApi;
}

export class CognitoIdp extends Construct {
  public readonly idp: CfnIdentityPool;

  constructor(scope: Construct, id: string, props: CognitoIdpProps) {
    super(scope, id);
    const authenticatedPolicyDocument =
      props.authenticatedPolicyDocument ??
      new PolicyDocument({
        statements: [
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: ["cognito-sync:*", "cognito-identity:*"],
            resources: ["*"],
          }),
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: ["appsync:GraphQL"],
            resources: [
              `arn:aws:appsync:${Stack.of(this).region}:${
                Stack.of(this).account
              }:apis/${props.graphqlApi.apiId}/types/Query/fields/*`,
              `arn:aws:appsync:${Stack.of(this).region}:${
                Stack.of(this).account
              }:apis/${props.graphqlApi.apiId}/types/Mutation/fields/*`,
            ],
          }),
        ],
      });

    const unauthenticatedPolicyDocument =
      props.unauthenticatedPolicyDocument ??
      new PolicyDocument({
        statements: [
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: ["cognito-sync:*"],
            resources: ["*"],
          }),
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: ["appsync:GraphQL"],
            resources: [
              `arn:aws:appsync:${Stack.of(this).region}:${
                Stack.of(this).account
              }:apis/${props.graphqlApi.apiId}/types/Query/fields/*`,
            ],
          }),
        ],
      });

    const idp = new CfnIdentityPool(this, "idp", {
      cognitoIdentityProviders: [
        {
          clientId: props.userPoolClient.userPoolClientId,
          providerName: `cognito-idp.ap-northeast-1.amazonaws.com/${props.userPool.userPoolId}`,
          serverSideTokenCheck: true,
        },
      ],
      allowUnauthenticatedIdentities: true,
    });

    const authenticated = new Role(this, "authenticated", {
      assumedBy: new WebIdentityPrincipal("cognito-identity.amazonaws.com", {
        StringEquals: {
          "cognito-identity.amazonaws.com:aud": idp.ref,
        },
        "ForAnyValue:StringLike": {
          "cognito-identity.amazonaws.com:amr": "authenticated",
        },
      }),
      inlinePolicies: { policy: authenticatedPolicyDocument },
    });
    const unauthenticated = new Role(this, "unauthenticated", {
      assumedBy: new WebIdentityPrincipal("cognito-identity.amazonaws.com", {
        StringEquals: { "cognito-identity.amazonaws.com:aud": idp.ref },
        "ForAnyValue:StringLike": {
          "cognito-identity.amazonaws.com:amr": "unauthenticated",
        },
      }),
      inlinePolicies: { policy: unauthenticatedPolicyDocument },
    });

    new CfnIdentityPoolRoleAttachment(this, "role-attachment", {
      identityPoolId: idp.ref,
      roles: {
        authenticated: authenticated.roleArn,
        unauthenticated: unauthenticated.roleArn,
      },
    });

    this.idp = idp;
  }
}

デプロイします。今回のプロジェクトはyarn workspacesを使っています。ルートディレクトリから下記のコマンドでデプロイ出来るように設定しています。

yarn deploy

OAuthの承認済みリダイレクトURIの設定

デプロイが完了したらAWSマネジメントコンソールを開いて、作成されたCognito UserPoolのアプリクライアントの設定を開きます。
ホストされたUIを起動をクリックしてCognito Hosted UIを開きます。その後Googleアカウントに接続をクリックします。

すると、承認エラーが発生します。この黒塗りされた部分に表示されるURIをコピーして、開いたままにしてたGCPのOAuthクライアントの承認済みリダイレクトURIに追加して保存します。

フロントエンドの構築

Amplify CLIを使用してGraphQLスキーマからコードができるように設定を行います。。

cd ${PROJECT_ROOT}
yarn -s amplify codegen add

以降、スキーマの変更が発生した場合は、yarn codegenで再生成が行えます。

AWS CDKでのデプロイ時に生成したoutputs.jsonからデータをインポートしてAmplifyを設定します。一部はインポートせずに手動で設定しています。今回はそこまでやるのが面倒だったので手動にしましたが頑張れば全部のパラメーターをインポートさせることはできそうです。

packages/frontend/index.tsx

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import { App } from "./App";
import reportWebVitals from "./reportWebVitals";
import Amplify from "aws-amplify";
import { InfraStack } from "./cdk/outputs.json";

Amplify.configure({
  aws_project_region: InfraStack.region,
  aws_appsync_graphqlEndpoint: InfraStack.graphqlendpoint,
  aws_appsync_region: InfraStack.region,
  aws_appsync_authenticationType: "AWS_IAM",
  aws_cognito_identity_pool_id: InfraStack.identitypoolid,
  aws_cognito_region: InfraStack.region,
  aws_user_pools_id: InfraStack.userpoolid,
  aws_user_pools_web_client_id: InfraStack.userpoolclientid,
  oauth: {
    domain: InfraStack.domain,
    scope: [
      "phone",
      "email",
      "openid",
      "profile",
      "aws.cognito.signin.user.admin",
    ],
    redirectSignIn: "http://localhost:3000/",
    redirectSignOut: "http://localhost:3000/",
    responseType: "code",
  },
  federationTarget: "COGNITO_USER_POOLS",
});

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

サインイン、サインアウト、ユーザー情報取得、クエリー、ミューテーションを行うボタンを作ります。
サインインしているか、していないかで画面を切り替えています。(わかりにくいですが)
コンソールログを確認しながらボタンを押せば、Amplify経由でAPIを叩くとどういった情報が受け取れるのか、未認証ユーザーではMutationが失敗することが確認できます。

packages/frontend/App.tsx

import React from "react";
import "./App.css";
import { API, Auth, graphqlOperation, Hub } from "aws-amplify";
import { CognitoHostedUIIdentityProvider } from "@aws-amplify/auth";
import * as queries from "../src/graphql/queries";
import * as mutations from "../src/graphql/mutations";
import { CognitoUserInterface } from "@aws-amplify/ui-components";

export interface User extends CognitoUserInterface {
  attributes: {
    email: string;
    nickname: string;
    picture: string;
  };
}

async function checkUser() {
  const user = await Auth.currentAuthenticatedUser().catch((err) => err);
  if (user instanceof Error) {
    console.log("user is not login");
  } else {
    console.log("user: ", user);
  }

  const userInfo = await Auth.currentUserInfo();
  console.log("userinfo: ", userInfo);

  const creds = await Auth.currentCredentials();
  console.log("creds: ", creds);
}

async function query() {
  const list = await API.graphql(graphqlOperation(queries.listMessages));
  console.log(list);
}

async function mutation() {
  const result = await API.graphql(
    graphqlOperation(mutations.addMessage, {
      input: {
        message: "メッセージ",
      },
    })
  );
  console.log(result);
}

export const App: React.FC = () => {
  const [user, setUser] = React.useState<User | null>(null);

  React.useEffect(() => {
    Hub.listen("auth", ({ payload: { event, data } }) => {
      switch (event) {
        case "signIn":
        case "cognitoHostedUI":
          getUser().then((userData) => setUser(userData));
          break;
        case "signOut":
          setUser(null);
          break;
        case "signIn_failure":
        case "cognitoHostedUI_failure":
          console.log("Sign in failure", data);
          break;
      }
    });

    getUser().then((userData) => setUser(userData));
  }, []);

  function getUser() {
    return Auth.currentAuthenticatedUser()
      .then((userData) => userData)
      .catch(() => console.log("Not signed in"));
  }

  if (user == null) {
    return (
      <>
        <p>User: None</p>
        <button onClick={checkUser}>Check User</button>
        <button onClick={query}>Get Query</button>
        <button onClick={mutation}>Set Mutation(don't work)</button>
        <br />
        <button
          onClick={() => {
            Auth.federatedSignIn();
          }}
        >
          Sign in
        </button>
        <button
          onClick={() => {
            Auth.federatedSignIn({
              provider: CognitoHostedUIIdentityProvider.Google,
            });
          }}
        >
          Sign in with Google
        </button>
      </>
    );
  } else {
    return (
      <>
        <p>nickname: {user.attributes.nickname}</p>
        <button onClick={checkUser}>Check User</button>
        <button onClick={query}>Get Query</button>
        <button onClick={mutation}>Set Mutation</button>
        <br />
        <button
          onClick={() => {
            Auth.signOut();
          }}
        >
          Sign out
        </button>
      </>
    );
  }
};

動作確認

フロントエンドを起動します。

yarn start

http://localhost:3000にアクセスします。

最初に未認証状態で動作を確認します。コンソールを表示してからCheck User → Get Query → Set Mutationをクリックします。

続いてSign in with Googleをクリックしてログインしてから同様にCheck User → Get Query → Set Mutationをクリックします。

未認証ユーザーではMutationが実行できず、認証ユーザーではMutationが実行できれば動作確認完了です。

あとがき

Cognitoワカンネーって気づいてから、かれこれ2年ぐらいたちますが未だに完全に理解はできないです。ですがIdentity Poolを使う構成を試してちょっとは理解できた気がしました。
以上です。

Discussion