2021年10月23日、24日にデザインカンファレンスである、Designship 2021が開催されました。

全体の構成や機能については、sakito氏の「デザインカンファレンスのライブ配信サイトを開発した話」で紹介されています。 そのため、今回は違う切り口として、このライブ配信サイトを開発する際に技術的に工夫した内容などを紹介します。

Identity Platformを使用した認証機能の実装

今回のカンファレンスは有料であるため、参加者のみがライブ配信サイトに入れるという機能が必要です。

具体的には、チケット購入者、スポンサー様には事前にパスコードが発行されます。 そのパスコードと一致した場合、サイトにログインできるようにしていました。

インフラ基盤としてFirebaseを使用している場合、認証機能はFirebase Authenticationを選択することが多いですが、今回はGCPのIdentity Platformを選択しました。

これは、

  • カンファレンスの参加者が使用するライブ配信サイト
  • カンファレンスのオペレーションチームが利用する管理画面

があるためです。

これをFirebase Authenticationで実現する場合、

  • Firebase AuthenticationのCustom Claimを使用する
  • Firebaseのプロジェクトを複数用意する

という方法でも実現は可能かもしれません。

しかし、「Firebase AuthenticationのCustom Claimを使用する」方法では、コンテキストが分離しておらず、カンファレンスの参加者のセッションで管理画面にはアクセスできないようにするなど、少々面倒なクライアント側の対応が必要です。 また、「Firebaseのプロジェクトを複数用意する」方法では、ライブ配信サイトが使用しているFirebase Realtime Databaseへ直接アクセスできないため、別途サーバーで処理する機構を用意する必要があります。

Identity PlatformはFirebaseではなくGCPのサービスですが、Firebase Authenticationと互換性があり、SAMLやOIDCの認証方式やマルチテナント機能をサポートしています。

そのため、開発においても、GCPのSDKではなく、FirebaseのSDKを使用します。

このマルチテナント機能を使うことで、コンテキストを分けつつ、1つのプロジェクト内のFirebase Realtime Databaseを共有して使用できます。

Identity Plartformを使用した構成図

Identity Platformのカスタムトークンの発行とカスタムクレームの設定

今回はFirebaseを使っているため、Firebase for Cloud FunctionsをCallable HTTPS Functionとして使用してカスタムトークンを発行することにしました。

以下はCallable HTTPS FunctionでIdentity Plartformを使用したカスタムトークンを発行する例です。

import { auth } from 'firebase-admin';
import * as functions from 'firebase-functions';

const f = functions.region('asia-northeast1');

type IssueAdminPageCustomAuthTokenRequest = {
  data: {
    passCode: string;
  };
};

const tenantAuth = auth().tenantManager().authForTenant('<ここにIdentity PlartformのテナントID>');

export const issueAdminPageCustomAuthToken = f.https.onCall(async ({ data }: IssueAdminPageCustomAuthTokenRequest) => {
  // 今回は 'PASSCODE' という文字と一致したらカスタムトークンを発行するようにする
  if (data.passCode !== 'PASSCODE') {
    throw new functions.https.HttpsError('permission-denied', 'Passcode is invalid.');
  }

  const user = await tenantAuth
    .createUser()
    .catch((e) => functions.logger.error('Error creating a user', e));

  if (!user) {
    throw new functions.https.HttpsError('aborted', 'Faild to create a user.');
  }

  const [token] = await Promise.all([
    tenantAuth
      .createCustomToken(user.uid)
      .catch((e) => functions.logger.error('Error creating a custom token', e, user)),
    /**
     * セキュリティルールで使用するため、カスタムクレームを設定
     *
     * NOTE:
     *  テナントIDを設定することも考えたが、何かしらの理由でテナントIDを入れ替える可能性もあるため、
     *  それに引きづられてセキュリティルール側も変えるのはメンテナンス性が悪い
     *
     *  故に、'adminPage'という定数を使用している
     */
    tenantAuth.setCustomUserClaims(user.uid, { authContext: 'adminPage' }),
  ]);

  if (!token) {
    throw new functions.https.HttpsError('aborted', 'Faild to create a token.');
  }

  return { token };
});

