T-CREATOR

Jotai で認証状態(Auth Context)を管理するベストプラクティス - ログイン状態の保持からルーティング制御まで

Jotai で認証状態(Auth Context)を管理するベストプラクティス - ログイン状態の保持からルーティング制御まで

モダンな Web アプリケーションにおいて、ユーザーの認証状態管理は最も重要な要素の一つですね。ログイン状態の保持、権限チェック、セキュアなルーティング制御など、適切に実装するには多くの考慮事項があります。

従来の Context API や Redux を使った認証管理では、コードが複雑になりがちで、パフォーマンスの問題も発生しやすいのが現実でした。そんな中、Jotai を使った認証状態管理が注目を集めています。

今回は、Jotai を活用した認証システムの実装について、基本的なログイン状態の管理から高度なルーティング制御まで、実際に手を動かしながら学んでいきましょう。

現代の Web アプリにおける認証状態管理の課題

現代の Web アプリケーションでは、認証状態管理において以下のような課題に直面することが多いです。

状態の複雑性とパフォーマンス問題

従来の Context API を使った認証管理では、認証状態が変更されるたびに関連するすべてのコンポーネントが再レンダリングされてしまいます。

typescript// 従来のContext APIの問題例
const AuthContext = createContext<AuthState | null>(null);

const AuthProvider: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [permissions, setPermissions] = useState<string[]>(
    []
  );

  // ユーザー情報が変更されると、全ての子コンポーネントが再レンダリング
  const value = {
    user,
    setUser,
    isLoading,
    setIsLoading,
    permissions,
    setPermissions,
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};

このアプローチでは、ユーザー名だけを表示するヘッダーコンポーネントと、権限チェックを行うボタンコンポーネントが、互いに無関係な状態変更でも再レンダリングされてしまいます。

セキュリティリスクの増大

認証状態の管理が散在していると、以下のようなセキュリティリスクが生じやすくなります。

#リスク項目問題の詳細
1トークンの不適切な保存LocalStorage に平文で保存するリスク
2セッション期限の管理不備期限切れトークンでの不正アクセス
3権限チェックの漏れコンポーネント単位での権限確認漏れ
4XSS 攻撃への脆弱性クライアントサイドでの機密情報露出

コードの保守性とテスタビリティ

認証ロジックが複数のコンポーネントに分散していると、以下の問題が発生します。

typescript// 問題のあるコード例:認証ロジックの分散
const HomePage = () => {
  const { user } = useContext(AuthContext);

  // 各コンポーネントで個別に権限チェック
  if (!user || !user.permissions.includes('read_home')) {
    return <div>アクセス権限がありません</div>;
  }

  return <div>ホームページ</div>;
};

const AdminPage = () => {
  const { user } = useContext(AuthContext);

  // 同様の権限チェックロジックが重複
  if (!user || !user.permissions.includes('admin')) {
    return <div>管理者権限が必要です</div>;
  }

  return <div>管理画面</div>;
};

このように認証チェックが各コンポーネントに散在していると、権限体系の変更時に多くのファイルを修正する必要があり、バグの温床となりがちです。

Jotai で認証状態を管理する 3 つのメリット

Jotai を使った認証状態管理には、従来の手法と比べて以下の 3 つの大きなメリットがあります。

1. 細粒度な状態管理による最適化

Jotai の atomic design により、認証状態を必要最小限の単位に分割できます。

typescript// Jotaiを使った細分化された認証状態
import { atom } from 'jotai';

// ユーザー基本情報
export const userAtom = atom<User | null>(null);

// 認証状態(ログイン済みかどうか)
export const isAuthenticatedAtom = atom(
  (get) => get(userAtom) !== null
);

// ローディング状態
export const authLoadingAtom = atom(false);

// 権限情報
export const userPermissionsAtom = atom<string[]>([]);

// 特定の権限チェック
export const hasAdminPermissionAtom = atom((get) =>
  get(userPermissionsAtom).includes('admin')
);

この設計により、ヘッダーでユーザー名を表示するコンポーネントはuserAtomのみを購読し、権限チェックが必要なコンポーネントはhasAdminPermissionAtomのみを購読できます。関係のない状態変更では再レンダリングが発生しません。

2. 宣言的でテスタブルな認証ロジック

Jotai の atom を使うことで、認証ロジックを宣言的に記述でき、単体テストも簡単になります。

