RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

マルチフロントエンド環境での統一認証基盤を実現する内部パッケージの設計と実装

はじめまして。エンジニアの小川です。弊社のAIマーケティングチームにてフルスタックで開発を行っています。
本記事では、マルチフロントエンド環境における認証基盤の課題とその解決策として開発した@novasell/auth-kitパッケージの設計思想と実装について詳しく解説します。

はじめに

弊社のAIマーケティングプラットフォームでは顧客ごとの要件や影響範囲を考慮して、機能別に独立したフロントエンドアプリケーションを構築しています。

このようなマルチフロントエンド環境では認証認可の導入において将来的に以下のような課題が見えていました:

  • 認証ロジックの重複実装によるメンテナンスコストの増大
  • 認可設定の一元管理の困難さ
  • Auth0設定の分散による設定ミスのリスク

これらの潜在的な課題を先回りして解決するため、私たちは@novasell/auth-kitという内部パッケージを開発しました。

システム構成と背景

マルチフロントエンド構成を採用した理由

私たちのプラットフォームが複数のフロントエンドアプリケーションで構成されているのには、いくつかの重要な理由があります:

  1. 顧客ごとのカスタマイズ性: 各顧客が必要とする機能だけを有効化し、不要な機能による複雑性を避ける
  2. 独立したリリースサイクル: 機能ごとに異なる開発速度やリリース頻度に対応
  3. 影響範囲の最小化: 一つの機能の変更が他の機能に影響を与えないアーキテクチャ

現在の構成は以下のようになっています:

frontend/
├── .env                  # 環境変数(全アプリで共有)
├── app_a/                # メイン機能アプリケーション
├── app_b/                # サブ機能アプリケーション
├── portal/               # ポータルサイト
├── dashboard/            # 分析ダッシュボード
└── packages/
    ├── auth-kit/         # 認証基盤パッケージ
    └── api-client/       # API通信パッケージ

このような構成では、各アプリケーションが独立している一方で、認証のような横断的な機能をどう実装するかが重要な課題となります。すべてのアプリケーションで個別に認証を実装すると、コードの重複や設定の不整合といったリスクがあります。

Auth0導入の経緯

すでに社内の他プロダクトではAuth0を使用した認証基盤が稼働していました。プロダクト間でのSSOを実現し、ユーザー体験を統一するため、このプラットフォームでも同じAuth0テナントを使用することにしました。

実現したかったこと

1. 柔軟な認可設定

アプリケーションが複数存在するため、以下のような認可制御が必要でした:

  • アプリレベル認可: ユーザーごとにアクセス可能なアプリケーションを制御
  • 認証のみモード: 一部のページでは認証のみで認可チェックをスキップ
  • ページ単位の制御: ページごとに異なる認証・認可レベルを設定

2. 開発者体験の向上

フロントエンド開発者が簡単に認証機能を導入できるよう、以下を実現しました:

  • 2つの導入パターン: シンプルな一括設定と細かい制御が可能な個別設定
  • TypeScript完全対応: 型安全な実装
  • 環境変数の集約: Auth0関連の設定を一元管理

3. セキュリティとユーザー体験

認証後のユーザー体験とセキュリティを両立させるため:

  • セキュアなリダイレクト: ログイン後、元々アクセスしようとしていたページへ安全に自動遷移
  • URL検証: ホワイトリスト方式でリダイレクト先を検証し、オープンリダイレクト脆弱性を防止

実装の詳細

コア実装

1. AuthProvider - 基盤となる認証プロバイダー

AuthProviderは、Auth0のSDKをラップし、アプリケーション全体に認証機能を提供する最も基礎的なコンポーネントです。このコンポーネントの主な責務は以下の通りです:

  • Auth0 SDKの初期化と設定
  • ログイン後のリダイレクト処理
  • セッション管理機能の統合
// src/AuthProvider.tsx
export const AuthProvider = ({ children }: AuthProviderProps) => {
  // ログイン後のリダイレクト処理
  const onRedirectCallback = (appState?: { returnTo?: string }) => {
    const fallback = getAllowedOrigin();
    const returnTo = appState?.returnTo || fallback;
    // セキュアなURL検証を行い、不正なリダイレクトを防止
    const safeReturnTo = getSafeReturnToUrl(returnTo);
    window.location.href = safeReturnTo;
  };

  return (
    <Auth0Provider
      domain={auth0Config.domain}
      clientId={auth0Config.clientId}
      authorizationParams={auth0Config.authorizationParams}
      useRefreshTokens={auth0Config.useRefreshTokens}
      cacheLocation={auth0Config.cacheLocation}
      onRedirectCallback={onRedirectCallback}
    >
      {children}
    </Auth0Provider>
  );
};