今回の構成の場合、ブラウザから直接Firebase Realtime Databaseを参照しているため、セキュリティルールで管理画面のログインユーザー以外からはwriteを制限する必要があります。

上記の例では、カスタムトークンを発行する以外に、authContextというカスタムクレームを追加していますが、この値をセキュリティルールで使用します。

以下はFirebase Realtime Databaseのセキュリティルールの設定例です。

{
  "rules": {
    ".read": true,
    ".write": "auth !== null && auth.token.authContext === 'adminPage'"
  }
}

マルチテナントを有効化しているIdentity Platformでの認証

マルチテナントを有効化しているIdentity Platformで認証する際は、クライアントコード側のFirebaseのAuthオブジェクトにテナントIDを設定することで、参照するテナントを切り替えられます。

// Firebase SDK v8
import firebase from 'firebase';
import 'firebase/auth';
import 'firebase/functions';

const f = firebase.app().functions('asia-northeast1');

const auth = firebase.auth();
auth.tenantId = "<ここにIdentity PlartformのテナントID>";

export const login = async (passCode) => {
  const response = await f.httpsCallable('issueAdminPageCustomAuthToken')({
    passCode,
  });
  const signInResponse = await auth.signInWithCustomToken(response.data.token);

  return signInResponse.user;
};

同一ソースコードでFirebase SDKを使用する場合の工夫

ライブ配信サイトと管理画面はそれぞれ規模が小さいため、クライアントは同じソースコード上で実装されています。 そのため、同一ソースコードで複数のテナントを扱う必要がありました。

同一コード内で複数のテナントを操作する場合、必ずFirebaseのアプリケーションの初期化時にアプリケーション名をつける必要があります。 Firebase Realtime Databaseなど、認証以外のアプリケーションを呼び出す際も、アプリケーション名を指定しないと、セキュリティルールで期待通りに認証情報が使用できないため、注意が必要です。

// Firebase SDK v8
import firebase from 'firebase';
import 'firebase/auth';

const liveSiteTenantId = 'ここにIdentity Plartformのライブサイト用のテナントID';
const adminPageTenantId = 'ここにIdentity Plartformの管理画面用のテナントID';

// Firebaseから取得した設定値を設定
const firebaseConfig = {
  apiKey: 'xxxxxxxx',
  authDomain: 'xxxxxxxx.firebaseapp.com',
  databaseURL: 'https://xxxxxxxx.<region>.firebasedatabase.app',
  projectId: 'xxxxxxxx',
  storageBucket: 'xxxxxxxx.appspot.com',
  messagingSenderId: 'xxxxxxxx',
  appId: 'xxxxxxxx',
};

/**
 * NOTE:
 *  第二引数のアプリケーション名、テナントIDでなくてもよいが、都合が良いのでテナントIDを使用している
 *
 *  セキュリティルールと異なり、テナントIDが変わったら、コンテキストを変えることになるため、
 *  変わる可能性のあるテナントIDをアプリケーション名として使用しても問題ない
 */
firebase.initializeApp(firebaseConfig, liveSiteTenantId);
firebase.initializeApp(firebaseConfig, adminPageTenantId);

export const liveSiteApp = {
  auth: (() => {
    const auth = firebase.app(liveSiteTenantId).auth();
    auth.tenantId = liveSiteTenantId;
    return auth;
  })(),
  database: firebase.app(liveSiteTenantId).database(),
};

export const adminPageApp = {
  auth: (() => {
    const auth = firebase.app(adminPageTenantId).auth();
    auth.tenantId = adminPageTenantId;
    return auth;
  })(),
  database: firebase.app(adminPageTenantId).database(),
};

開発環境と本番環境で異なる値を設定

Webサイトを構築する際、動作確認や品質保証のため、本番環境のほかに、開発環境や品質保証(QA)環境など、複数環境を用意することがあります。

基本的には、それぞれの環境には本番環境と同じ内容をデプロイすることで、確認作業ができます。 理想的には差異がないことが望ましいですが、設定値など、どうしてもそれぞれの環境ごとに異なる値を使いたいことがあります。

多くの場合、複数環境はそのためのインフラ環境を用意し、環境差異は環境変数で吸収します。

