KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

新認証基盤への移行コストを最小化するフロントエンドリファクタリング

こんにちは。AI在庫管理チームでソフトウェアエンジニアをしている江藤です。 現在、カケハシでは認証基盤を新しくしていて、AI在庫管理チームではその新しい認証基盤(以下、「認証ポータル」と呼びます)への移行準備を進めています。

今回は、AI在庫管理のフロントエンドにおける認証方式の移行に際して、切り替え時のコード修正コストを最小化するためにどのように実装したかを紹介します。 この記事が次の2点について考えるきっかけになれば幸いです。

  • 移行しやすい実装の作り方: ライブラリ固有の処理をアプリから切り離し、差し替え可能にする考え方
  • feature flag の使いどころ: 切り替えロジック(分岐)を最小限の箇所に閉じ込める進め方

こちらの記事はカケハシ Advent Calendar 2025 の 20日目の記事です。

はじめに

AI在庫管理では認証基盤としてもともとAmazon Cognitoを利用しており、そのクライアントライブラリである @aws-amplify/auth を用いた認証処理を行っていました。 当時はライブラリの処理をラップせず、各処理箇所で直接インポートして実装していました。

認証ポータル移行後は oidc-client-ts ライブラリを使用する想定で、移行期間は feature flag(機能フラグ)で段階的に切り替えたい、という事情もありました。 しかし当時の実装のままだと、いくつものファイルにフラグによる if 分岐を書く必要があり、切り替えのコストが大きくなってしまいます。

そこで、認証ポータル移行フラグの使用箇所を 1箇所にまとめるため、認証に関する処理を共通インターフェース化するところから始めました。

最終的には下記画像のような状態を目指します。

共通インターフェース化

まずはアプリケーションで使用している認証関連の処理を洗い出し、型を定義します。今回は AuthManager という名前にしました。

サインイン系の関数は認証方式によって異なるため、インターフェースになくてもよいのですが、人によっては意図が伝わりにくいので含めた方がわかりやすいと考えました。 kind については後述しますが、アプリケーションで使用する AuthManager の種別を指定するために使います。

type AuthManagerInterface = Readonly<{
  kind: 'cognito' | 'authnportal';
  configure: () => void;
  signIn: (username: string, password: string) => Promise<void>;
  signInRedirect: () => Promise<void>;
  signInCallback: () => Promise<void>;
  signOut: () => Promise<void>;
  getToken: () => Promise<string>;
  isLoggedIn: () => Promise<boolean>;
  subscribe: (callback: (event: 'signOut' | 'tokenExpired') => void) => () => void;
}>;

このインターフェース通りに従来の実装をリファクタリングしていきます。 各認証方式で使用しないサインイン関数では単純にエラーを投げるだけの実装とします。

import * as Auth from '@aws-amplify/auth';
import { cognitoUserPoolsTokenProvider } from '@aws-amplify/auth/cognito';

const createCognitoAuthManager = (userPoolId: string, userPoolClientId: string) => {
  // ライブラリの初期セットアップ
  const configure = () => {
    cognitoUserPoolsTokenProvider.setAuthConfig({
      Cognito: {
        userPoolId,
        userPoolClientId,
      },
    });
    Amplify.configure(
      {
        Auth: {
          Cognito: {
            userPoolId,
            userPoolClientId,
          },
        },
      },
      { Auth: { tokenProvider: cognitoUserPoolsTokenProvider } },
    );
  };

  const signIn = async (username: string, password: string) => {
    await Auth.signIn({ username, password });
  };

  // signInRedirect, signInCallback は認証ポータルでしか使用しないため、ここではエラーにする
  const signInRedirect = async () => {
    throw new Error('Not implemented');
  };
  const signInCallback = async () => {
    throw new Error('Not implemented');
  };

  // ... 各関数の実装

  return {
    kind: 'cognito',
    configure,
    signIn,
    signInRedirect,
    signInCallback,
    signOut,
    getToken,
    isLoggedIn,
    subscribe,
  } as const satisfies AuthManagerInterface;
};

合わせて、認証ポータル用の AuthManager も実装します。

import { UserManager } from 'oidc-client-ts';

