Reactでスクリプトタグを含んだHTMLを挿入する(SSG/SSR対応)

いわゆるJAMstackを用いたWebメディアなどでは、コンテンツの構成が記事ごとに少しずつ違うけれどもマークダウンでは表現力が制限されるため、本文などコンテンツの一部をHTMLで記述して表示したいという状況はそれなりにあると思います。 そのような状況で、外部サービスから提供されるスクリプトタグを含んだコンテンツを表示したい、という要件に対応したものです。

dangerouslySetInnerHTMLの制限

ReactにはdangerouslySetInnerHTMLを用いることで、任意のDOM要素の子要素としてHTMLを挿入することができます。 ただし、この機能は内部的にinnerHTML用いています。innerHTMLXSS攻撃に対する対策として<script>タグを実行しません。(ただし、インベントハンドラとしてスクリプトを実行することは可能です) そのため、スクリプトタグを含むHTMLはdangerouslySetInnerHTMLで設定することはできますが、そのスクリプトは実行されないということになります。

appendChildcreateContextualFragmentの利用

WebAPIのappendChildメソッドは特定のノードに対して子ノードを追加するメソッドです。 このメソッドによって追加されたノードは通常のノードと同様に実行されるため、追加されたスクリプトタグも実行されます。 文字列として渡されたHTMLから要素を生成する際は、createContextualFragmentメソッドを用います。これはHTMLタグを含む文字列から、指定された範囲の開始点を起点とするドキュメントフラグメントを生成するメソッドです。

Reactでの擬似的なコードは以下となります。

const HTMLComponent = ({ htmlString }) => {
  const divRef = useRef();
  
  useLayoutEffect(() => {
    if (!divRef.current) {
      return;
    }
    
    const fragment = document
      .createRange()
      .createContextualFragment(htmlString);
    
    divRef.current.appendChild(fragment);
  }, [htmlString]);
  
  return <div ref={divRef} />;
};

ただし、上記のコードではいくつか対応できないパターンがあります。

HubSpotの埋め込みコードのような外部ファイルの読み込みとそれに依存するインラインスクリプトがある場合

<script charset="utf-8" type="text/javascript" src="//js.hsforms.net/forms/shell.js"></script>
<script>
  hbspt.forms.create({
    portalId: "xxxxxx",
    formId: "xxxxxx"
  });
</script>

appendChildによって追加すると、2つのスクリプトタグが同時に実行されるため、2番目のインラインスクリプトhbsptグローバル変数として存在しない状態で実行されることとなります。 この問題は、外部ファイルを読み込むスクリプトタグを先に追加・実行し、それが完了したのちに残りの要素を追加することで解決することが可能です。

const HTMLComponent = ({ htmlString }) => {
  const divRef = useRef();
  
  useLayoutEffect(() => {
    if (!divRef.current) {
      return;
    }
    
    (async () => {
      const scriptStrings = htmlString.match(
        /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
      );
      
      let updatedHtmlString = htmlString;
      await scriptStrings.reduce(async (acc, current) => {
        await acc;

        const scriptFragment = document
          .createRange()
          .createContextualFragment(current);
        const scriptElement = scriptFragment.querySelector('script');

        if (scriptElement.src === '') {
          return Promise.resolve();
        }
  
        updatedHtmlString = updatedHtmlString.replace(current, '');
  
        if (
          Array.from(document.querySelectorAll('script')).some(
            se => se.src === scriptElement.src
          )
        ) {
          return Promise.resolve();
        }
    
          return new Promise(resolve => {
          scriptElement.addEventListener('load', () => {
            resolve();
          });

          document.head.appendChild(scriptElement);
        });
      }, Promise.resolve());
      
      const fragment = document
        .createRange()
        .createContextualFragment(updatedHtmlString);
      
      divRef.current.appendChild(fragment);
    })();
  }, [htmlString]);
  
  return <div ref={divRef} />;
};

外部ファイルを読み込むスクリプトタグの場合、ヘッダに追加して元のHTMLタグから取り除きます。 もし、すでに同じソースを読み込むスクリプトタグが存在する場合、多重の読み込みを防止するために処理をスキップします。 スクリプトタグのロードが完了した後に残りのHTMLを挿入することで、外部ファイルに依存するインラインスクリプトを問題なく実行することができます。

SSG/SSRでコンテンツがレンダリングされない

useLayoutEffectはサーバサイドでは実行されないため、SSR/SSGを行った際にレンダリングされていない状態となります。 この問題に対しては、挿入する要素に対して初期値を設定することで対応します。