Firebase(GCP)で複数環境を用意する場合は、Firebase(GCP)のプロジェクトを分けることが多いです。 ここまでは良いのですが、Firebase for Cloud Functionsを使用する場合、環境変数が使えないという問題があります。

ワークアラウンドとしては、Firebase for Cloud Functionsの裏側はGCPであるため、GCPのAPIでCloud Functionsをデプロイすることで環境変数を設定できます。 しかし、Firebaseに最適化されたCloud Functionsとして使用したい場合、一番初めはFirebase for Cloud Functionsとしてデプロイする必要があります。

しかし、今回は、FirebaseのRuntime Configurationを使うことにしました。

理由としては、ワークアラウンドはデプロイのしくみを作る際に面倒であるため、できればFirebaseのしくみで実現したかったからです。 また、Runtime Configurationを使用することで、Firebaseのエミュレータ上にデプロイしたFirebase for Cloud Functionsでも値を参照できるメリットもあります。

Runtime Configuration設定スクリプトの作成

Runtime Configurationを設定する場合、キーには大文字やアンダースコア(_)などの記号が使えず、小文字しか設定できないという制約があります。

$ npx firebase functions:config:set someservice.HOGE="HOGE"

Error: Invalid config name someservice.HOGE, cannot use upper case.

しかし、しばしば値をスネークケースやキャメルケースで設定したいことがあります。 実は、JSON文字列として値を設定する場合、この制約を受けずに済みます。

JSONを処理するのは面倒である点、「Next.jsとFirebase for Cloud Functionsで共通の値を使用」で定義した値をシームレスに使用できる点から、Shell ScriptではなくTypeScriptでfirabase functions:config:setコマンドをラップすることにしました。

ちなみに、Firebase CLIはfirebase-toolsとしてJavaScriptで実装されているため、わざわざCLIをラップせずにモジュールとして呼び出せば良いとも考えましたが、

という理由から、Firebase CLIをラップすることにしました。

また、このRuntime ConfigurationをFirebase エミュレータで使用する場合、functionsのルートディレクトリ(firebase.jsonfunctions.sourceに設定しているディレクトリ)に.runtimeconfig.jsonを設置する必要があります。

このあたりを吸収するため、簡単なスクリプトを作成することにしました。

// functionConfig.ts
import { getStageConfig } from '@designship/stage-config';
import * as child from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(child.exec);

const CommandType = {
  emulator: 'emulator',
  production: 'production',
} as const;
type CommandType = typeof CommandType[keyof typeof CommandType];

const parseCommand = (s?: string): CommandType | undefined => {
  if (!s) {
    return
  }
  const command = s as CommandType;
  switch (command) {
    case CommandType.emulator: {
      return CommandType.emulator;
    }
    case CommandType.production: {
      return CommandType.production;
    }
    default: {
      const _unknownCommand: never = command;
      return
    }
  }
};

(async (): Promise<void> => {
  const command = parseCommand(process.argv[2]);
  if (!command) {
    const message = `The command required, ${Object.values(CommandType).map(v => `'${v}'`).join(' or ')}`;
    console.log(message);
    throw new Error(message);
  }

  const topLevelKey = 'stageconfig';

  try {
    const runtimeConfig = getStageConfig(process.env.STAGE ? process.env.STAGE : 'dev');

    switch (command) {
      case CommandType.emulator: {
        console.log(JSON.stringify(Object.fromEntries([[topLevelKey, runtimeConfig]]), null, 2));
        return
      }
      case CommandType.production: {
        const setCommand = `firebase functions:config:set ${topLevelKey}=${JSON.stringify(JSON.stringify(runtimeConfig))}`;
        console.log(`Running '${setCommand}' command....`);
        await execAsync(setCommand);
        return
      }
      default: {
        const unknownCommand: never = command;
        throw new Error(`The case for ${unknownCommand} is not described.`);
      }
    }
  } catch (e) {
    console.error(e);
    throw e
  }
})().catch(() => {
  process.exit(1);
});

このスクリプトは以下のように、用途によって使い分けられるようにしました。

# emulator用のRuntime ConfigurationのJSONを標準出力として出力
$ STAGE='prod' ts-node functionConfig.ts emulator > /path/to/function-source-directory/.runtimeconfig.json
# Cloud FunctionsにRuntime Configurationを設定
$ STAGE='prod' ts-node functionConfig.ts production

