新進気鋭の認証サービスClerkを実プロダクトで使いこなせ

はじめに

こんにちは。プレイドでWicleを開発しているchakeです。
Wicleはプロダクトを使うユーザーの行動を分析し、改善するサイクルを助けるプロダクトアナリティクスサービスです。先日パブリックベータ版をリリースし、だれでもオンライン上からサインアップすることができるようになりました。詳細はこちらをご覧ください。

本ブログではWicleの認証を支えるClerkについて、採用してよかったことやハマった点などを中心に紹介します。
また、2024年9月現在、Clerkをdevelopment環境で利用した紹介記事は多くある一方で、production環境で実際に利用した記事はあまりないため、実運用までに必要なもろもろの準備などについても解説していきます。

Clerkとは

ClerkはClerk, Inc.が提供するReact、Next.jsなどのモダンウェブ向けに作られた認証・認可サービスです。Next.js、Remixなどのフレームワークに特化したSDKを提供しており、高機能かつ高品質な認証処理を簡単に実装することができます。
Clerkを類似サービスのAuth0とプライシングの観点で比べると無料枠のMAUが多く、MAU超過分についても比較的安価に抑えられるという点がメリットとして挙げられます。Firebase Authenticationやsupabaseの方が安く無料枠も大きいですが、これらはAuth0やClerkを使う場合に比べると実装コストやメンテナンスコストが発生しやすいと考えています。

私がClerkの存在を知ったのは2023年のVercel Shipのことで、当時はまだ認可周りを中心に機能面で物足りず、また国内外での採用事例も少なく、実プロダクトで使うには不安があるという印象でした。そこから1年以上が経ちましたが、Clerkは会社としてはシリーズBの資金調達を行い、プロダクトとしては日々急速に機能が追加されアップデートし続けています。個人的には技術選定の候補に並べるためのラインは十分に超えていると考えています。

選定当時の状況

Wicleは構想やプロトタイピングから数えると2021年からスタートしており、先日のパブリックリリースまではクローズドで複数社に実際に導入いただき価値検証を行いながら改善を続けてきたという状況でした。
私が入社してひと月経った2024年4月頃から、パブリックリリースに向けて動き始めましたが、パブリック化にあたって解決したい課題がいくつかありました。

まず、当時のWicleはプレイドのコアプロダクトであるKARTEの認証・認可基盤をそのまま使用していました。プロダクトのフェーズとしても認証・認可を実装するよりも機能開発を優先したかったのと、十分に実績のあるKARTEの基盤が使えるなら使わない理由もありませんでした。一方で、エンタープライズレベルの基盤よりもPLG型サービスとしてはライトな基盤で様々な要件に柔軟に素早く変更を重ねるメリットをより重視したいので、認証・認可周りはプロダクト成長のために価値のある投資と考え再構築を決定しました。

もう一つの課題はフロントエンドのリプレースでした。それまでのWicleはVue2で実装されており、パブリックリリースに向けてVue3へのマイグレーションかReactへのリプレースを検討し始めていました。認証サービスを選定する上でこれらプロダクト側の事情を含めて複合的に検討する必要がありました。
リプレースにまつわる各技術選定の詳細は今回は割愛しますが、新しいフロントエンドフレームワークにはRemixを採用しました。サインイン画面や設定画面、アカウントセットアップ画面など既存機能から独立して切り出せる機能はReactで実装し、そうでないものは既存のVueのコードベースに手を加える、ハイブリッドな構成を取りました。今後は段階的に移行できるものはしていきつつ、プロダクトがリプレースコストを払うべき適切なタイミングを見定めて移行を行う予定です。

システム構成

以前のWicleのシステムの前段部分の構成を簡略化したものです。Expressで一通りのAPIを実装しており、また特定のルーティングはVueで実装されたフロントエンドのビルド成果物を配信するWebサーバの役割を担っています。認証が必要なルーティングについてはExpressミドルウェアからKARTE側のサービスを通じて認証します。

