T-CREATOR

SolidJS で認証機能を実装する:JWT・OAuth 入門

SolidJS で認証機能を実装する:JWT・OAuth 入門

現代の Web アプリケーション開発において、ユーザー認証は不可欠な機能です。特に SPA(Single Page Application)では、セキュアで効率的な認証システムの実装が求められますね。

SolidJS は、そのリアクティブなシグナルシステムと軽量な設計により、認証機能の実装に優れた特性を持っています。本記事では、JWT(JSON Web Token)と OAuth を活用した本格的な認証システムの構築方法をご紹介いたします。

背景

SolidJS の認証実装の特徴

SolidJS での認証実装には、他のフレームワークとは異なる独特な特徴があります。最も重要なのは、SolidJS のシグナルベースのリアクティビティシステムを活用できることでしょう。

認証状態の変更が自動的に UI に反映される仕組みを構築できるため、ユーザー体験の向上につながります。

mermaidflowchart TD
    User[ユーザー] -->|ログイン要求| Auth[認証コンテキスト]
    Auth -->|シグナル更新| Signal[認証シグナル]
    Signal -->|自動再レンダリング| UI[UI コンポーネント]
    Signal -->|状態変更| Route[ルートガード]
    Route -->|アクセス制御| Protected[保護されたページ]

図で理解できる要点:

  • シグナルによる自動的な状態同期
  • コンポーネント間でのリアルタイム認証状態共有
  • 効率的なルーティング制御

JWT と OAuth の基本概念

JWT(JSON Web Token)は、JSON 形式でエンコードされたトークンベースの認証方式です。サーバーレスアーキテクチャや API 通信において、ステートレスな認証を実現できる点が大きな魅力ですね。

OAuth は、第三者認証プロバイダー(Google、GitHub など)を活用してユーザー認証を行う仕組みです。ユーザーは既存のアカウントを使用でき、開発者は認証システムの構築コストを削減できます。

認証方式メリットデメリット使用場面
JWTステートレス、スケーラブルトークン管理が複雑API 認証、マイクロサービス
OAuthユーザビリティ向上、セキュリティ強化外部依存、設定が複雑SNS 連携、エンタープライズ

現代的な認証システムの要件

現代の認証システムには以下の要件が求められます:

セキュリティ要件

  • HTTPS 通信の徹底
  • CSRF・XSS 攻撃への対策
  • トークンの適切な管理と更新

ユーザビリティ要件

  • シームレスなログイン体験
  • マルチデバイス対応
  • 自動ログアウト機能

これらの要件を SolidJS で効率的に実装する方法を、本記事で詳しく解説していきます。

課題

従来のフレームワークとの認証実装の違い

React や Vue.js での認証実装に慣れている開発者の方は、SolidJS での実装に戸惑うことがあるかもしれません。

主な違いは、状態管理のアプローチにあります。React の useState や Vue の reactive とは異なり、SolidJS では createSignal を使用したシグナルベースの管理が基本となりますね。

mermaidgraph LR
    subgraph React
        State[useState] --> Component1[コンポーネント]
        State --> Component2[コンポーネント]
    end

    subgraph SolidJS
        Signal[createSignal] --> Comp1[コンポーネント]
        Signal --> Comp2[コンポーネント]
    end

    React -->|学習コスト| SolidJS

補足:シグナルベースの状態管理は、より直感的で効率的な再レンダリングを実現します。

SolidJS でのステート管理における認証の課題

SolidJS での認証状態管理には、以下の課題があります:

グローバル状態の共有 認証情報を複数のコンポーネントで共有する必要がありますが、適切なコンテキスト設計が重要です。誤った実装は、不要な再レンダリングや状態の不整合を引き起こす可能性があります。

永続化とセキュリティの両立 トークンをブラウザに保存する際、セキュリティと利便性のバランスを取る必要があります。localStorage は XSS 攻撃のリスクがあり、セッションストレージは永続性に劣るという課題があります。

セキュリティ面での注意点

認証実装において最も重要なのは、セキュリティの確保です。特に注意すべき点をご紹介します:

トークンストレージのセキュリティ JWT トークンの保存場所は慎重に選択する必要があります。以下の表で各ストレージの特徴をまとめました。

ストレージセキュリティレベル永続性XSS 耐性推奨度
localStorage×
sessionStorage×
httpOnly Cookie
メモリ(State)

CSRF 攻撃への対策 Cookie ベースの認証を使用する場合、CSRF トークンの実装が不可欠です。SolidJS では、フォーム送信時に自動的に CSRF トークンを含める仕組みを構築できます。

これらの課題を解決するための具体的なアプローチを、次の章で詳しく見ていきましょう。

解決策

SolidJS での認証アーキテクチャ設計

SolidJS で効果的な認証システムを構築するには、適切なアーキテクチャの設計が不可欠です。コンテキストベースの認証管理を中心とした設計パターンをご紹介いたします。

認証アーキテクチャの全体像を図で示すと以下のようになります:

mermaidflowchart TB
    subgraph App[アプリケーション層]
        Router[SolidJS Router]
        Pages[ページコンポーネント]
    end

    subgraph Auth[認証層]
        Context[認証コンテキスト]
        Guards[ルートガード]
        Hooks[認証フック]
    end

    subgraph Storage[ストレージ層]
        Memory[メモリストレージ]
        Cookie[HttpOnly Cookie]
        Local[LocalStorage]
    end

    subgraph External[外部サービス]
        JWT_API[JWT API]
        OAuth[OAuth プロバイダー]
    end

    Router --> Guards
    Pages --> Hooks
    Context --> Memory
    Context --> Cookie
    Guards --> Context
    Hooks --> Context
    Context --> JWT_API
    Context --> OAuth

図で理解できる要点:

  • レイヤー化された認証アーキテクチャ
  • 各層の責務分離による保守性向上
  • 外部サービスとの明確な境界

JWT トークン管理の実装方法

JWT トークンの安全で効率的な管理は、認証システムの核心部分です。SolidJS でのベストプラクティスをステップごとに解説いたします。

トークン管理の基本方針

  1. アクセストークンはメモリ上で管理
  2. リフレッシュトークンは HttpOnly Cookie で保存
  3. 自動リフレッシュ機能の実装

この方針により、XSS 攻撃のリスクを最小化しながら、ユーザビリティを維持できます。

OAuth プロバイダーとの連携手法

OAuth 2.0 / OpenID Connect を活用した外部プロバイダーとの連携は、現代的な認証システムの重要な要素です。

代表的なプロバイダーとの連携パターンをご紹介します:

プロバイダー特徴実装の複雑さ推奨用途
Google高い普及率、豊富な API一般向けアプリ
GitHub開発者向け、シンプル開発ツール
Auth0統合認証プラットフォームエンタープライズ
Firebase AuthGoogle 製、多機能モバイル連携

OAuth フローの概要を図解すると以下のようになります:

mermaidsequenceDiagram
    participant User as ユーザー
    participant App as SolidJS App
    participant Provider as OAuth プロバイダー
    participant API as バックエンド API

    User->>App: ログインボタンクリック
    App->>Provider: 認証リクエスト (client_id, redirect_uri)
    Provider->>User: ログイン画面表示
    User->>Provider: 認証情報入力
    Provider->>App: 認可コード返却
    App->>API: 認可コード送信
    API->>Provider: アクセストークン要求
    Provider->>API: アクセストークン返却
    API->>App: JWT トークン発行
    App->>User: ログイン完了

補足:このフローにより、ユーザーの認証情報が SolidJS アプリに直接渡されることなく、安全な認証が実現されます。

具体例

JWT ベース認証の実装

トークンの生成・保存・検証

まず、認証コンテキストの基本構造を実装していきます。SolidJS のシグナルを活用した効率的な状態管理が特徴です。

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

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

認証状態の型定義では、ユーザー情報とトークン情報を明確に分離しています。これにより、型安全性を保ちながら開発を進められますね。