typescript// テスタブルな認証ロジック
export const loginAtom = atom(
  null,
  async (get, set, credentials: LoginCredentials) => {
    set(authLoadingAtom, true);

    try {
      const response = await authApi.login(credentials);

      // トークンの安全な保存
      secureStorage.setToken(response.token);

      // ユーザー情報の設定
      set(userAtom, response.user);
      set(userPermissionsAtom, response.permissions);

      return { success: true };
    } catch (error) {
      console.error('ログインエラー:', error);
      return { success: false, error: error.message };
    } finally {
      set(authLoadingAtom, false);
    }
  }
);

3. セキュアな状態管理の実現

Jotai の特性を活かすことで、セキュリティを重視した認証システムを構築できます。

typescript// セキュアなトークン管理
const tokenAtom = atom<string | null>(null);

// トークンは直接公開せず、必要な時だけアクセス
export const authHeaderAtom = atom((get) => {
  const token = get(tokenAtom);
  return token ? { Authorization: `Bearer ${token}` } : {};
});

// セッション期限チェック
export const isSessionValidAtom = atom((get) => {
  const token = get(tokenAtom);
  if (!token) return false;

  try {
    const payload = JSON.parse(atob(token.split('.')[1]));
    return payload.exp * 1000 > Date.now();
  } catch {
    return false;
  }
});

この設計により、トークン情報は必要最小限の atom でのみアクセス可能になり、セキュリティリスクを大幅に軽減できます。

基本的な認証 atom の実装

それでは実際に、Jotai を使った認証システムの基盤となる atom を実装していきましょう。

型定義の設計

まず、認証システムで使用する型を定義します。

typescript// types/auth.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  createdAt: string;
}

export interface LoginCredentials {
  email: string;
  password: string;
}

export interface AuthResponse {
  user: User;
  token: string;
  refreshToken: string;
  permissions: string[];
}

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

コア atom の実装

認証システムの中核となる atom を実装します。

typescript// atoms/authAtoms.ts
import { atom } from 'jotai';
import {
  User,
  LoginCredentials,
  AuthResponse,
} from '../types/auth';

// プライベートなatomは外部に公開しない
const userAtom = atom<User | null>(null);
const authTokenAtom = atom<string | null>(null);
const refreshTokenAtom = atom<string | null>(null);
const authLoadingAtom = atom(false);
const authErrorAtom = atom<string | null>(null);
const userPermissionsAtom = atom<string[]>([]);

// 読み取り専用の公開atom
export const currentUserAtom = atom((get) => get(userAtom));
export const isAuthenticatedAtom = atom(
  (get) => get(userAtom) !== null
);
export const authLoadingStateAtom = atom((get) =>
  get(authLoadingAtom)
);
export const authErrorStateAtom = atom((get) =>
  get(authErrorAtom)
);

// 権限チェック用の派生atom
export const userPermissionsStateAtom = atom((get) =>
  get(userPermissionsAtom)
);

export const hasPermissionAtom = atom(
  (get) => (permission: string) =>
    get(userPermissionsAtom).includes(permission)
);

認証アクション用 atom の実装

ログイン、ログアウトなどの認証アクションを処理する atom を実装します。

typescript// 続き:atoms/authAtoms.ts

// ログイン処理
export const loginAtom = atom(
  null,
  async (get, set, credentials: LoginCredentials) => {
    set(authLoadingAtom, true);
    set(authErrorAtom, null);

    try {
      // APIへのログインリクエスト
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials),
      });

      if (!response.ok) {
        throw new Error('ログインに失敗しました');
      }

      const data: AuthResponse = await response.json();

      // 認証情報の保存
      set(userAtom, data.user);
      set(authTokenAtom, data.token);
      set(refreshTokenAtom, data.refreshToken);
      set(userPermissionsAtom, data.permissions);

      // セキュアストレージへの保存
      localStorage.setItem('authToken', data.token);
      localStorage.setItem(
        'refreshToken',
        data.refreshToken
      );

      return { success: true, user: data.user };
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : 'ログインエラーが発生しました';
      set(authErrorAtom, errorMessage);
      return { success: false, error: errorMessage };
    } finally {
      set(authLoadingAtom, false);
    }
  }
);