const HTMLComponent = ({ htmlString }) => {
  const divRef = useRef();
  
  const initialHTMLString = useMemo(() => {
    const scriptStrings = htmlString.match(
      /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
    );
    
    return scriptStrings.reduce((acc, current) => {
      return acc.replace(current, '');
    }, htmlString);
  }, [htmlString]);
  
  useLayoutEffect(() => {
    if (!divRef.current) {
      return;
    }
    
    (async () => {
      const scriptStrings = htmlString.match(
        /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi
      );
      
      let updatedHtmlString = htmlString;
      await scriptStrings.reduce(async (acc, current) => {
        await acc;
  
        const scriptFragment = document
          .createRange()
          .createContextualFragment(current);
        const scriptElement = scriptFragment.querySelector('script');
  
        if (scriptElement.src === '') {
          return Promise.resolve();
        }

        updatedHtmlString = updatedHtmlString.replace(current, '');
        
        if (
          Array.from(document.querySelectorAll('script')).some(
            se => se.src === scriptElement.src
          )
        ) {
          return Promise.resolve();
        }

        return new Promise(resolve => {
          scriptElement.addEventListener('load', () => {
            resolve();
          });

          document.head.appendChild(scriptElement);
        });
      }, Promise.resolve());
      
      const fragment = document
        .createRange()
        .createContextualFragment(updatedHtmlString);
      
      divRef.current.innerHTML = '';
      divRef.current.appendChild(fragment);
    })();
  }, [htmlString]);
  
  return (
    <div
      ref={divRef}
      dangerouslySetInnerHTML={{
        __html: initialHTMLString,
      }}
    />
  );
};

htmlStringからスクリプトタグを除いたinitialHTMLStringを設定しているのは、プリレンダリングされたHTMLの読み込み時にインラインスクリプトが実行されることを防ぐためです。 これを行わないと、例えばHubSpotのインラインスクリプトが含まれている場合、プリレンダリングされたHTML読み込み時にスクリプトが実行されフォームが追加、アプリの起動とハイドレーションのあとのuseLayoutEffectの処理によって、一度フォームが消えた後に再び追加されるという挙動となります。

まとめ

  • dangerouslySetInnerHTMLではスクリプトタグを含んだHTMLを挿入することはできるが、innerHTMLの仕様によりそのスクリプトタグは実行されない。
  • appendChildcreateContextualFragmentを利用することで、スクリプトタグを含んだHTMLを挿入することができる。
  • 外部ファイルを読み込むスクリプトタグとインラインスクリプトの両方が存在する場合、外部ファイルの読み込みが行われた後にHTMLを挿入する必要がある。
  • SSG/SSRを利用する場合は、スクリプトタグを除いたHTMLを初期値として設定する。

この実装では任意のスクリプトの実行が可能となるため、基本的に信頼できる入力に対してのみ適用するようにしましょう。 今回はデータの入力が、特定のコンテンツ管理者だけがログインできるContentfulからのみであるという状況であったためにこの実装を採用しています。 不特定多数のユーザが入力、閲覧するような要件の場合は適切なサニタイズを確実に。

Auth0のauth0-spa-jsを使っていい感じにアクセストークンを取得する

背景

auth0/auth0-spa-jsはAuth0が提供するSPA用のライブラリです。
このライブラリを用いることで、複雑なOpenID Connect、OAuth 2.0の処理を簡易かつ安全にSPAに組み込むことができます。

しかし、最近は3rd Party Cookieに対する制約が強くなり、特にSafariではIntelligent Tracking Prevention(ITP)により、getTokenSilently()というインタラクションを伴わないアクセストークンの取得が不可能となっています。
では、そのようなブラウザではもう一つあるアクセストークンを取得するメソッド、getTokenWithPopup()を使えば問題ないかというと、こちらにはポップアップに対する制約が影響してきます。
iOS Safariは、デフォルトでポップアップブロックが有効になっており、かつ個別のサイトごとにポップアップを許可することが不可能となっています。

iOS Safariのシェアは大きく、特にメディア系のサイトではその傾向はより強くなります。
サイトにアクセスしたユーザに対して、利用するためにはiOS Safariのポップアップブロック機能を無効にする必要があると通知し、実際に機能を無効にしてもらう、という方式は現実的ではありません。

そういった制約を踏まえ、ユーザに対する負荷や影響を可能な限り低減するアクセストークンを取得する実装を解説します。

Auth0の設定

Renew Tokens When Using SafariRefresh Token Rotationを参考に、アプリケーションのリフレッシュトークンローテーションを有効にします。

アクセストークン取得関数

const getAccessTokenFunc = async (options, client) => {
  try {
    return await client.getTokenSilently(options);
  } catch (error) {
    switch (error.error) {
      case 'consent_required':
        return await client.getTokenWithPopup(options);
      case 'login_required':
        await client.loginWithRedirect({
          ...options,
          cacheLocation: 'localstorage',
          useRefreshTokens: true,
          appState: {
            targetUrl: window.location.href.replace(
              window.location.origin,
              ''
            ),
          },
        });
        return;
      default:
        throw error;
    }
  }
};

引数

optionsGetTokenSilentlyOptionsclientAuth0Clientインスタンスです。

client.getTokenSilently()

Auth0ClientgetTokenSilently()をまず最初に実行します。
内部的にはignoreCacheを指定しない場合、最初にキャッシュの確認が行われます。
アクセストークンが存在する場合はそのアクセストークンが返却され、リフレッシュトークンが存在すればリフレッシュトークンによるアクセストークンの取得を、キャッシュ自体が存在しない場合はiframeを用いたアクセストークンの取得を試みます。

case 'consent_required':

このエラーは、ユーザに対してAPIのアクセス権をクライアントに与えるかどうか確認を求める必要があることを示すものです。
Auth0のAPIの設定でAllow Skipping User Consentが有効で、かつAuth0で管理しているクライアントである場合はユーザ確認はスキップされます。
localhostまたはAuth0のDynamic Application (Client) Registrationを通じて登録されたThird-Party Appである場合、この確認が要求されることがあります。Twitterなどのアプリ連携の確認画面がこれに相当します。