typescript// contexts/AuthContext.tsx
import {
  createContext,
  useContext,
  ParentComponent,
} from 'solid-js';
import { createSignal, createMemo } from 'solid-js';
import type { User, AuthState } from '../types/auth';

// 認証コンテキストの型定義
interface AuthContextValue {
  state: () => AuthState;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  refreshToken: () => Promise<boolean>;
}

// コンテキストの作成
const AuthContext = createContext<AuthContextValue>();

コンテキストの設計では、認証に必要な主要な操作を明確に定義しています。非同期操作には Promise を使用し、エラーハンドリングも考慮した設計となっています。

typescript// contexts/AuthContext.tsx (続き)
export const AuthProvider: ParentComponent = (props) => {
  const [user, setUser] = createSignal<User | null>(null);
  const [accessToken, setAccessToken] = createSignal<
    string | null
  >(null);
  const [isLoading, setIsLoading] = createSignal(false);

  // 認証状態の計算プロパティ
  const state = createMemo(
    (): AuthState => ({
      user: user(),
      accessToken: accessToken(),
      isLoading: isLoading(),
      isAuthenticated: !!accessToken() && !!user(),
    })
  );

  return (
    <AuthContext.Provider
      value={{ state, login, logout, refreshToken }}
    >
      {props.children}
    </AuthContext.Provider>
  );
};

createMemo を使用した認証状態の計算により、依存する値が変更された場合のみ再計算が行われます。これにより、パフォーマンスの最適化が図られています。

JWT トークンの検証ロジックを実装します:

typescript// utils/jwt.ts
import { jwtDecode } from 'jwt-decode';

interface JwtPayload {
  sub: string;
  email: string;
  name: string;
  exp: number;
  iat: number;
}

export const validateToken = (token: string): boolean => {
  try {
    const decoded = jwtDecode<JwtPayload>(token);
    const currentTime = Math.floor(Date.now() / 1000);

    // トークンの有効期限をチェック
    if (decoded.exp < currentTime) {
      return false;
    }

    return true;
  } catch (error) {
    console.error('Token validation failed:', error);
    return false;
  }
};

トークン検証では、有効期限のチェックを中心としたシンプルな実装としています。実際のプロダクションでは、さらに詳細な検証ロジックが必要になる場合があります。

ルートガードの実装

認証が必要なページへのアクセスを制御するルートガードを実装します。SolidJS Router と連携した効果的な実装方法をご紹介いたします。

typescript// components/RouteGuard.tsx
import { Component, JSX, Show } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { useAuth } from '../contexts/AuthContext';

interface RouteGuardProps {
  children: JSX.Element;
  fallback?: JSX.Element;
  redirectTo?: string;
}

export const RouteGuard: Component<RouteGuardProps> = (
  props
) => {
  const auth = useAuth();

  return (
    <Show
      when={auth.state().isAuthenticated}
      fallback={
        props.fallback || (
          <Navigate href={props.redirectTo || '/login'} />
        )
      }
    >
      {props.children}
    </Show>
  );
};

ルートガードコンポーネントは、認証状態に基づいてコンテンツの表示/非表示を制御します。認証されていない場合は、指定されたページへのリダイレクトが実行されます。

typescript// components/PublicRoute.tsx
import { Component, JSX, Show } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { useAuth } from '../contexts/AuthContext';

interface PublicRouteProps {
  children: JSX.Element;
  redirectTo?: string;
}

export const PublicRoute: Component<PublicRouteProps> = (
  props
) => {
  const auth = useAuth();

  return (
    <Show
      when={!auth.state().isAuthenticated}
      fallback={
        <Navigate href={props.redirectTo || '/dashboard'} />
      }
    >
      {props.children}
    </Show>
  );
};

逆に、ログイン済みユーザーがアクセスすべきでないページ(ログインページなど)用のコンポーネントも用意します。これにより、適切なユーザーフローを実現できます。

ログイン・ログアウト機能

実際のログイン・ログアウト処理を実装していきましょう。API 通信とトークン管理を組み合わせた実装例です。