// ログアウト処理
export const logoutAtom = atom(null, async (get, set) => {
  set(authLoadingAtom, true);

  try {
    // サーバーサイドでのセッション無効化
    const token = get(authTokenAtom);
    if (token) {
      await fetch('/api/auth/logout', {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}` },
      });
    }
  } catch (error) {
    console.warn('サーバーサイドログアウトに失敗:', error);
  } finally {
    // クライアントサイドの状態クリア
    set(userAtom, null);
    set(authTokenAtom, null);
    set(refreshTokenAtom, null);
    set(userPermissionsAtom, []);
    set(authErrorAtom, null);

    // ローカルストレージのクリア
    localStorage.removeItem('authToken');
    localStorage.removeItem('refreshToken');

    set(authLoadingAtom, false);
  }
});

初期化処理の実装

アプリケーション起動時の認証状態復元処理を実装します。

typescript// 続き:atoms/authAtoms.ts

// 認証状態の初期化
export const initializeAuthAtom = atom(
  null,
  async (get, set) => {
    set(authLoadingAtom, true);

    try {
      const storedToken = localStorage.getItem('authToken');

      if (!storedToken) {
        return {
          success: false,
          reason: 'トークンが見つかりません',
        };
      }

      // トークンの有効性チェック
      const response = await fetch('/api/auth/verify', {
        headers: { Authorization: `Bearer ${storedToken}` },
      });

      if (!response.ok) {
        // トークンが無効な場合はクリア
        localStorage.removeItem('authToken');
        localStorage.removeItem('refreshToken');
        throw new Error('トークンが無効です');
      }

      const data: AuthResponse = await response.json();

      // 認証状態の復元
      set(userAtom, data.user);
      set(authTokenAtom, storedToken);
      set(userPermissionsAtom, data.permissions);

      return { success: true, user: data.user };
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : '認証の初期化に失敗しました';
      set(authErrorAtom, errorMessage);
      return { success: false, error: errorMessage };
    } finally {
      set(authLoadingAtom, false);
    }
  }
);

この基本的な atom の実装により、認証状態の管理基盤が整いました。次は、これらの atom を使ってログイン状態の永続化とセッション管理を実装していきます。

ログイン状態の永続化とセッション管理

ユーザーがブラウザを閉じて再度開いた際にも、ログイン状態を維持するための永続化機能を実装しましょう。

セキュアなトークン保存戦略

認証トークンの保存には、セキュリティとユーザビリティのバランスが重要です。

typescript// utils/secureStorage.ts
class SecureStorage {
  private readonly TOKEN_KEY = 'auth_token';
  private readonly REFRESH_TOKEN_KEY = 'refresh_token';
  private readonly USER_KEY = 'user_data';

  // トークンの暗号化保存(実際の実装では適切な暗号化ライブラリを使用)
  setToken(token: string): void {
    try {
      // HttpOnlyクッキーが理想的だが、SPAでの制約を考慮
      localStorage.setItem(this.TOKEN_KEY, token);
    } catch (error) {
      console.error('トークンの保存に失敗:', error);
    }
  }

  getToken(): string | null {
    try {
      return localStorage.getItem(this.TOKEN_KEY);
    } catch (error) {
      console.error('トークンの読み込みに失敗:', error);
      return null;
    }
  }

  clearTokens(): void {
    localStorage.removeItem(this.TOKEN_KEY);
    localStorage.removeItem(this.REFRESH_TOKEN_KEY);
    localStorage.removeItem(this.USER_KEY);
  }

  // セッション期限チェック
  isTokenValid(token: string): boolean {
    try {
      const payload = JSON.parse(atob(token.split('.')[1]));
      const currentTime = Math.floor(Date.now() / 1000);
      return payload.exp > currentTime;
    } catch {
      return false;
    }
  }
}

export const secureStorage = new SecureStorage();

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

セッション期限切れの検知と自動ログアウト機能を実装します。

typescript// atoms/sessionAtoms.ts
import { atom } from 'jotai';
import { secureStorage } from '../utils/secureStorage';

// セッション監視用のatom
export const sessionCheckAtom = atom(
  null,
  async (get, set) => {
    const token = secureStorage.getToken();

    if (!token || !secureStorage.isTokenValid(token)) {
      // セッション期限切れの場合は自動ログアウト
      await set(logoutAtom);
      return {
        valid: false,
        reason: 'セッションが期限切れです',
      };
    }

    return { valid: true };
  }
);

// 定期的なセッションチェック
export const startSessionMonitoringAtom = atom(
  null,
  (get, set) => {
    const interval = setInterval(async () => {
      const result = await set(sessionCheckAtom);
      if (!result.valid) {
        clearInterval(interval);
      }
    }, 5 * 60 * 1000); // 5分ごとにチェック

    return () => clearInterval(interval);
  }
);

リフレッシュトークンによる自動更新

トークンの自動更新機能を実装します。

typescript// atoms/tokenRefreshAtoms.ts
import { atom } from 'jotai';

export const refreshTokenAtom = atom(
  null,
  async (get, set) => {
    const refreshToken =
      localStorage.getItem('refresh_token');

    if (!refreshToken) {
      await set(logoutAtom);
      return {
        success: false,
        error: 'リフレッシュトークンがありません',
      };
    }

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

      if (!response.ok) {
        throw new Error('トークンの更新に失敗しました');
      }

      const data = await response.json();

      // 新しいトークンで状態を更新
      secureStorage.setToken(data.token);
      localStorage.setItem(
        'refresh_token',
        data.refreshToken
      );

      return { success: true, token: data.token };
    } catch (error) {
      // リフレッシュに失敗した場合は強制ログアウト
      await set(logoutAtom);
      return { success: false, error: error.message };
    }
  }
);

認証が必要なページへのルーティング制御

Next.js のルーティングシステムと Jotai を組み合わせて、認証が必要なページへのアクセス制御を実装します。

認証ガードコンポーネントの実装

typescript// components/AuthGuard.tsx
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import {
  isAuthenticatedAtom,
  authLoadingStateAtom,
} from '../atoms/authAtoms';

interface AuthGuardProps {
  children: React.ReactNode;
  redirectTo?: string;
  requireAuth?: boolean;
}

export const AuthGuard: React.FC<AuthGuardProps> = ({
  children,
  redirectTo = '/login',
  requireAuth = true,
}) => {
  const isAuthenticated = useAtomValue(isAuthenticatedAtom);
  const isLoading = useAtomValue(authLoadingStateAtom);
  const router = useRouter();

  useEffect(() => {
    // ローディング中は何もしない
    if (isLoading) return;

    // 認証が必要なページで未認証の場合
    if (requireAuth && !isAuthenticated) {
      router.replace(
        `${redirectTo}?returnUrl=${encodeURIComponent(
          router.asPath
        )}`
      );
      return;
    }

    // ログインページで既に認証済みの場合
    if (!requireAuth && isAuthenticated) {
      const returnUrl = router.query.returnUrl as string;
      router.replace(returnUrl || '/dashboard');
    }
  }, [
    isAuthenticated,
    isLoading,
    requireAuth,
    router,
    redirectTo,
  ]);

  // ローディング中の表示
  if (isLoading) {
    return (
      <div className='flex items-center justify-center min-h-screen'>
        <div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600'></div>
      </div>
    );
  }

  // 認証が必要だが未認証の場合は何も表示しない
  if (requireAuth && !isAuthenticated) {
    return null;
  }

  return <>{children}</>;
};

ページレベルでの認証制御

typescript// pages/dashboard.tsx
import { AuthGuard } from '../components/AuthGuard';
import { useAtomValue } from 'jotai';
import { currentUserAtom } from '../atoms/authAtoms';

const DashboardPage = () => {
  const user = useAtomValue(currentUserAtom);

  return (
    <AuthGuard>
      <div className='p-6'>
        <h1 className='text-2xl font-bold mb-4'>
          ようこそ、{user?.name}さん
        </h1>
        <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'>
          {/* ダッシュボードのコンテンツ */}
        </div>
      </div>
    </AuthGuard>
  );
};

export default DashboardPage;

カスタムフックによる認証チェック

より柔軟な認証チェックのためのカスタムフックを実装します。

typescript// hooks/useAuthGuard.ts
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import {
  isAuthenticatedAtom,
  hasPermissionAtom,
} from '../atoms/authAtoms';

interface UseAuthGuardOptions {
  requiredPermission?: string;
  redirectTo?: string;
  onUnauthorized?: () => void;
}

export const useAuthGuard = (
  options: UseAuthGuardOptions = {}
) => {
  const {
    requiredPermission,
    redirectTo = '/login',
    onUnauthorized,
  } = options;

  const isAuthenticated = useAtomValue(isAuthenticatedAtom);
  const hasPermission = useAtomValue(hasPermissionAtom);
  const router = useRouter();

  useEffect(() => {
    if (!isAuthenticated) {
      if (onUnauthorized) {
        onUnauthorized();
      } else {
        router.replace(redirectTo);
      }
      return;
    }

    if (
      requiredPermission &&
      !hasPermission(requiredPermission)
    ) {
      if (onUnauthorized) {
        onUnauthorized();
      } else {
        router.replace('/unauthorized');
      }
    }
  }, [
    isAuthenticated,
    hasPermission,
    requiredPermission,
    router,
    redirectTo,
    onUnauthorized,
  ]);

  return {
    isAuthenticated,
    hasRequiredPermission: requiredPermission
      ? hasPermission(requiredPermission)
      : true,
  };
};

権限レベル別のアクセス制御実装

きめ細かい権限制御システムを実装しましょう。

権限管理用 atom の設計

typescript// atoms/permissionAtoms.ts
import { atom } from 'jotai';

// 権限の種類を定義
export type Permission =
  | 'read:posts'
  | 'write:posts'
  | 'delete:posts'
  | 'read:users'
  | 'write:users'
  | 'admin:system';

// ロールベースの権限マッピング
const rolePermissionMap: Record<string, Permission[]> = {
  user: ['read:posts'],
  editor: ['read:posts', 'write:posts'],
  moderator: [
    'read:posts',
    'write:posts',
    'delete:posts',
    'read:users',
  ],
  admin: [
    'read:posts',
    'write:posts',
    'delete:posts',
    'read:users',
    'write:users',
    'admin:system',
  ],
};

export const userRoleAtom = atom<string | null>(null);

// ユーザーの権限リストを計算
export const userPermissionsAtom = atom((get) => {
  const role = get(userRoleAtom);
  return role ? rolePermissionMap[role] || [] : [];
});

// 特定の権限チェック
export const createPermissionCheckAtom = (
  permission: Permission
) =>
  atom((get) =>
    get(userPermissionsAtom).includes(permission)
  );

// 複数権限の組み合わせチェック
export const hasAllPermissionsAtom = atom(
  (get) => (permissions: Permission[]) =>
    permissions.every((permission) =>
      get(userPermissionsAtom).includes(permission)
    )
);

export const hasAnyPermissionAtom = atom(
  (get) => (permissions: Permission[]) =>
    permissions.some((permission) =>
      get(userPermissionsAtom).includes(permission)
    )
);

条件付きレンダリングコンポーネント

typescript// components/PermissionGate.tsx
import { useAtomValue } from 'jotai';
import {
  Permission,
  hasAllPermissionsAtom,
  hasAnyPermissionAtom,
} from '../atoms/permissionAtoms';

interface PermissionGateProps {
  children: React.ReactNode;
  permissions: Permission[];
  requireAll?: boolean;
  fallback?: React.ReactNode;
}

export const PermissionGate: React.FC<
  PermissionGateProps
> = ({
  children,
  permissions,
  requireAll = true,
  fallback = null,
}) => {
  const hasAllPermissions = useAtomValue(
    hasAllPermissionsAtom
  );
  const hasAnyPermission = useAtomValue(
    hasAnyPermissionAtom
  );

  const hasAccess = requireAll
    ? hasAllPermissions(permissions)
    : hasAnyPermission(permissions);

  return hasAccess ? <>{children}</> : <>{fallback}</>;
};

// 使用例
const PostManagement = () => {
  return (
    <div>
      <h2>投稿管理</h2>

      <PermissionGate permissions={['read:posts']}>
        <PostList />
      </PermissionGate>

      <PermissionGate
        permissions={['write:posts']}
        fallback={<div>投稿作成権限がありません</div>}
      >
        <CreatePostButton />
      </PermissionGate>

      <PermissionGate permissions={['delete:posts']}>
        <DeletePostButton />
      </PermissionGate>
    </div>
  );
};

エラーハンドリングとログアウト処理

堅牢な認証システムには適切なエラーハンドリングが不可欠です。

包括的なエラーハンドリング

typescript// atoms/authErrorAtoms.ts
import { atom } from 'jotai';

export type AuthError =
  | 'INVALID_CREDENTIALS'
  | 'SESSION_EXPIRED'
  | 'NETWORK_ERROR'
  | 'PERMISSION_DENIED'
  | 'UNKNOWN_ERROR';

export const authErrorAtom = atom<AuthError | null>(null);

// エラーメッセージの多言語対応
const errorMessages: Record<AuthError, string> = {
  INVALID_CREDENTIALS:
    'メールアドレスまたはパスワードが正しくありません',
  SESSION_EXPIRED:
    'セッションの有効期限が切れました。再度ログインしてください',
  NETWORK_ERROR:
    'ネットワークエラーが発生しました。しばらく後にお試しください',
  PERMISSION_DENIED: 'この操作を実行する権限がありません',
  UNKNOWN_ERROR: '予期しないエラーが発生しました',
};

export const authErrorMessageAtom = atom((get) => {
  const error = get(authErrorAtom);
  return error ? errorMessages[error] : null;
});

// エラークリア用atom
export const clearAuthErrorAtom = atom(null, (get, set) =>
  set(authErrorAtom, null)
);

グローバルエラーハンドラー

typescript// components/AuthErrorHandler.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import { useEffect } from 'react';
import {
  authErrorAtom,
  authErrorMessageAtom,
  clearAuthErrorAtom,
} from '../atoms/authErrorAtoms';
import { logoutAtom } from '../atoms/authAtoms';

export const AuthErrorHandler: React.FC = () => {
  const error = useAtomValue(authErrorAtom);
  const errorMessage = useAtomValue(authErrorMessageAtom);
  const clearError = useSetAtom(clearAuthErrorAtom);
  const logout = useSetAtom(logoutAtom);

  useEffect(() => {
    if (error === 'SESSION_EXPIRED') {
      // セッション期限切れの場合は自動ログアウト
      logout();
    }
  }, [error, logout]);

  if (!error || !errorMessage) return null;

  return (
    <div className='fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50'>
      <div className='flex items-center justify-between'>
        <span>{errorMessage}</span>
        <button
          onClick={clearError}
          className='ml-4 text-red-500 hover:text-red-700'
        ></button>
      </div>
    </div>
  );
};

安全なログアウト処理

typescript// atoms/logoutAtoms.ts
import { atom } from 'jotai';

export const safeLogoutAtom = atom(
  null,
  async (get, set) => {
    try {
      // 1. ローディング状態の設定
      set(authLoadingAtom, true);

      // 2. サーバーサイドでのセッション無効化
      const token = get(authTokenAtom);
      if (token) {
        await fetch('/api/auth/logout', {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${token}`,
            'Content-Type': 'application/json',
          },
        });
      }

      // 3. クライアントサイドの状態クリア
      set(userAtom, null);
      set(authTokenAtom, null);
      set(refreshTokenAtom, null);
      set(userPermissionsAtom, []);
      set(userRoleAtom, null);
      set(authErrorAtom, null);

      // 4. ストレージのクリア
      secureStorage.clearTokens();

      // 5. キャッシュのクリア(必要に応じて)
      if ('caches' in window) {
        const cacheNames = await caches.keys();
        await Promise.all(
          cacheNames.map((name) => caches.delete(name))
        );
      }

      return { success: true };
    } catch (error) {
      console.error('ログアウト処理でエラーが発生:', error);

      // エラーが発生してもクライアントサイドはクリア
      set(userAtom, null);
      set(authTokenAtom, null);
      secureStorage.clearTokens();

      return { success: false, error: error.message };
    } finally {
      set(authLoadingAtom, false);
    }
  }
);