case 'login_required':

3rd Party Cookieがブロックされている場合、そのリクエストにはクッキーが含まれないため、Auth0は認証が完了していない状態のリクエストとしてlogin_requiredのエラーを返します。
SafariでITPが有効な状態では、Auth0は常にこのレスポンスを返します。

loginWithRedirect()

auth0-spa-jspgetTokenWithPopup()は、内部でloginWithPopup()を呼び出しています。
これはAuth0におけるログイン処理が、 OAuth 2.0におけるアクセストークンを発行するための処理を拡張したOpenID Connectの処理に準拠していることを意味しています。
loginWithPopup()はaudienceを指定しない呼び出しではAuthorization APIへのアクセストークンをIDトークンとともに返します。
それであれば、loginWithPopup()と同じくアクセストークンとIDトークンを取得することができるloginWithRedirect()のaudienceを目的とするAPIの値にして呼び出すことで、ログイン処理ともに任意のAPIのアクセストークンを取得することができるはずです。
もちろん、ログイン処理と同様にAuth0Clientの初期化時にhandleRedirectCallback()を実行することが必要ですが、その処理の中でcacheLocationの指定により、アクセストークンを含むAPIのレスポンスが、localstorageに保存されます。

実行時の挙動

Auth0Clientインスタンスを通して、ログイン処理が完了している状態では以下の挙動となります。

  • 3rd Party Cookieが完全にブロックされていないブラウザ(Chromeなど)で、Third-Party Appやlocalhostではないアプリの場合、getTokenSilently()によりユーザに特別な操作や画面遷移を行うことなくアクセストークンを取得することができる
  • 3rd Party Cookieが完全にブロックされていないブラウザでThird-Party Appやlocalhostの場合、初回や一定時間が経過したアクセスの際に、アプリケーションがAPIにアクセスする旨を確認するダイアログが表示される場合がある
  • 3rd Party Cookieが完全にブロックされているブラウザでは、まずキャッシュを確認し、そのアクセストークンが有効でない場合、リダイレクトによるアクセストークンの取得とlocalstorageへの格納が行われる
    • アクセストークンの取得タイミングを任意に指定する処理を実装することで、iOS Safariであってもユーザに対する負荷や影響を低減するUIを検討する余地が生まれる

まとめ

  • auth0/auth0-spa-jsはSPAにAuth0の認証を追加する際に、簡易かつ安全な高レベルなAPIを提供するため、基本的に利用すべき
  • 最近のブラウザは3rd Party Cookieやポップアップに対する制約が強化されているため、auth0/auth-spa-jsが提供するAPIをそのまま使うだけでは、ユーザへの負荷や特定の設定を強いることなくアクセストークンを取得することが難しい
  • OpenID ConnectおよびOAuth 2.0の仕組みおよび、auth0/auth0-spa-jsの内部での処理を理解することで、直接Auth0のAuthentication APIを呼び出す方法よりもセキュリティを担保しつつ、ユーザへの負荷や影響を低減する手法が存在する

さて、ここまで書いておいてなんなのですが、単一サービスや特定のブランドの認証基盤としてAuth0を利用する場合、Custom Domainsの機能、つまりAuth0のドメインをクラインとのドメインサブドメインとすることで3rd Party Cookieの問題を解決することができます。
もちろん、ドメインの設計は複数サービスにまたがるブランディングやユーザに対する認知にも多分に影響しますし、開発環境やステージング環境にもカスタムドメインを設定する必要があるなど、主にコスト面での影響も大きくなるのですが。 こういった技術的な制約なども認識しつつ、適切な選択ができればと思います。

AWSで開発するサーバレスAPIバックエンド

Serverless Meetup Tokyo #14で発表しました。

www.slideshare.net


AWSで開発するサーバレスAPIバックエンド

Serverless Meetup Tokyo #14

2019.09.19. 三宅 暁


自己紹介

  • 三宅 暁 (ミヤケ アキラ)
  • フリーランス(8月から)
  • フロントエンド
    • SPAをJS / HTML / CSSまるごと設計・実装できる程度の能力
    • Reactが得意、あとVueとAngular
  • バックエンド
    • サーバレス中心のAWS
    • Node.js
    • API設計
    • OpenIDまわり

自己紹介


主なサーバレス関連のライブラリ

  • serverless-import-swagger
    • OpenAPI形式のAPI定義からserverless.ymlのhandlerの定義を生成するツール
    • 最近のバージョンアップで設定まわりが洗練されました
  • node-lambda-utilities
    • lambdaのhandlerのテストをしやすくするツール
    • contextやcallbackのモックの生成とhandlerの実行

今日話すこと

  • AppSync + DynamoDB + Cognitoを使って、GraphQLのAPIバックエンドを作る
    • ノンコーディング
    • デプロイが簡単にできて
    • なるべくベンダーロックインしないように

GraphQL or RESTful ?


プロジェクトのサンプル

https://github.com/AKIRA-MIYAKE/aws-serverless-sample