chake-wicle10.png

次に、RemixとClerkを導入した現在の構成です。KARTEの認証部分がそのままClerkに置き換わり、新フロントエンドとしてRemixがExpress上で動いています。既存の認証の流れを残したまま、新フロントエンド側ではRemix loader内で認証による制御を行っています。
Wicleのドメイン特性としてSSRが必須ではなかったこともあり、当初は既存の構成に組み込みやすいRemix SPA modeの採用を考えましたが、Clerk側がSPA modeに未対応だったためRemixサーバを動かす形に落ち着きました。なお、ClerkのRemix SPA modeの対応は先日サポートされました。

仮に検討当時にSPA modeがサポートされていて採用していた場合は、Remix Serverの部分がVueと同じようにRemixでビルドした静的なアセットをExpressから配信する形に変わるだけです。なんらかの理由でSSRサーバの運用に対し前向きになれないケースでは、後に必要になったときの移行のしやすさからもSPA modeは十分選択肢に挙がると考えていますが、サーバランタイムを前提とした一部のAPIが使えないことには注意が必要です。

chake-wicle09.png

また図の通り、APIサーバへのリクエストはすべてブラウザ側からの経路に集約しており、ブラウザ側ではVueとReactの2枚のSPAが動き、Clerkによって認証チェックが行われるpublicなAPIがいるシンプルな形になっています。
Remix側ではloader/action内でClerkによる認証を行っているため、(既存のAPIをinternal化してしまって)サーバ側でAPIを叩くという構成にすることもできます。一方で、internal化する場合、既存のVue側からの経路にRemixサーバを通さなければならず、APIをプロキシするだけのエンドポイントが必要になるなどコードベースが冗長になる可能性がありました。それぞれ経路を分ける形もありえますが、それによって得られるメリットがそれほど大きくなかったことと全体の構成に対するキャッチアップのしやすさが増す懸念から避けました。

Remix on Express

RemixアプリケーションはExpressやCloudflareなどどこにでもデプロイすることができます。Wicleでは公式が提供するアダプターを使って、Expressの上に乗せています。以下のコード例ではremix-serverディレクトリにremix vite:buildした成果物を配置しています。

import { createRequestHandler } from '@remix-run/express';

const router = express.Router();

router.get(
  '*',
  createRequestHandler({
    build: require('../remix-server'),
    getLoadContext: req => {
      return {
        WICLE_CONTEXT: req.WICLE_CONTEXT
      };
    },
  }),
);

次にAPIエンドポイントをプロテクトするコード例です。Clerkが提供するExpress用の認証ミドルウェアを使います。ミドルウェアを通してreqオブジェクトに認証に関する情報が詰められ、それらを使ってエラーハンドリングを行います。

import { ClerkExpressRequireAuth } from '@clerk/clerk-sdk-node'

router.use([ClerkExpressWithAuth()])

router.use((req, res, next) => {
  if (!req.auth?.sessionId) {
    return res.status(401).json({ message: 'Unauthorized' });
  }
  next();
});

Clerk側で持つアカウント情報と独自DBの紐付け

アカウントのサインアップが行われるとまずClerk側にアカウント情報が登録されます。例えば、メールアドレスやパスワード、Google SSO経由であればアイコン画像などがこれに含まれます。Wicleを含む一般的なWebサービスではこういったアカウントの個人情報にシステム独自の属性を紐づけることが多いですが、Wicleではシビアな扱いが要求される個人情報の管理をClerk側に寄せつつ、Clerkのアカウント情報が持つprimaryなidを独自のアカウント情報に紐づけることでこれを実現しています。

例えば、こちらはWicleのアカウント情報を取得するAPIのサンプルコードです。