この処理のデメリットとしては、Runtime Configurationのキー名を変更した場合、値が残り続けてしまう点です。

値を残さないために、差分を計算してRuntime Configurationをunsetできますが、

  • デプロイ前にunsetすると、デプロイ間際でのユーザー影響が出る(無停止デプロイができない状態)
  • デプロイ後にunsetすると、別プロセスでsetおよびunsetが実行されたりするケースなどを考慮する必要がある

という問題があります。

今回は短期間なプロジェクトであるため、トップレベルのキー(stageconfig)の変更はないと考え、unset処理は入れませんでした。

ちなみに、NODE_ENVはNext.jsですでに使われていたため、余計な不具合を避けたいと考え、代わりにSTAGEという環境変数を利用して、環境ごとの設定をするようにしました。

デプロイのしくみ

ライブ配信サイトはGitHubを使用して開発していました。 そのため、デプロイに関しては導入が容易なGitHub Actionsで行うことにしました。

開発は、mainリポジトリに対してPull Requsetを出して、CIとレビューが通ればマージするというスタイルを取っていました。 そのため、mainリポジトリに変更があったら、自動で開発環境にデプロイし、開発版がいつでも確認できるようにしていました。

さらに、本番環境に関しては、GitHub Actionsのworkflow_dispatch機能を使用して、任意のタイミングでデプロイできるようにしました。

GitHub Actionsでのworkflow_dispatchでのデプロイ

GitHub Actionsと周辺ファイル設定

具体的には以下のようにActionを定義しました。

name: Deploy
on:
  push:
    branches:
      - main
  workflow_dispatch:
    inputs:
      stage:
        description: 'support only "prod" or "dev"'
        required: true
        default: 'prod'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: 14
          cache: 'npm'
      - name: Install Dependencies
        run: npm ci
      - name: Deploy to Firebase
        run: |
          npx firebase use "${STAGE}"
          npm run deploy -- --force
        env:
          FIREBASE_TOKEN: ${{ github.event.inputs.stage == 'prod' && secrets.FIREBASE_TOKEN_PROD || secrets.FIREBASE_TOKEN_DEV }}
          STAGE: ${{ github.event.inputs.stage == 'prod' && 'prod' || 'dev' }}

step内のスクリプトが煩雑にならないように、Firebaseの環境の選択とnpm scriptsで用意したデプロイコマンドを実行するというシンプルな構成にしました。

STAGE変数の値でFirebaseのプロジェクトを選択できるようにするため、.firebasercprojectsのキーと対応するように設定しました。

{
  "projects": {
    "dev": "sample-project-id-for-dev",
    "prod": "sample-project-id-for-prod"
  }
}

また、参考程度ですがnpm scriptsは、以下のように設定しました。

{
  "scripts": {
    "functions:config": "...",
    "build": "...",
    "predeploy": "npm run build && npm run functions:config -- production",
    "deploy": "firebase deploy"
  }
}

ハマったこと

Callable HTTPS Functionを使用するとCORSエラーとなる

初回デプロイ時に、動作確認をしていると、Callable HTTPS Functionを呼び出した際に、CORSエラーとなりました。 原因は、Cloud Functionsが任意のユーザーが呼び出せる様になっていなかったためです。

Callable HTTPS Functionが呼び出しができない状態 at GCPコンソール

この問題は、Cloud Functions 起動元ロールにallUsersプリンシパルを設定した権限を、該当のCloud Functionsに付与することで解決しました。

Callable HTTPS Functionが呼び出しができる状態 at GCPコンソール

しかし、別のProjectでは再現しなかったため、なぜ権限が付与されていなかったのかの原因まではわかりませんでした。 もしわかる方がいらっしゃいましたらご教示ください。

終わりに

当日はライブ配信サイト自体は大きな問題もなく、無事2日間カンファレンスを開催できました。 もちろん、完璧ではなくいくつもの反省点がありましたが、それらは来年以降、改善したいと考えています。

また、当日参加できなかった方はアーカイブを販売しているので、ぜひこの機会にご覧ください。