ベンダーロックインしないために

  • Amplifyは使わない
    • フロントエンドがAWSにロックインしないように
    • Apollo Clientを利用
  • Cognitoを用いるが、OpenID Providerとして利用する

APIの仕様

  • メッセージの作成・取得・一覧・削除
  • 取得・一覧はログインしなくても呼び出せる
  • 一覧は作成日での降順
  • 作成・削除はログインが必要
  • 自分が作成したメッセージのみ削除できる

schema.graphql

type Query @aws_api_key @aws_oidc {
    readMessage(id: ID!): Message
    listMessage(limit: Int, nextToken: String): MessageCollection!
}

type Mutation @aws_oidc {
    createMessage(input: CreateMessageInput!): Message!
    deleteMessage(input: DeleteMessageInput!): Message
}
  • 基本の承認モードをAPI_KEYに指定し、OPENID_CONNECTを追加
  • mutationはOPENID_CONNECTのみ許可し、認証状態を必須に

schema.graphql

type Message @aws_api_key @aws_oidc {
    id: ID!
    value: String!
    createdBy: String!
    createdAt: String!
}
  • createdByはCognitoが発行するsubを用いる

schema.graphql

type MessageKeyValue @aws_api_key @aws_oidc {
    id: ID!
    key: String!
    value: String!
    message: Message
}

type MessageCollection @aws_api_key @aws_oidc {
    items: [MessageKeyValue!]!
    nextToken: String
    scannedCount: Int
}

Mutation.createMessage.request.vtl

#set($id = $util.autoId())
#set($now = $util.time.nowISO8601())
#set($sub = $context.identity.sub)

#set($items = [...])

{
  "version" : "2018-05-29",
  "operation" : "BatchPutItem",
  "tables" : {
    "${DynamoDB}": $utils.toJson($items)
  }
}
  • BatchPutItemを用いてクエリ用のレコードを同時に登録

Mutation.createMessage.request.vtl

#set($items = [
  {
    "id": $util.dynamodb.toString("${id}"),
    "key": $util.dynamodb.toString("message"),
    "value": $util.dynamodb.toString("${context.arguments.input.value}"),
    "createdBy": $util.dynamodb.toString("${sub}"),
    "createdAt": $util.dynamodb.toString("${now}")
  },
  {
    "id": $util.dynamodb.toString("${id}"),
    "key": $util.dynamodb.toString("message/createdBy"),
    "value": $util.dynamodb.toString("${sub}")
  },
  {
    "id": $util.dynamodb.toString("${id}"),
    "key": $util.dynamodb.toString("message/createdAt"),
    "value": $util.dynamodb.toString("${now}")
  }
])

Query.listMessage.request.vtl

{
  "version": "2017-02-28",
  "operation": "Query",
  "query": {
    "expression": "#key = :key",
    "expressionValues": {
      ":key": $util.dynamodb.toStringJson("message/createdAt")
    },
    "expressionNames": {
      "#key": "key"
    }
  },
  "index": "key-value-index",
  "limit": #if($context.arguments.limit) $context.arguments.limit #else 10 #end,
  "nextToken": #if($context.arguments.nextToken) "${context.arguments.nextToken}" #else null #end,
  "scanIndexForward": false
}
  • key-value-indexをスキャンし、valueに入っているcreatedAtの値でのソートを実現
  • limitとnextTokenにデフォルト値をセット

MessageKeyValue.message.request.vtl

{
  "version" : "2017-02-28",
  "operation" : "GetItem",
  "key" : {
    "id": $util.dynamodb.toStringJson("${context.source.id}"),
    "key": $util.dynamodb.toStringJson("message")
  }
}
  • listMessageが返す、MessageKeyValueのmessageフィールドのマッピング
  • 1件ごとにGetItemが実行されるため、1+Nに

Mutation.deleteMessage.request.vtl

{
  "version": "2017-02-28",
  "operation": "DeleteItem",
  "key": {
    "id": $util.dynamodb.toStringJson("${context.arguments.input.id}"),
    "key": $util.dynamodb.toStringJson("message")
  },
  "condition": {
    "expression": "createdBy = :createdBy",
    "expressionValues": {
      ":createdBy": $util.dynamodb.toStringJson("${context.identity.sub}")
    }
  }
}
  • BatchDeleteではconditionが使えないため、メインのレコードのみの削除
  • パイプラインリゾルバの利用がおそらく適切

serverless-appsync-plugin

https://github.com/sid88in/serverless-appsync-plugin

  • AppSyncの設定を簡潔に定義してデプロイできるServerless Frameworkのプラグイン
  • mappingTemplates(リゾルバ)は {type}.{field}.request.vtl{type}.{field}.respose.vtl のファイルをデフォルトで認識
  • vtlファイルで環境変数を設定できる
  • CloudFormationの出力を利用可能

serverless.yml

custom:
  appSync:
    substitutions:
      DynamoDB: sls-sample-${env:ENV}
  • Batch系処理ではvtlで直接テーブル名の指定が必要
  • ステージごとにテーブル名が異なるため、環境変数として定義 
  • vtlでは $DynamoDB のように、変数としてアクセス可能

serverless.yml