export const getAccount = async (accountId: string): Promise<Account | null> => {
  // Wicleが持つアカウント情報を取得する
  const account = await findOneAccount({ id: accountId });

  // Clerkが持つアカウント情報を取得する
  const clerkUser = await getClerkUser(account.clerkUserId);

  // 合わせる
  return {
    id: account.id,
    age: account.age,
    email: clerkUser.email,
    lastName: clerkUser.lastName,
    firstName: clerkUser.firstName,
  };
};

Clerk APIを叩くと地理的な問題で数百msほどRTTが乗るのでなんらかのキャッシュを挟んでおくのをおすすめします。

export async function getClerkUser(
  clerkUserId: string,
  useCache = true,
) {
  const { value } = await getCache(key);

  if (useCache && value) {
    return value;
  }

  const res = await fetchClerkApi(`/users/${clerkUserId}`, {
    method: 'GET',
  });

 setCache(key, result);

  return result;
}
async function fetchClerkApi(path: string, options?: RequestInit) {
  const response = await fetch(`https://api.clerk.com/v1${path}`, {
    ...options,
    headers: {
      ...options?.headers,
      'content-type': 'application/json',
      Authorization: `Bearer ${process.env.CLERK_SECRET_KEY}`,
    },
  });

  const json = await response.json();

  return json;
}

Clerkコンポーネントのi18n対応

Wicleは多言語対応がマスト要件だったので、Clerkのビルトインコンポーネントが簡単にi18n対応できるかという点は選定段階で考慮するポイントの一つでした。当初の懸念としてはLocalization propがexperimentalなステータスであることでしたが、実際に検証してみると、翻訳の精度は低いものの独自にカスタマイズ可能なので問題にならないという結論になりました。

例えば、Clerkが提供する<SignIn />コンポーネントを一行書くだけで画像のようなよくあるサインインUIを実現することができます。

import { SignIn as ClerkSignIn } from '@clerk/remix';

export function SignIn() {
  return <ClerkSignIn />
}

chake-wicle03.png

これら便利コンポーネントを使う前準備として、ClerkをRemixで利用するためにルートコンポーネントをClerkが提供するproviderでラップする必要があります。こちらは公式のチュートリアルのコードです。

import { ClerkApp } from '@clerk/remix'
import { rootAuthLoader } from '@clerk/remix/ssr.server';

export const loader: LoaderFunction = (args) => rootAuthLoader(args)

function App() {
  return (
    <html lang="en">...</html>
  )
}

export default ClerkApp(App)

ここではreact-i18nextなどでアプリケーションの多言語対応がすでに行われていることを前提とします。ClerkApplocalizationオブジェクトを受け取るインターフェースを持ちますが、Appの外にいるためこのままでは動的な値を渡すことができません。

export const loader = (args: LoaderFunctionArgs) => {
  return rootAuthLoader(args, async () => {
    const locale = url.searchParams.get('lang');

    if (locale !== 'ja' && locale !== 'en') {
      const locale = await i18next.getLocale(args.request);
      return json({ locale });
    }

    return json({ locale });
  });
};

function App() {
  const data = useLoaderData<typeof loader>();
  useChangeLanguage(data.locale);

  const { i18n } = useTranslation();

  return (
    <html lang={data.locale} dir={i18n.dir()}>...</html>
  )
}

export default ClerkApp(App);

これはClerkProviderをRemix用にラップしたClerkAppを使わず、素のClerkProviderを使うことで解決することができます。useLoaderDataの戻り値に含まれるclerkStateをそのまま中継しつつ、localizationprops経由で言語情報を渡します。

import { ClerkProvider } from '@clerk/remix';

export function App() {
  const data = useLoaderData<typeof loader>();
  useChangeLanguage(data.locale);

  const { i18n } = useTranslation();

  const clerkState = data.clerkState;
  const clerkLocale = data.locale === 'ja' ? clerkJaJP : { localization: 'en-US' };

  return (
    <html lang={data.locale} dir={i18n.dir()}>
      <ClerkProvider localization={clerkLocale} clerkState={clerkState}>
        ... 
      </ClerkProvider>
    </html>
  )
}

