Reactでスクリプトタグを含んだHTMLを挿入する(SSG/SSR対応)
いわゆるJAMstackを用いたWebメディアなどでは、コンテンツの構成が記事ごとに少しずつ違うけれどもマークダウンでは表現力が制限されるため、本文などコンテンツの一部をHTMLで記述して表示したいという状況はそれなりにあると思います。 そのような状況で、外部サービスから提供されるスクリプトタグを含んだコンテンツを表示したい、という要件に対応したものです。
dangerouslySetInnerHTML
の制限
ReactにはdangerouslySetInnerHTML
を用いることで、任意のDOM要素の子要素としてHTMLを挿入することができます。
ただし、この機能は内部的にinnerHTML
用いています。innerHTML
はXSS攻撃に対する対策として<script>
タグを実行しません。(ただし、インベントハンドラとしてスクリプトを実行することは可能です)
そのため、スクリプトタグを含むHTMLはdangerouslySetInnerHTML
で設定することはできますが、そのスクリプトは実行されないということになります。
appendChild
とcreateContextualFragment
の利用
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
の仕様によりそのスクリプトタグは実行されない。appendChild
とcreateContextualFragment
を利用することで、スクリプトタグを含んだ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 SafariとRefresh 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; } } };
引数
options
はGetTokenSilentlyOptions
、client
はAuth0Client
のインスタンスです。
client.getTokenSilently()
Auth0Client
のgetTokenSilently()
をまず最初に実行します。
内部的には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-jsp
のgetTokenWithPopup()
は、内部で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への格納が行われる
まとめ
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
- バックエンド
自己紹介
主なサーバレス関連のライブラリ
- serverless-import-swagger
- OpenAPI形式のAPI定義からserverless.ymlのhandlerの定義を生成するツール
- 最近のバージョンアップで設定まわりが洗練されました
- node-lambda-utilities
- lambdaのhandlerのテストをしやすくするツール
- contextやcallbackのモックの生成とhandlerの実行
今日話すこと
GraphQL or RESTful ?
- 参考
- 個人的な考え
プロジェクトのサンプル
https://github.com/AKIRA-MIYAKE/aws-serverless-sample
ベンダーロックインしないために
- Amplifyは使わない
- フロントエンドがAWSにロックインしないように
- Apollo Clientを利用
- Cognitoを用いるが、OpenID Providerとして利用する
- 認可処理の拡張性を確保するため
- @axa-fr/react-oidc-contextを利用
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 }
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 }
- MessageKeyValueはノンコーディングを達成しつつDynamoDBデータモデリング虎の巻:第参巻 〜実践編〜の汎用的なGSIを実現するために生まれたもの
- スキーマが不自然で1+Nになるため、速やかに修正されるべき箇所
- パイプラインリゾルバの利用がおそらく適切
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 }
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」
アプリの統合 / ドメイン名
フロントエンド
- 動作確認のためのシンプルな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] );
- ログイン状態に応じて、クライアントを切り替えるコンテキストを定義
フロントエンドのベンダーロックイン
実サービスで利用するために検討が必要な箇所
- AppSyncのAPI_KEY承認モードの利用
- 厳密なログアウト
- @axa-fr/react-oidc-contextはトークンを削除することでログアウト
- 厳密な制御を行うなら、ログアウトエンドポイントを用いてCognito側のログアウトも必要
- ホストされたサインアップ、サインインページとリダイレクト
まとめ
- AppSync + DynamoDB + Cognitoを使えば、ノンコーディングでGraphQLのAPIバックエンドを作ることができる
- パイプラインリゾルバを利用すれば、実サービスでもある程度までは対応できそう
- serverless-appsync-pluginを利用することで(ほぼ)自動でデプロイが可能
- AWSにほとんど依存しないフロントエンドの開発も可能
- 非ログイン状態でのAppSyncへのアクセス制御は検証が必要
Marpでスライドを作ると、そのまま貼り付けれるから便利。
react-scriptsはwebpackで何をしているのか
- create-react-appが利用しているreact-scriptは、webpackで何をしているのか
eject
コマンドで挿入される設定ファイル群はreact-scriptのものなので、設定を変更するときの参考に
対象
- create-react-app/packages/react-scripts/ の
config/webpack.config.js
eject
コマンドで追加されるのもこのファイル- 最近の変更 (Merge webpack configuration #5722) で単一のファイルに統合された
publicPath
と publicUrl
const publicPath = isEnvProduction ? paths.servedPath : isEnvDevelopment && '/'; const shouldUseRelativeAssetPaths = publicPath === './'; const publicUrl = isEnvProduction ? publicPath.slice(0, -1) : isEnvDevelopment && '';
- ビルドしたファイル群がどこにデプロイされるか
- 開発時は相対パス
index.html
やcssファイルのコンテンツへのパスの制御publicPath
のデフォルトはWebサーバのルートディレクトリpublicUrl
のデフォルトは空文字- 共に
config/paths.js
で定義
getStyleLoaders
const getStyleLoaders = (cssOptions, preProcessor) => { ... return loaders; };
- cssのローダーを返す関数
- 開発時は
style-loader
を、ビルド時はMiniCssExtractPlugin
を適用- ビルド時はcssファイルが生成される
postcss-loader
のoptions
でpostcssのプラグイン等を設定preProcessor
(sass-loaderとか) が渡された場合、loadersに追加
entry
[ isEnvDevelopment && require.resolve('react-dev-utils/webpackHotDevClient'), paths.appIndexJs, ]
- 開発時は
react-dev-utils/webpackHotDevClient.js
を追加- WebpackDevServerにsocket接続するためのclient
output
devtoolModuleFilenameTemplate
{ devtoolModuleFilenameTemplate: isEnvProduction ? info => path .relative(paths.appSrc, info.absoluteResourcePath) .replace(/\\/g, '/') : isEnvDevelopment && (info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')), }
- source-mapの出力先の変換
optimization
TerserPlugin
new TeserPlugin({ parse: { ecma: 8 }, compress: { ecma: 5 }, mangle: { safari10: true }, output: { ecma: 5 }, })
webpack-contrib/terser-webpack-plugin
を利用- パースはECMA8を適用するが、無効なコードへの変換を防ぐために圧縮と出力ではECMA5を適用している
- Safari10/11におけるloop内のスコープと
await
のバグのワークアラウンドの追加
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]), ]
pnp-webpack-plugin
の追加- yarnのPlug'n'Playを利用するためのプラグイン
react-dev-utils/ModuleScopePlugin.js
の追加
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, }
- 通常のloaderの前に実行
- eslintの設定として
eslint-config-react-app
を指定 - formatterに
react-dev-utils/eslintFormatter
を指定- eslintの出力をcreate-react-appのコンソールに統合
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-plugin-named-asset-import
がプラグインに追加されるbabel-preset-react-app/webpack-overrides.js
をbabel-loader
のcustomize
に設定- Babel Macrosのキャッシュをいい感じにハンドリングしてくれる
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 }, ], ], }, }
- プリセットとして
babel-preset-react-app/dependencies.js
を指定 @babel/runtime
以外の依存パッケージをトランスパイルする
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
オプションを有効にしている getLocalIdent
はcssのクラス名の生成方法を指定するオプション
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)
react-dev-utils/InterpolateHtmlPlugin.js
を追加- HtmlWebpackPluginの処理の後に、
index.html
の変数定義の部分をenv.raw
の値で置き換える
plugins
ModuleNotFoundPlugin
new ModuleNotFoundPlugin(paths.appPath)
react-dev-utils/ModuleNotFoundPlugin.js
を追加- インポートするモジュールが見つからなかった場合のエラーメッセージを制御
plugins
WatchMissingNodeModulesPlugin
isEnvDevelopment && new WatchMissingNodeModulesPlugin(paths.appNodeModules)
- 開発時に
react-dev-utils/WatchMissingNodeModulesPlugin
を追加 - 依存ライブラリがインストールされていない場合に、強制的にインストール
plugins
WorkboxWebpackPlugin
isEnvProduction && new WorkboxWebpackPlugin.GenerateSW({ clientsClaim: true, exclude: [/\.map$/, /asset-manifest\.json$/], importWorkboxFrom: 'cdn', navigateFallback: publicUrl + '/index.html', navigateFallbackBlacklist: [ new RegExp('^/_'), new RegExp('/[^/]+\\.[^/]+$'), ], })
- ビルド時に
workbox-webpack-plugin
を追加 Workbox
はWebアプリケーションのオフラインサポートを追加するGoogleのJavaScriptライブラリ- 詳しくは
workbox-webpack-plugin
の公式ページを参照 react-scripts/template/src/serviceWorker.js
のregister()
で登録することで、静的ファイルのプリキャッシュを行うことができる
plugins
ForkTsCheckerWebpackPlugin
useTypeScript && new ForkTsCheckerWebpackPlugin({ tsconfig: paths.appTsConfig, formatter: typescriptFormatter, })
Realytics/fork-ts-checker-webpack-plugin
を追加- 別プロセスでTypeScriptの型チェックを実行
- TypeScriptの利用は
tsconfig.json
の有無で判定される - formatterに
react-dev-utils/typescriptFormatter.js
を指定- プラグインの出力をcreate-react-appのコンソールに統合
PIXI.jsでのWebGLRendererでsubtractする
プロジェクトで利用しているPIXI.jsで必要になったので。
PIXI.jsはHTMLのcanvasを操作するライブラリ。
利用可能な環境であればWebGLで動作し、利用できない環境であればCanvasにフォールバックしてくれる。
グラフィックの操作やマウスイベントをハンドリングをいい感じに行なってくれるけれども、subtract、描画したグラフィックで背面のグラフィックを消すこと、canvasの destination-out
がサポートされていないし、以下のような制約がある。
The WebGL renderer only supports the NORMAL, ADD, MULTIPLY and SCREEN blend modes.
ただ、内部的には当然ブラウザのAPIを利用しているので、少しの工夫で実現が可能。
mapWebGLBlendModesToPixi ここでPIXI.jsのブレンドモードとWebGLの設定のマッピングが行われている。
renderer.state
でアクセスできる WebGLState にドキュメントにはないけど、 blendModes
というプロパティが存在してさっきの mapWebGLBlendModesToPixi
を保持している。
mapWebGLBlendModesToPixi
は配列で numberである BLEND_MODES
の各値をインデックスとして取得するので、配列の最後に任意の設定を追加して、 Graphics
の blendMode
に直接その値を指定すれば、その設定を利用して描画してくれるようになる。
具体的には以下のコード。
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) }) }) }
流れとしては、
必要であれば、token_use
の確認を。
OpenID Provider(OP)をServerlessで提供する
OpenID ConnectのOpenID Provider(OP)をAWSを利用したサーバレスアーキテクチャで提供するためのserverless-oidc-providerを開発しました。
serverless-oidc-providerはnode-oidc-providerをAWS 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
等の情報を元に、リソースに対するクライアントアプリケーションに対するアクセス制御、ユーザに対するアクセス制御を行います。