2. ProtectedRoute - 細粒度のアクセス制御

ProtectedRouteコンポーネントは、ページやコンポーネント単位で認証・認可を制御する重要な役割を担っています。このコンポーネントの特徴は、認証のみのチェックと、アプリケーションレベルの認可チェックを柔軟に切り替えられる点です。

// src/ProtectedRoute.tsx
export const ProtectedRoute = ({
  appName,
  children,
  authenticationOnly,  // 認証のみか、認可も必要か
  accessDeniedRedirectUrl,
  loadingComponent,
  errorComponent
}: ProtectedRouteProps) => {
  const { isLoading, isAuthenticated, error, login } = useAuth();
  const appAccessStatus = useAccessibleApp(appName);
  const loginInitiated = useRef(false);

  // 未認証の場合、自動的にログインページへ
  useEffect(() => {
    if (!isLoading && !isAuthenticated && !loginInitiated.current) {
      const returnTo = window.location.href;
      loginInitiated.current = true;
      login(returnTo); // 現在のURLを保持してログイン
    }
  }, [isLoading, isAuthenticated, login]);

  // ... 状態に応じた画面表示ロジック
};

3. AppAuthProvider - シンプルな一括設定

開発者の利便性を考慮して、AuthProviderとProtectedRouteを組み合わせたAppAuthProviderコンポーネントを提供しています。これにより、1つのコンポーネントでアプリケーション全体を保護できます:

// src/AppAuthProvider.tsx
export const AppAuthProvider = ({
  children,
  appName,
  authenticationOnly = false,
  accessDeniedRedirectUrl = `${window.location.origin}/access-denied`,
  loadingComponent,
  errorComponent
}: AppAuthProviderProps) => (
  <AuthProvider>
    <ProtectedRoute
      appName={appName}
      authenticationOnly={authenticationOnly}
      accessDeniedRedirectUrl={accessDeniedRedirectUrl}
      loadingComponent={loadingComponent}
      errorComponent={errorComponent}
    >
      {children}
    </ProtectedRoute>
  </AuthProvider>
);

認可システムの実装

アプリケーションレベルの認可は、バックエンドAPIと連携して実現しています:

// src/useAccessibleApp.ts
import { useUser } from './useUser';
import { type UserResponse } from '@novasell/api-client';

export type AppAccessStatus =
  | { status: 'checking' }
  | { status: 'granted', user: UserResponse }
  | { status: 'denied', user: UserResponse }
  | { status: 'error', error: Error };

export const useAccessibleApp = (appName: string): AppAccessStatus => {
  const { user, error } = useUser();

  if (!user) return { status: 'checking' };
  if (error) return { status: 'error', error };

  // ユーザーのアクセス可能アプリリストをチェック
  const hasAccess = user.accessible_apps.some(app => app.name === appName);

  return hasAccess
    ? { status: 'granted', user }
    : { status: 'denied', user };
};

環境変数の統一管理

auth-kitの大きな特徴の一つが、環境変数の統一管理です。Viteのビルド設定を工夫することで、パッケージ内からfrontendディレクトリのルートにある環境変数ファイルを読み込めるようにしました:

// vite.config.ts
export default defineConfig(({ mode }) => {
  // 現在のディレクトリから読み込み
  const currentEnv = loadEnv(mode, process.cwd(), '')
  // 一個上の階層から読み込み
  const parentEnv = loadEnv(mode, path.resolve(__dirname, '../'), '')

  // 環境変数をマージ
  const env = { ...parentEnv, ...currentEnv }

  return {
    // ... その他の設定
    define: {
      // VITE_で始まる環境変数をすべて定義
      ...Object.keys(env).reduce((prev: Record<string, string>, key) => {
        if (key.startsWith('VITE_')) {
          prev[`import.meta.env.${key}`] = JSON.stringify(env[key]);
        }
        return prev;
      }, {})
    }
  };
});

これにより、各アプリケーションは個別にAuth0の設定を持つ必要がなくなり、frontendディレクトリルートの.envファイルで一元管理できます:

# frontend/.env
VITE_AUTH0_DOMAIN=your-tenant.auth0.com
VITE_AUTH0_CLIENT_ID=your-client-id
VITE_AUTH0_AUDIENCE=https://api.example.com
VITE_AUTH0_REDIRECT_URI=http://localhost:3000