本格的な認証フローの完成形

最後に、これまでの実装を統合した完全な認証システムを構築します。

メインの認証プロバイダー

typescript// components/AuthProvider.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import { useEffect } from 'react';
import {
  initializeAuthAtom,
  startSessionMonitoringAtom,
  isAuthenticatedAtom,
  authLoadingStateAtom,
} from '../atoms/authAtoms';
import { AuthErrorHandler } from './AuthErrorHandler';

export const AuthProvider: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const initializeAuth = useSetAtom(initializeAuthAtom);
  const startMonitoring = useSetAtom(
    startSessionMonitoringAtom
  );
  const isAuthenticated = useAtomValue(isAuthenticatedAtom);
  const isLoading = useAtomValue(authLoadingStateAtom);

  useEffect(() => {
    // アプリケーション起動時の認証初期化
    const initialize = async () => {
      await initializeAuth();
    };

    initialize();
  }, [initializeAuth]);

  useEffect(() => {
    // 認証後のセッション監視開始
    if (isAuthenticated) {
      const stopMonitoring = startMonitoring();
      return stopMonitoring;
    }
  }, [isAuthenticated, startMonitoring]);

  if (isLoading) {
    return (
      <div className='flex items-center justify-center min-h-screen'>
        <div className='text-center'>
          <div className='animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4'></div>
          <p className='text-gray-600'>
            認証状態を確認中...
          </p>
        </div>
      </div>
    );
  }

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