typescript// services/authService.ts
interface LoginRequest {
  email: string;
  password: string;
}

interface LoginResponse {
  accessToken: string;
  refreshToken: string;
  user: User;
}

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

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

    return response.json();
  },

  async logout(): Promise<void> {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    });
  },
};

API サービスでは、認証に関するすべての HTTP 通信を一元管理しています。credentials: 'include' により、HttpOnly Cookie の送信が確保されます。

typescript// contexts/AuthContext.tsx (login/logout 実装)
const login = async (email: string, password: string) => {
  setIsLoading(true);

  try {
    const response = await authService.login({
      email,
      password,
    });

    // アクセストークンをメモリに保存
    setAccessToken(response.accessToken);
    setUser(response.user);

    // リフレッシュトークンは HttpOnly Cookie で自動管理
    console.log('ログインが完了しました');
  } catch (error) {
    console.error('ログインエラー:', error);
    throw error;
  } finally {
    setIsLoading(false);
  }
};

const logout = async () => {
  try {
    await authService.logout();
  } catch (error) {
    console.error('ログアウトエラー:', error);
  } finally {
    // 状態をクリア
    setAccessToken(null);
    setUser(null);
  }
};

ログイン成功時は、アクセストークンをメモリに保存し、ユーザー情報を状態に設定します。ログアウト時は、サーバー側でのセッション無効化と合わせて、クライアント側の状態もクリアします。

OAuth 連携の実装

Google OAuth の実装例

Google OAuth 2.0 を使用した認証実装を具体的に見ていきましょう。OAuth フローの実装には、細かな設定と適切なエラーハンドリングが重要です。

typescript// config/oauth.ts
export const googleOAuthConfig = {
  clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
  redirectUri: `${window.location.origin}/auth/google/callback`,
  scope: 'openid email profile',
  responseType: 'code',
  state: generateRandomState(), // CSRF 対策
};

function generateRandomState(): string {
  return Math.random().toString(36).substring(2, 15);
}

OAuth 設定では、セキュリティ強化のために State パラメータを使用しています。これにより、CSRF 攻撃を防ぐことができますね。

typescript// services/googleAuth.ts
export const googleAuthService = {
  // Google 認証ページへのリダイレクト
  initiateLogin(): void {
    const params = new URLSearchParams({
      client_id: googleOAuthConfig.clientId,
      redirect_uri: googleOAuthConfig.redirectUri,
      response_type: googleOAuthConfig.responseType,
      scope: googleOAuthConfig.scope,
      state: googleOAuthConfig.state,
    });

    const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
    window.location.href = authUrl;
  },

  // 認可コードの処理
  async handleCallback(
    code: string,
    state: string
  ): Promise<LoginResponse> {
    // State の検証
    if (state !== googleOAuthConfig.state) {
      throw new Error('Invalid state parameter');
    }

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

    if (!response.ok) {
      throw new Error('Google OAuth authentication failed');
    }

    return response.json();
  },
};

Google OAuth の実装では、認証フローを 2 段階に分けています。最初に Google の認証ページにリダイレクトし、コールバック時にサーバーサイドで認可コードを処理します。

GitHub OAuth の実装例

GitHub OAuth の実装も同様のパターンで構築できます。GitHub API の特性を活かした実装例をご紹介いたします。

typescript// config/github-oauth.ts
export const githubOAuthConfig = {
  clientId: import.meta.env.VITE_GITHUB_CLIENT_ID,
  redirectUri: `${window.location.origin}/auth/github/callback`,
  scope: 'user:email',
  state: generateRandomState(),
};

GitHub OAuth では、必要最小限のスコープを指定しています。ユーザーのメールアドレスのみを取得し、プライバシーを保護します。