パッケージ側では、これらの環境変数を読み取って設定オブジェクトを生成します:

// src/config.ts
const getAuth0Config = (): Auth0Config => {
  const domain = import.meta.env.VITE_AUTH0_DOMAIN;
  const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
  const audience = import.meta.env.VITE_AUTH0_AUDIENCE;

  // 必須設定値の検証
  if (!domain) throw new Error('VITE_AUTH0_DOMAIN is required');
  if (!clientId) throw new Error('VITE_AUTH0_CLIENT_ID is required');
  if (!audience) throw new Error('VITE_AUTH0_AUDIENCE is required');

  return {
    domain,
    clientId,
    authorizationParams: {
      redirect_uri: import.meta.env.VITE_AUTH0_REDIRECT_URI || '',
      audience,
      scope: 'openid profile email offline_access',
    },
    ...
  };
};

実際の利用方法

パターン1: AppAuthProvider(シンプルな一括設定)

アプリケーション全体に認証・認可を適用する場合に最適です。このパターンは、すべてのページで認証が必要なアプリケーションや、公開ページが存在しないアプリケーションに適しています:

// frontend/app_a/src/App.tsx
import { AppAuthProvider } from "@novasell/auth-kit";

const App = () => (
  <QueryClientProvider client={queryClient}>
    <AppAuthProvider appName="app_a">
      <TooltipProvider>
        <HashRouter>
          <Routes>
            <Route path="/" element={<Index />} />
            <Route path="*" element={<NotFound />} />
          </Routes>
        </HashRouter>
      </TooltipProvider>
    </AppAuthProvider>
  </QueryClientProvider>
);

このパターンの利点は以下の通りです:

メリット: - 実装の簡潔さ: わずか1つのコンポーネントの追加で認証・認可機能を実装 - 包括的な保護: アプリケーション全体が自動的に保護される - メンテナンスの容易さ: 認証ロジックの変更が一箇所で完結 - 設定の柔軟性: 必要に応じてカスタムローディング画面やエラー画面を設定可能

パターン2: AuthProvider + ProtectedRoute(細かい制御)

公開ページと保護ページが混在するアプリケーションや、ページごとに異なる認証レベルを設定したい場合に適しています。このパターンは、ポータルサイトやコーポレートサイトのように、一部のコンテンツは公開し、一部は認証を必要とする構成に最適です:

// frontend/portal/src/App.tsx
import { AuthProvider, ProtectedRoute } from '@novasell/auth-kit';

function App() {
  return (
    <AuthProvider>
      <Router>
        <Routes>
          {/* 公開ページ - 認証不要 */}
          <Route path="/" element={<HomePage />} />

          {/* 認証のみ必要なページ - ログインユーザーなら誰でもアクセス可 */}
          <Route path="/apps" element={
            <ProtectedRoute
              appName="portal"
              authenticationOnly={true}
              accessDeniedRedirectUrl="/access-denied"
            >
              <AppsPage />
            </ProtectedRoute>
          } />

          {/* 認可も必要なページ - 特定のアプリアクセス権限が必要 */}
          <Route path="/admin" element={
            <ProtectedRoute
              appName="portal"
              authenticationOnly={false}
              accessDeniedRedirectUrl="/access-denied"
            >
              <AdminPage />
            </ProtectedRoute>
          } />

          <Route path="/access-denied" element={<AccessDeniedPage />} />
        </Routes>
      </Router>
    </AuthProvider>
  );
}

このパターンが提供する柔軟性は以下の通りです:

メリット: - ページレベルの制御: 各ページで異なる認証・認可ポリシーを適用 - 段階的な認証: 公開ページ → 認証のみ → 認可必須という段階的なアクセス制御 - UXの最適化: 必要な時だけ認証を求めることで、ユーザー体験を向上 - カスタマイズ性: ページごとに異なるエラー画面やリダイレクト先を設定

API通信との統合

auth-kitは単独で機能するだけでなく、同じく内部パッケージとして開発したapi-clientと密接に連携します。これにより、認証トークンの取得からAPIリクエストへの付与まで、シームレスに処理されます:

// api-clientとの統合例
import { createAuthenticatedApiClient } from '@novasell/api-client';
import { useAuth } from '@novasell/auth-kit';

const useApiClient = () => {
  const { getAccessTokenSilently } = useAuth();

  return createAuthenticatedApiClient({
    baseURL: import.meta.env.VITE_API_URL,
    tokenProvider: getAccessTokenSilently,
  });
};