認証付き API クライアント

typescript// utils/authApiClient.ts
import { getDefaultStore } from 'jotai';
import {
  authTokenAtom,
  refreshTokenAtom,
  safeLogoutAtom,
} from '../atoms/authAtoms';

class AuthApiClient {
  private store = getDefaultStore();

  async request(url: string, options: RequestInit = {}) {
    const token = this.store.get(authTokenAtom);

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

    let response = await fetch(url, {
      ...options,
      headers,
    });

    // トークン期限切れの場合は自動更新を試行
    if (response.status === 401 && token) {
      const refreshResult = await this.store.set(
        refreshTokenAtom
      );

      if (refreshResult.success) {
        // 新しいトークンで再試行
        const newToken = this.store.get(authTokenAtom);
        response = await fetch(url, {
          ...options,
          headers: {
            ...headers,
            Authorization: `Bearer ${newToken}`,
          },
        });
      } else {
        // リフレッシュに失敗した場合は強制ログアウト
        await this.store.set(safeLogoutAtom);
        throw new Error('認証セッションが期限切れです');
      }
    }

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

    return response.json();
  }

  get(url: string) {
    return this.request(url, { method: 'GET' });
  }

  post(url: string, data: any) {
    return this.request(url, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  put(url: string, data: any) {
    return this.request(url, {
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }

  delete(url: string) {
    return this.request(url, { method: 'DELETE' });
  }
}

export const authApiClient = new AuthApiClient();

アプリケーション全体の統合

typescript// pages/_app.tsx
import type { AppProps } from 'next/app';
import { Provider } from 'jotai';
import { AuthProvider } from '../components/AuthProvider';
import '../styles/globals.css';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Provider>
      <AuthProvider>
        <Component {...pageProps} />
      </AuthProvider>
    </Provider>
  );
}

export default MyApp;

まとめ

Jotai を使った認証状態管理により、従来の Context API や Redux ベースの実装と比べて、以下のような大きなメリットを得ることができました。

実装上の利点

細粒度な状態管理: 認証状態を必要最小限の atom に分割することで、無駄な再レンダリングを防ぎ、パフォーマンスを大幅に向上させることができます。

宣言的な認証ロジック: atom の組み合わせにより、複雑な認証フローを宣言的に記述でき、コードの可読性と保守性が向上します。

テスタビリティの向上: 各 atom が独立してテスト可能な設計により、単体テストから統合テストまで、包括的なテスト戦略を立てやすくなります。

セキュリティの強化

今回の実装では、以下のセキュリティ対策を組み込みました。

#対策項目実装内容
1トークン管理適切な保存場所の選択と期限チェック
2セッション監視定期的な有効性確認と自動ログアウト
3権限制御きめ細かい権限チェックと条件分岐
4エラーハンドリング包括的なエラー処理と安全な状態管理

開発体験の向上

Jotai の特性を活かすことで、以下の開発体験の改善を実現できました。

型安全性: TypeScript との親和性が高く、コンパイル時に多くのエラーを検出できます。

デバッグの容易さ: 各 atom が独立しているため、問題の切り分けが簡単で、開発効率が向上します。

拡張性: 新しい認証機能の追加や既存機能の修正が、他の部分に影響を与えにくい設計になっています。

今回実装した認証システムは、小規模な Web アプリケーションから大規模なエンタープライズアプリケーションまで、様々なプロジェクトで活用していただけると思います。特に、パフォーマンスとセキュリティを重視するモダンな Web アプリケーション開発において、Jotai を使った認証管理は非常に有効な選択肢となるでしょう。

ぜひ、今回の実装を参考に、より安全で効率的な認証システムの構築にチャレンジしてみてください。

関連リンク