custom:
  appSync:
    additionalAuthenticationProviders:
      - authenticationType: OPENID_CONNECT
        openIdConnectConfig:
          issuer: !Join
            - ''
            - - https://cognito-idp.ap-northeast-1.amazonaws.com/
              - !Ref UserPool
  • CognitoをOPENID_CONNECT承認モードとして利用するための設定
  • issuerのURLがUserPoolのIDを含んだものとなるので、resourcesで出力された値を参照

Cognito UserPoolのアプリの統合の設定

  • アプリクライアントはCloudFormationで作れるが、アプリの統合は未サポート
  • この箇所だけコンソールかCLIからの設定が必要

アプリの統合 / アプリクライアントの設定

  • 「有効な ID プロバイダ」
    • 「Cognito User Pool」を選択
  • 「サインインとサインアウトの URL」
    • 「コールバック URL」に http://localhost:3000/authentication/callback を設定
      • @axa-fr/react-oidc-contextのデフォルト値
    • 「サインアウト URL」に http://localhost:3000 を設定
    • 「OAuth 2.0」
      • 「許可されている OAuth フロー」の「Authorization code grant」を選択
      • 「許可されている OAuth スコープ」の「phone」「email」「openid」「profile」を選択
        • openid」があれば動作はするはず

アプリの統合 / ドメイン


フロントエンド

  • 動作確認のためのシンプルなReactアプリ
    • ログイン / ログアウト
    • listMessageの結果の表示
    • createMessageの実行
    • deleteMessageの実行

ログイン / ログアウト (AppHeader)

const AppHeader = () => {
  const { login, logout, oidcUser } = useReactOidc();

  return (
    <h1>AWS SLS Sample</h1>
      {
        oidcUser
          ? <div><button onClick={logout}>Logout</button></div>
          : <div><button onClick={login}>Login</button></div>
      }
      {oidcUser && (
        <span>sub(generated by cognito): {oidcUser.profile.sub}</span>
      )}
  );
};
  • @axa-fr/react-oidc-contextのフックを利用
  • Cognitoのページにリダイレクトされ、ユーザ登録まで処理してくれる

認証方式の切り替え (ApolloContext)

const client = useMemo(
    () => {
      if (oidcUser && oidcUser.id_token) {
        return new ApolloClient({
          uri: process.env.REACT_APP_GRAPHQL_URL,
          request: operation => {
            operation.setContext({ headers: { Authorization: `${oidcUser.id_token}` },
            });
          },
        });
      } else {
        return new ApolloClient({
          uri: process.env.REACT_APP_GRAPHQL_URL,
          request: operation => {}
            operation.setContext({ headers: { 'x-api-key': process.env.REACT_APP_GRAPHQL_API_KEY },
            });
          },
        });
      }
    },
    [oidcUser]
  );
  • ログイン状態に応じて、クライアントを切り替えるコンテキストを定義

フロントエンドのベンダーロックイン

  • GraphQL、OpenID共にAWSに依存しない仕組みにしているため、最低限の修正でエンドポイントの変更が可能
    • OpenIDの設定値の変更
    • ApolloContextのヘッダーの変更

実サービスで利用するために検討が必要な箇所

  • AppSyncのAPI_KEY承認モードの利用
    • 定期的な再発行を行い、キーを返すAPIを実装する
    • Cognitoに匿名ユーザを登録し、sub値で判断する
    • Client Credentials Grantフローで取得したトークンを返すAPIを実装する(そのトークンが利用可能か未検証)
  • 厳密なログアウト
    • @axa-fr/react-oidc-contextはトークンを削除することでログアウト
    • 厳密な制御を行うなら、ログアウトエンドポイントを用いてCognito側のログアウトも必要
  • ホストされたサインアップ、サインインページとリダイレクト
    • ADMIN_NO_SRP_AUTHを用いてそれぞれの処理を行い、取得されたトークンを返すAPIを実装する

まとめ

  • AppSync + DynamoDB + Cognitoを使えば、ノンコーディングでGraphQLのAPIバックエンドを作ることができる
    • パイプラインリゾルバを利用すれば、実サービスでもある程度までは対応できそう
  • serverless-appsync-pluginを利用することで(ほぼ)自動でデプロイが可能
  • AWSにほとんど依存しないフロントエンドの開発も可能
  • 非ログイン状態でのAppSyncへのアクセス制御は検証が必要

Marpでスライドを作ると、そのまま貼り付けれるから便利。

react-scriptsはwebpackで何をしているのか

  • create-react-appが利用しているreact-scriptは、webpackで何をしているのか
  • eject コマンドで挿入される設定ファイル群はreact-scriptのものなので、設定を変更するときの参考に

対象


publicPathpublicUrl

const publicPath = isEnvProduction
  ? paths.servedPath
  : isEnvDevelopment && '/';
const shouldUseRelativeAssetPaths = publicPath === './';

const publicUrl = isEnvProduction
  ? publicPath.slice(0, -1)
  : isEnvDevelopment && '';
  • ビルドしたファイル群がどこにデプロイされるか
  • 開発時は相対パス
  • index.htmlcssファイルのコンテンツへのパスの制御
  • publicPath のデフォルトはWebサーバのルートディレクト
  • publicUrl のデフォルトは空文字
  • 共に config/paths.js で定義

getStyleLoaders