// export default ClerkApp(App);

翻訳の独自カスタマイズについては、Clerkの日本語翻訳をベースとしつつ、独自に定義で上書きすることができます。Clerkの日本語の言語ファイルはこちら

import { jaJP } from '@clerk/localizations';
import type { LocalizationResource } from '@clerk/types';

export const clerkJaJP: LocalizationResource = {
  ...jaJP,
  socialButtonsBlockButton: '{{provider|titleize}}でサインイン',
  backButton: '戻る',
}

Clerk JSバージョン固定

Clerkはブラウザ側での動作のためにclerk.<your-domain>にホスティングされた@clerk/clerk-jsをロードします。このバージョンはデフォルトで最新バージョンを取りに行くので、本番環境では動作の安定のために固定化します。特に<SignIn />などのClerkの組み込みコンポーネントを使っている場合はメジャーバージョンによってはUIが変わります。設定方法は環境変数に設定するだけです。

// .env
CLERK_JS_VERSION=5.20.0

v4.73.6

chake-wicle04.png

v5.20.0

chake-wicle05.png

Clerkのproduction環境の設定

公式ドキュメント記載の通りに行います。

  1. ドメインを用意する
  2. ドメインにDNSレコードを追加する
  3. Clerkダッシュボード上で各プロバイダーごとにOAuth認証情報を設定する

追加するDNSレコードはClerk Frontend APIAccount Portalを使うためにCNAMEレコードをそれぞれ1つずつ、各種Clerkからのメール送信を行うためにCNAMEレコード3つの計5つです。WicleではterraformでAWS Route53の設定を行なっているため、以下のようになります。

resource "aws_route53_record" "clerk_wicle_io" {
  zone_id = aws_route53_zone.wicle_io.zone_id
  name    = "clerk.${aws_route53_zone.wicle_io.name}"
  type    = "CNAME"
  ttl     = 60
  records = ["frontend-api.clerk.services"]
}
resource "aws_route53_record" "accounts_wicle_io" {
  zone_id = aws_route53_zone.wicle_io.zone_id
  name    = "accounts.${aws_route53_zone.wicle_io.name}"
  type    = "CNAME"
  ttl     = 60
  records = ["accounts.clerk.services"]
}
resource "aws_route53_record" "clk_domainkey_wicle_io" {
  zone_id = aws_route53_zone.wicle_io.zone_id
  name    = "clk._domainkey.${aws_route53_zone.wicle_io.name}"
  type    = "CNAME"
  ttl     = 60
  records = ["dkim1.xxxxxxxxxx.clerk.services"]
}
resource "aws_route53_record" "clk2_domainkey_wicle_io" {
  zone_id = aws_route53_zone.wicle_io.zone_id
  name    = "clk2._domainkey.${aws_route53_zone.wicle_io.name}"
  type    = "CNAME"
  ttl     = 60
  records = ["dkim2.xxxxxxxxxx.clerk.services"]
}
resource "aws_route53_record" "clkmail_wicle_io" {
  zone_id = aws_route53_zone.wicle_io.zone_id
  name    = "clkmail.${aws_route53_zone.wicle_io.name}"
  type    = "CNAME"
  ttl     = 60
  records = ["mail.xxxxxxxxxx.clerk.services"]
}

迷惑メール対策

Clerkにはサインイン時の認証メールの送信やパスワードの変更時の通知、招待メールの送信などの機能があり、Clerkが管理する専用のIPアドレスプールを使用して、Sendgridでメールを送信しています。
WicleではこれらのメールがGmailの迷惑メールに判定されるという問題が起きました。
Clerkのdevelopmentインスタンスではすべてのメールが@accounts.devドメインから送信され、productionインスタンスでは独自のドメイン(wicle.ioなど)から送信されます。この問題は前者では起きず、後者のときのみ発生していました。