typescript// services/githubAuth.ts
export const githubAuthService = {
  initiateLogin(): void {
    const params = new URLSearchParams({
      client_id: githubOAuthConfig.clientId,
      redirect_uri: githubOAuthConfig.redirectUri,
      scope: githubOAuthConfig.scope,
      state: githubOAuthConfig.state,
    });

    const authUrl = `https://github.com/login/oauth/authorize?${params}`;
    window.location.href = authUrl;
  },

  async handleCallback(
    code: string,
    state: string
  ): Promise<LoginResponse> {
    if (state !== githubOAuthConfig.state) {
      throw new Error('Invalid state parameter');
    }

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

    if (!response.ok) {
      throw new Error('GitHub OAuth authentication failed');
    }

    return response.json();
  },
};

GitHub の実装では、よりシンプルなスコープ設定により、開発者向けのアプリケーションに適した認証が実現されています。

認証情報の状態管理

複数の OAuth プロバイダーに対応した統合的な状態管理を実装します。プロバイダーの種類に関わらず、一貫した認証体験を提供することが重要です。

typescript// types/auth.ts (拡張)
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  provider: 'google' | 'github' | 'email'; // プロバイダー情報を追加
}

export interface OAuthProvider {
  name: string;
  displayName: string;
  iconUrl: string;
  initiateLogin: () => void;
}

ユーザー情報にプロバイダー情報を含めることで、どの認証方法を使用したかを追跡できます。これは、アカウント管理や統計情報の収集に役立ちます。

typescript// hooks/useOAuth.ts
import { createSignal } from 'solid-js';
import { googleAuthService } from '../services/googleAuth';
import { githubAuthService } from '../services/githubAuth';

export const useOAuth = () => {
  const [isLoading, setIsLoading] = createSignal(false);

  const providers: OAuthProvider[] = [
    {
      name: 'google',
      displayName: 'Google',
      iconUrl: '/icons/google.svg',
      initiateLogin: googleAuthService.initiateLogin,
    },
    {
      name: 'github',
      displayName: 'GitHub',
      iconUrl: '/icons/github.svg',
      initiateLogin: githubAuthService.initiateLogin,
    },
  ];

  const loginWithProvider = (provider: string) => {
    setIsLoading(true);
    const oauthProvider = providers.find(
      (p) => p.name === provider
    );

    if (oauthProvider) {
      oauthProvider.initiateLogin();
    } else {
      setIsLoading(false);
      throw new Error(`Unknown provider: ${provider}`);
    }
  };

  return {
    providers,
    loginWithProvider,
    isLoading,
  };
};

OAuth フックにより、複数プロバイダーへの対応が簡素化されます。新しいプロバイダーの追加も、配列に新しい設定を追加するだけで対応可能です。

認証システムの実装において最も重要なのは、セキュリティとユーザビリティのバランスを適切に取ることです。今回ご紹介した実装例を参考に、プロジェクトの要件に応じてカスタマイズしてください。

まとめ

本記事では、SolidJS での JWT・OAuth 認証システムの実装について詳しく解説いたしました。

SolidJS の特徴であるシグナルベースのリアクティビティを活用することで、効率的で保守性の高い認証システムを構築できることをご理解いただけたでしょうか。特に、認証状態の変更が自動的に UI に反映される仕組みは、優れたユーザー体験の実現につながります。

重要なポイントの振り返り

項目実装のポイントセキュリティ考慮事項
JWT 管理メモリ + HttpOnly Cookie の組み合わせXSS 攻撃の防止、適切な有効期限設定
OAuth 連携State パラメータによる CSRF 対策最小権限の原則に基づくスコープ設定
状態管理シグナルベースの効率的な更新認証情報の適切な初期化とクリア
ルートガード宣言的なアクセス制御認証状態の確実な検証

認証システムの実装において最も重要なのは、セキュリティファーストの設計思想です。利便性を追求しつつも、ユーザーの情報を適切に保護する実装を心がけましょう。

今回ご紹介した実装パターンは、小規模から中規模のアプリケーションに適用可能です。大規模なエンタープライズアプリケーションでは、さらに高度なセキュリティ要件や監査機能が必要になる場合がありますので、要件に応じて拡張してください。

SolidJS での認証実装を通じて、モダンな Web アプリケーション開発のスキル向上につながれば幸いです。

関連リンク