const getStyleLoaders = (cssOptions, preProcessor) => {
  ...
  return loaders;
};
  • cssのローダーを返す関数
  • 開発時は style-loader を、ビルド時は MiniCssExtractPlugin を適用
    • ビルド時はcssファイルが生成される
  • postcss-loaderoptions でpostcssのプラグイン等を設定
  • preProcessor (sass-loaderとか) が渡された場合、loadersに追加

entry

[
  isEnvDevelopment &&
    require.resolve('react-dev-utils/webpackHotDevClient'),
  paths.appIndexJs,
 ]

output

devtoolModuleFilenameTemplate

{
  devtoolModuleFilenameTemplate: isEnvProduction
    ? info =>
        path
          .relative(paths.appSrc, info.absoluteResourcePath)
          .replace(/\\/g, '/')
    : isEnvDevelopment &&
      (info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
}

optimization

TerserPlugin

new TeserPlugin({
  parse: { ecma: 8 },
  compress: { ecma: 5 },
  mangle: { safari10: true },
  output: { ecma: 5 },
})

resolve

modules

{
  modules: ['node_modules'].concat(
    process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
  ),
}       
  • webpackが探索する node_modules の追加
    • プロジェクトの node_modules を優先するために、2番目に追加される

resolve

plugins

[
  PnpWebpackPlugin,
  new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
]

module

strictExportPresence

{ strictExportPresence: true }
  • エクスポートが不足している場合にエラーにする

module.rules

requireEnsure

{ parser: { requireEnsure: false } },
  • 標準仕様にない require.ensure を無効化

module.rules

eslint-loader

{
  test: /\.(js|mjs|jsx)$/,
  enforce: 'pre',
  use: [
    {
      options: {
        formatter: require.resolve('react-dev-utils/eslintFormatter'),
        eslintPath: require.resolve('eslint'),
        baseConfig: { extends: [require.resolve('eslint-config-react-app')] },
        ignore: false,
        useEslintrc: false,
      },
      loader: require.resolve('eslint-loader'),
    },
  ],
  include: paths.appSrc,
}

mdoule.rules

babel-loader (src内のファイルを対象)

{
  test: /\.(js|mjs|jsx|ts|tsx)$/,
  include: paths.appSrc,
  loader: require.resolve('babel-loader'),
  options: {
    customize: require.resolve(
      'babel-preset-react-app/webpack-overrides'
    ),
    babelrc: false,
    configFile: false,
    presets: [require.resolve('babel-preset-react-app')],
    plugins: [
      [
        require.resolve('babel-plugin-named-asset-import'),
        {
          loaderMap: {
            svg: {
              ReactComponent: '@svgr/webpack?-svgo![path]',
            },
          },
        },
      ],
    ],
  },
}

mdoule.rules

babel-loader (src内のファイルを対象)


babel-preset-react-app

  • babel-preset-react-app/create.js で定義が行われている
  • 以下のパッケージがデフォルトで含まれる
    • @babel/preset-env / @babel/preset-react / babel-plugin-macros / @babel/plugin-transform-destructuring / @babel/plugin-proposal-class-properties / @babel/plugin-proposal-object-rest-spread / @babel/plugin-transform-runtime / @babel/plugin-syntax-dynamic-import
  • TypeScriptが有効な時に追加されるもの
    • @babel/preset-typescript / @babel/plugin-proposal-decorators
  • flowが有効な時に追加されるもの
    • @babel/plugin-transform-flow-strip-types
  • ビルド時に追加されるもの
    • babel-plugin-transform-react-remove-prop-types
  • テスト時に追加されるもの
    • babel-plugin-dynamic-import-node

mdoule.rules

babel-loader (src外のファイルを対象)

{
  test: /\.(js|mjs)$/,
  exclude: /@babel(?:\/|\\{1,2})runtime/,
  loader: require.resolve('babel-loader'),
  options: {
    presets: [
      [
        require.resolve('babel-preset-react-app/dependencies'),
        { helpers: true },
      ],
    ],
  },
}

mdoule.rules

css (グローバル)

{
  test: cssRegex,
  exclude: cssModuleRegex,
  use: getStyleLoaders({
    importLoaders: 1,
    sourceMap: isEnvProduction && shouldUseSourceMap,
  }),
}
  • cssRegex/\.css$/
  • cssModuleRegex/\.module\.css$/ に該当するファイルは除外
  • getStyleLoaders 関数でローダを取得

mdoule.rules

css (モジュール)

{
  test: cssModuleRegex,
  use: getStyleLoaders({
    importLoaders: 1,
    sourceMap: isEnvProduction && shouldUseSourceMap,
    modules: true,
    getLocalIdent: getCSSModuleLocalIdentm
  }),
}
  • cssModuleRegex/\.module\.css$/ に該当するファイルに適用される
  • css-loaderの modules オプションを有効にしている
  • getLocalIdentcssのクラス名の生成方法を指定するオプション

module.rules

sass

{
  test: sassRegex,
  exclude: sassModuleRegex,
  use: getStyleLoaders(
    {
      importLoaders: 2,
      sourceMap: isEnvProduction && shouldUseSourceMap,
    },
    'sass-loader'
  ),
},
{
  test: sassModuleRegex,
  use: getStyleLoaders(
    {
      importLoaders: 2,
      sourceMap: isEnvProduction && shouldUseSourceMap,
      modules: true,
      getLocalIdent: getCSSModuleLocalIdent,
    },
    'sass-loader'
  ),
}
  • 基本的にcssと同様でpreProsessorとして sass-loader を指定

plugins

InterpolateHtmlPlugin

new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw)