こちらのドキュメントを参考にDMARCの設定、ドメインのレピュテーションに対する調査、Clerkのコミュニティやサポートチームに協力を依頼しましたが、最終的にわかったこととしてはこの問題が発生しているユーザーとそうでないユーザーが存在することと、ClerkがSendgridと連携して調査中であるということだけでした。この調査中というステータスは数ヶ月変わっていなかったため、一旦諦めてメール送信にResendを使うことにしました。

Clerkはメール送信やアカウント作成や削除など主要な操作についてのwebhookがあるため、これを使います。

const router = express.Router();

type ClerkWebhookEvent = {
  data: Record<string, any>;
  object: 'event';
  type: 'email.created';
};

router.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req: Request, res: Response) => {
    // シグネチャの検証
    const svixId = req.headers['svix-id'] as string;
    const svixTimestamp = req.headers['svix-timestamp'] as string;
    const svixSignature = req.headers['svix-signature'] as string;

    if (!svixId || !svixTimestamp || !svixSignature) {
      return res.status(400).send('Invalid request');
    }

    const svixHeaders = {
      'svix-id': svixId,
      'svix-timestamp': svixTimestamp,
      'svix-signature': svixSignature,
    };

    const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET_KEY as string);

    let evt: ClerkWebhookEvent | null = null;

    try {
      evt = wh.verify(req.body, svixHeaders) as ClerkWebhookEvent;
    } catch (error: any) {
      return res.status(400).send('Invalid request');
    }

    // typeがemail.createdの場合はメール送信を行う
    if (evt.type === 'email.created') {
      // Clerk側でメール送信を行う場合はスキップ
      if (evt.data.delivered_by_clerk) {
        return res.status(200).send('OK');
      }

      const resend = new Resend(process.env.RESEND_SECRET_KEY);

      const subject = evt.data.subject;
      const toEmailAddress = evt.data.to_email_address;
      const body = evt.data.body;
      const bodyPlain = evt.data.body_plain;

      try {
        await resend.emails.send({
          from: `Wicle <notifications@wicle.io>`,
          to: [toEmailAddress],
          subject: subject,
          html: body,
          text: bodyPlain,
        });
      } catch (error: any) {
        return res.status(500).send('Internal Server Error');
      }
    }

    return res.status(200).send('OK');
  },
);

staging環境の申請

2024年9月現在、Clerkではアプリケーションという単位ごとにdevelopmentインスタンスとproductionインスタンスの二つしか提供されていないため、staging環境相当の環境がある場合には二つ目のアプリケーションを作成しなければなりません。しかし、課金単位はアプリケーションのため、production用のアプリケーションで有料プランを契約した場合、staging環境用は無料プランのままになり、機能面で制限されることになります。
この場合でも、staging環境のために2倍の課金をする必要はなく、Clerkサポートチームに問い合わせることで無料でアップグレードしてもらえます

SOC2レポート

プレイドでは個人情報を保存するサービスを自社のプロダクトで使う場合、そのサービスに対してリーガル面での確認を行なっています。このプロセスの中でClerkのSOC2レポートの内容を確認したく、Clerkのサポートチームに問い合わせました。これについてはClerkのドキュメントには記載はありませんでしたが、迅速な対応をしてもらい無事にSOC2レポートを受け取ることができました。、Clerkチーム、コミュニティにはこれ以外の多くの面でも協力していただきました。

まとめ

このブログでは、Clerkを本番環境で運用している(まだ少なそうな)国内の事例の一つとして、ハマったポイントやtipsを紹介しました。Clerkに興味があるけど採用事例が少なくて不安だなという方はぜひ参考にしてみてください。

最後に、プロダクトアナリティクスサービス「Wicle」をプロダクト開発に携わるすべての方に使ってもらえると嬉しいです。