const createAuthnPortalAuthManager = () => {
  let userManager: UserManager | null = null;

  const createUserManager = (): UserManager => {
    return new UserManager({/* 設定 */});
  };

  const getUserManager = () => {
    if (!userManager) {
      throw new Error('User manager is not created');
    }
    return userManager;
  };

  const configure = () => {
    userManager = createUserManager();
  };

  // signIn は認証ポータルでは使用しないため、ここではエラーにする
  const signIn = async (_username: string, _password: string) => {
    throw new Error('Not implemented');
  };

  const signInRedirect = async () => {
    await getUserManager().signinRedirect();
  };

  const signInCallback = async () => {
    await getUserManager().signinCallback();
  };

  // ... 各関数の実装

  return {
    kind: 'authnportal',
    configure,
    signIn,
    signInRedirect,
    signInCallback,
    signOut,
    getToken,
    isLoggedIn,
    subscribe,
  } as const satisfies AuthManagerInterface;
};

アプリケーションに組み込む

次に、各認証処理を行っている箇所を AuthManager を使う形に書き換えていきます。 ただし、このままだと「いまどちらの AuthManager を使うべきか」がわかりません。 そこで、現在の認証方式を判別し、適切な AuthManager を返す仕組みを用意します。

let authManager: AuthManagerInterface | null = null;

const AUTH_MANAGER_KIND_KEY = 'auth_manager_kind';
const USER_POOL_ID = '...';
const USER_POOL_CLIENT_ID = '...';

export function initializeAuthManager() {
  const managerKind = localStorage.getItem(AUTH_MANAGER_KIND_KEY);
  switch (managerKind) {
    case 'cognito': {
      // userPoolId / userPoolClientId はアプリの設定値(環境変数など)から注入する想定
      setAuthManager(createCognitoAuthManager(USER_POOL_ID, USER_POOL_CLIENT_ID));
      return;
    }
    case 'authnportal': {
      setAuthManager(createAuthnPortalAuthManager());
      return;
    }
    default: {
      // 初回起動など、まだ保存されていない場合は何もしない
      return;
    }
  }
}

export function setAuthManager(manager: AuthManagerInterface) {
  manager.configure();
  localStorage.setItem(AUTH_MANAGER_KIND_KEY, manager.kind);
  authManager = manager;
}

export function getAuthManager(): AuthManagerInterface {
  if (!authManager) {
    throw new Error('Auth manager not initialized');
  }
  return authManager;
}

initializeAuthManager はローカルストレージに設定された値をもとに、直近使用されていた AuthManager を判別します。 この関数はアプリケーションの起動時に一度実行するだけでよいので、アプリケーションのエントリーファイルなどで呼び出します。

setAuthManager はログイン時に、これから使用する AuthManager を設定するために使用します。

const signin = async () => {
  // ログイン時は `getAuthManager()` ではなく、これから使う `AuthManager` を生成して設定する
  const authManager = createAuthnPortalAuthManager();
  // ログイン後の認証関連の処理で `getAuthManager()` から取得できるようにする
  setAuthManager(authManager);
  await authManager.signInRedirect();
};

これで各認証処理を行っている箇所は、下記のように getAuthManager() から AuthManager を取得し、共通化された関数を呼び出すだけになります。

// トークンを取得する処理の例
const token = await getAuthManager().getToken();

feature flag を用いた認証方式の切り替え

各ファイルに散らばっていたライブラリ固有の処理は、すべて AuthManager インターフェースで共通化できました。 そのため、認証方式を切り替えるために feature flag を使用する箇所は、ログインコンポーネントの切り替え部分だけでよくなります。

const LoginComponent = () => {
  // feature flag を取得
  const enableAuthnPortal = getFeatureFlags();
  return enableAuthnPortal ? <AuthnPortalLogin /> : <CognitoLogin />;
};

まとめ

今回紹介した内容は、要するに「ライブラリ固有の処理を薄いレイヤーでラップして、アプリケーションから切り離すと移行がしやすくなる」というシンプルな話です。 しかし、この分離が効いてくる場面は多く、実際の効果はかなり大きいと感じています。

認証のようにアプリケーション全体に影響が広がる関心事ほど、ライブラリの呼び出しが各所に散らばると「変更コスト」も「理解コスト」も一気に跳ね上がります。 一方で、アプリケーションが触れるのをインターフェース(今回だと AuthManager)だけに揃えておけば、実装の入れ替えはその裏側で完結でき、feature flag での切り替えも境界(ログイン導線など)に閉じ込められます。

まずは「ライブラリを直接触っている箇所」を洗い出し、最小限の責務でインターフェースを切り出して、呼び出し側を段階的に置き換えるところから始めるのがおすすめです。 将来の移行だけでなく、テストのしやすさや設計の見通しの良さにも効いてくるので、長く運用するプロダクトほど投資価値が高いアプローチだと思います。