plugins

ModuleNotFoundPlugin

new ModuleNotFoundPlugin(paths.appPath)

plugins

WatchMissingNodeModulesPlugin

isEnvDevelopment &&
  new WatchMissingNodeModulesPlugin(paths.appNodeModules)

plugins

WorkboxWebpackPlugin

isEnvProduction &&
  new WorkboxWebpackPlugin.GenerateSW({
    clientsClaim: true,
    exclude: [/\.map$/, /asset-manifest\.json$/],
    importWorkboxFrom: 'cdn',
    navigateFallback: publicUrl + '/index.html',
    navigateFallbackBlacklist: [
      new RegExp('^/_'),
      new RegExp('/[^/]+\\.[^/]+$'),
    ],
  })

plugins

ForkTsCheckerWebpackPlugin

useTypeScript &&
  new ForkTsCheckerWebpackPlugin({
    tsconfig: paths.appTsConfig,
    formatter: typescriptFormatter,
  })

PIXI.jsでのWebGLRendererでsubtractする

プロジェクトで利用しているPIXI.jsで必要になったので。

PIXI.jsはHTMLのcanvasを操作するライブラリ。
利用可能な環境であればWebGLで動作し、利用できない環境であればCanvasにフォールバックしてくれる。
グラフィックの操作やマウスイベントをハンドリングをいい感じに行なってくれるけれども、subtract、描画したグラフィックで背面のグラフィックを消すこと、canvasdestination-out がサポートされていないし、以下のような制約がある。

The WebGL renderer only supports the NORMAL, ADD, MULTIPLY and SCREEN blend modes.

PIXI.BLEND_MODES

ただ、内部的には当然ブラウザのAPIを利用しているので、少しの工夫で実現が可能。

mapWebGLBlendModesToPixi ここでPIXI.jsブレンドモードとWebGLの設定のマッピングが行われている。

renderer.state でアクセスできる WebGLState にドキュメントにはないけど、 blendModes というプロパティが存在してさっきの mapWebGLBlendModesToPixi を保持している。
mapWebGLBlendModesToPixi は配列で numberである BLEND_MODES の各値をインデックスとして取得するので、配列の最後に任意の設定を追加して、 GraphicsblendMode に直接その値を指定すれば、その設定を利用して描画してくれるようになる。

具体的には以下のコード。

app.renderer.state.blendModes[20] = [0, 0x0303];

設定している値はここで呼び出している blendFunc(sfactor, dfactor) の引数。
直接設定してしまっているけれど、 WebGL constants で設定した方が行儀がいい。
描画元は alpha を 0 に、描画先は元のままとすることで、描画しようとしたところだけ透明になる。

今回はWebGLのみのサポートで決め打ちしてしまっているけれど、Canvasも対象とするのだったらWebGLが使えるかを判定した後に、それぞれ設定する方がいいと思う。

Cognito User PoolのJWTトークンを検証する

Cognito User Poolへログインした際に返却されるAccessTokenやIdTokenを検証する方法。
API Gatewayの認証でCognitoを使いたいけど、いろいろ処理を入れたいときなどに。

const request = require('request-promise-native')
const jwt = require('jsonwebtoken')
const generatePEM = require('rsa-pem-from-mod-exp')

const verifyToken = async token => {
  const { header, payload } = jwt.decode(token, { complete: true })

  if (!payload) {
    throw new Error('invalid token')
  }

  if (payload.iss !== YOUR_COGNITO_IDP_ISSUER) {
    throw new Error('invalid token')
  }

  const jwks = await request({
    method: 'GET',
    uri: `${payload.iss}/.well-known/jwks.json`,
    transform: body => JSON.parse(body)
  })

  const { n, e } = jwks.keys.find(c => (c.kid === header.kid))
  const pem = generatePEM(n, e)

  return new Promise((resolve, reject) => {
    jwt.verify(token, pem, (error, decoded) => {
      if (error) {
        reject(error)
        return
      }
      resolve(decoded)
    })
  })
}

流れとしては、

  1. 自分のCognitoのトークンか検証
  2. キーを取得してPEMを生成
  3. 生成したPEMを用いてトークンを検証

必要であれば、token_useの確認を。

OpenID Provider(OP)をServerlessで提供する

OpenID ConnectOpenID Provider(OP)をAWSを利用したサーバレスアーキテクチャで提供するためのserverless-oidc-providerを開発しました。

serverless-oidc-providerはnode-oidc-providerAWS Lambdaで実行し、ユーザ管理・認証をCognito User Poolに移譲、データ永続化にDynamoDBを利用することで、サーバを準備することなく迅速に、OpenID Provider(OP)を提供することができます。

詳しい利用方法はREADME.mdやソースコードを確認していただくのが早いとは思いますが、コンセプトや、どのようにユーザ管理を切り離しているのか、またAPI Gatewayのカスタムオーソライザーでの利用について、説明をしていきます。