この統合により、開発者は認証トークンの管理を意識することなく、保護されたAPIエンドポイントにアクセスできます。トークンの更新も自動的に処理されるため、セッションタイムアウトの心配も不要です。

工夫点と技術的な配慮

1. 段階的な導入を可能にする2つのパターン

auth-kitの最大の工夫点は、開発者のニーズに応じて選べる2つの導入パターンを提供していることです。これにより、プロジェクトの規模や要件に関わらず、最適な方法で認証機能を実装できます。

AppAuthProviderパターン(シンプル): - わずか1つのコンポーネントでアプリ全体を保護 - 設定項目を最小限に抑え、デフォルト値で動作 - 新規プロジェクトや小規模アプリケーションに最適

AuthProvider + ProtectedRouteパターン(柔軟): - ページごとに認証・認可レベルを細かく制御 - 公開ページと保護ページの混在が可能 - 既存アプリケーションへの段階的な導入に最適

この2つのパターンにより、以下のメリットを実現しています:

  • 学習コストの最小化: シンプルなケースはシンプルに、複雑なケースは必要に応じて複雑に
  • 段階的な移行: 既存アプリケーションに少しずつ認証を追加可能
  • 柔軟な要件対応: プロジェクトの成長に合わせてパターンを変更可能

2. セキュアなURL検証

認証後のリダイレクト処理は、オープンリダイレクト脆弱性の温床となりやすい部分です。悪意のあるユーザーが、リダイレクト先URLを操作してフィッシングサイトに誘導する可能性があります。

auth-kitでは、ホワイトリスト方式でURLを検証し、安全性を確保しています:

// src/urlValidator.ts
export const getSafeReturnToUrl = (url: string): string => {
  try {
    const urlObj = new URL(url);

    // 許可されたオリジンのみにリダイレクト
    if (allowedOrigins.includes(urlObj.origin)) {
      return url;
    }

    // 不正なURLの場合はデフォルトに
    return getAllowedOrigin();
  } catch {
    return getAllowedOrigin();
  }
};

この実装により、事前に定義された信頼できるドメインへのリダイレクトのみが許可され、外部サイトへの意図しない遷移を防ぎます。

auth-kitが実現した価値

auth-kitパッケージの開発により、マルチフロントエンド環境での認証基盤を効率的に構築できました。

開発効率の向上

  • 迅速な認証導入: 新規アプリケーションでも数分で認証機能を実装可能
  • 学習コストの最小化: Auth0の詳細な知識がなくても適切な認証機能を導入
  • 統一された実装: チーム全体で一貫した認証パターンを使用

運用・保守性の向上

  • 一元管理: 認証ロジックの変更を全アプリケーションへ即座に反映
  • 設定の統一: 環境変数の一元管理により設定ミスを防止
  • 型安全性: TypeScriptによる型定義で実装時のエラーを早期発見

ユーザー体験の向上

  • シームレスなSSO: 複数のアプリケーション間でのスムーズな認証体験
  • 適切なリダイレクト: ログイン後、元々アクセスしようとしていたページへの自動遷移

今後の展望

自動セットアップツールの提供

現在は手動でコンポーネントをラップする必要がありますが、将来的にはCLIツールやコードジェネレーターを提供し、より簡単に認証機能を導入できるようにしたいと思っています:

# 将来のCLI
npx @novasell/auth-kit init --app-name "new-app"

このようなツールにより、ボイラープレートコードの生成や設定ファイルの自動生成が可能になり、さらに開発効率が向上すると考えています。

まとめ

マルチフロントエンド環境における認証・認可の複雑さは、適切な抽象化とパッケージ化により大幅に軽減できます。auth-kitパッケージの開発を通じて、以下の重要な教訓を得ました:

学びと教訓

  1. 初期からの共通化: それぞれで認証機能を実装してしまう前に共通パッケージ化することで、技術的負債の蓄積を防げる
  2. 開発者体験を重視: 2つの利用パターンを提供することで、段階的な導入や様々な要件に柔軟に対応できる
  3. 設定の統一管理: 設定を一箇所で管理することで、アプリケーション間での設定のズレを防止し、Auth0の設定変更時も一度の修正で全体に反映できる

マルチフロントエンド環境での認証基盤構築は決して簡単ではありませんが、適切なアプローチとツールを選択することで、開発効率とユーザー体験の両方を向上させることが可能です。
この記事が、同様の課題に直面している開発チームの参考になれば幸いです。