コンセプト

OAuth 2.0 / OIDC 実装の新アーキテクチャーで述べられている、「ユーザー認証と認可の分離」、「アイデンティティー管理と認可の分離」と「API管理と認可の分離」といった考え方に大きな影響を受けています。
それらを達成しつつ、サービスごとの個別の要件に対応できる柔軟性の確保、スケーラビリティや耐障害性といったサーバレスのメリットを享受できることを目指しています。

OpenID Provider(OP)としてのコアの機能

node-oidc-providerに全面的に依存する構成を取っています。
エンドポイントごとにLambdaに最適化した実装を行った方が、パフォーマンス上有利ではあるのですが、OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語るで述べられているように実装難易度が高いこと、多くの機能についてnode-oidc-providerがOpenID Certificationの認証を受けていることから、Lambda上でnode-oidc-providerをWebアプリとして実行する選択をしました。

なお、node-oidc-providerはnode 8.0以上、内部で利用しているkoaはnode v7.6以上を要求します。
Lambdaで利用可能なnodeは6.10であるため、babel-registerを利用する必要があるのですが、現行のbabel-registerはLambda上で動作しません。参考
そのため、現在β版のv7系のbabelを利用しています。
新しいバージョンの@babel/registerはシンプルにrequire()を拡張可能なpiratesを利用するように実装の変更が行われており、Lambda上でも利用することが可能です。

require('@babel/polyfill')

require('./shims/url')
require('./shims/util')

require('@babel/register')({
  presets: [
    ["@babel/preset-env", {
      "targets": { "node": "6.10" }
    }]
  ],
  ignore: [filename => {
    if (filename.match(/node_modules\/oidc-provider/)) {
      return false
    } else if (filename.match(/node_modules\/koa/)) {
      return false
    } else {
      return true
    }
  }],
  cache: false,
  babelrc: false
})

@babel/registerは通常、node_modulesに対しては変換を行わないようになっています。そこで、上記のコードのように、oidc-providerとkoaに関連するモジュールに対して、require時に変換を行えるようにしています。

ユーザ認証

Cognito User Poolを利用していますが、Adminとしてのユーザログイン、ユーザ情報の取得のみの機能を利用しています。
ユーザの登録や管理は、各種SDKを利用したアプリケーションで行う想定としています。これは、可能な限りユーザ管理に関わる固有の実装を分離することを意図したものです。
具体的な実装はsrc/app/oidc/account/index.jsであり、同じインタフェースを備えるクラスを実装すれば、Cognito User Pool以外のユーザ認証の仕組みを利用することが可能です。
また、このクラスを利用するsrc/app/oidc/actions/interaction.jsと合わせて変更することで、任意の方法でのユーザの認証・特定の仕組みを利用することができます。

API Gateway カスタムオーソライザー

src/services/authorizeは、API Gatewayのカスタムオーソライザーとして指定可能なLambda関数のサンプルです。
RFC7662: OAuth 2.0 Token Introspectionに準拠したイントロスペクションエンドポイントを用いたカスタムオーソライザーの例であり、もちろんnode-oidc-providerの提供するエンドポイントでも利用することが可能です。
具体的な実装はsrc/app/authorize以下のコードとなります。

const { getToken } = require('./helpers/get-token')
const { generatePolicy } = require('./helpers/generate-policy')
const { introspect } = require('./introspection/introspect')

const authorizerHandler = async (event, context, callback) => {
  const token = getToken(event.authorizationToken)

  if (!token) {
    callback('Unauthorized')
  }

  const introspectionResult = await introspect(token)

  if (!introspectionResult.active) {
    callback('Unauthorized')
  }

  const { principalId, effect, resource, authContext } = await confirmPermission(introspectionResult, event.methodArn)

  const policy = generatePolicy(principalId, effect, resource, authContext)

  callback(undefined, policy)
}

/**
 * From the introspection result, confirm the authority to the access target.
 * If you are doing your own permission management, use this method to verify.
 */
const confirmPermission = async (introspectionResult, resource) => {
  // Currently it is an implementation that allows access if it is a valid token.
  return {
    principalId: (introspectionResult.sub) ? introspectionResult.sub : introspectionResult.client_id,
    effect: 'Allow',
    resource: resource,
    authContext: {
      // The value that can be set in the context is limited to the primitive value, so serialize it.
      introspection: JSON.stringify(introspectionResult)
    }
  }
}

module.exports = {
  authorizerHandler
}

confirmPermission()でイントロスペクションエンドポイントの結果とリソースに基づき、トークンの利用者がリソースにアクセス可能かを判断します。
現在のサンプルは、トークンが有効であればアクセスを許可する実装となっています。
また、保護されたリソースで再度のトークン情報の問い合わせが不要なように、カスタムオーソライザーのレスポンスのcontextに、イントロスペクションの結果をシリアライズしてセットしています。
実際には、イントロスペクションのレスポンスに含まれる、ユーザを一意に特定するsub(Client Credentials Grantでは含まない)とトークンを払い出されたclient_id、要求したscope等の情報を元に、リソースに対するクライアントアプリケーションに対するアクセス制御、ユーザに対するアクセス制御を行います。