← Back to DevBytes

MobX Authentication: JWT, Sessions, and OAuth Integration

Understanding Authentication State Management with MobX

Authentication is one of the most critical aspects of modern web applications. Managing authentication state—keeping track of logged-in users, tokens, session data, and permissions—can quickly become complex. MobX provides an elegant, reactive state management solution that makes authentication flows predictable, maintainable, and performant.

At its core, MobX uses the observable pattern: when observable data changes, any React component or derived computation that depends on it automatically updates. Applied to authentication, this means your UI instantly reflects login/logout events, token expiry, and permission changes without manual wiring or prop drilling.

Why MobX for Authentication?

Setting Up a MobX Authentication Store

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before diving into specific authentication strategies, let's establish a foundational store structure. We'll use MobX 6 with makeAutoObservable, which automatically applies observable, action, and computed decorators to class properties.

// src/stores/AuthStore.ts
import { makeAutoObservable, runInAction } from 'mobx';
import { makePersistable } from 'mobx-persist-store'; // for persistence across reloads

export interface User {
  id: string;
  email: string;
  name: string;
  roles: string[];
  avatar?: string;
}

export class AuthStore {
  // Observable state
  user: User | null = null;
  accessToken: string | null = null;
  refreshToken: string | null = null;
  isAuthenticated: boolean = false;
  isLoading: boolean = false;
  error: string | null = null;
  sessionExpiresAt: number | null = null; // Unix timestamp

  constructor() {
    makeAutoObservable(this);
    
    // Optional: persist tokens across browser refreshes
    makePersistable(this, {
      name: 'AuthStore',
      properties: ['accessToken', 'refreshToken', 'user', 'sessionExpiresAt'],
      storage: window.localStorage,
    });
  }

  // Computed getter — automatically recalculated when dependencies change
  get isTokenExpired(): boolean {
    if (!this.sessionExpiresAt) return true;
    return Date.now() >= this.sessionExpiresAt;
  }

  get userRole(): string | null {
    return this.user?.roles?.[0] ?? null;
  }

  get isAdmin(): boolean {
    return this.user?.roles?.includes('admin') ?? false;
  }

  // Actions — methods that modify observable state
  setAuthData(user: User, accessToken: string, refreshToken: string, expiresIn: number) {
    this.user = user;
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    this.isAuthenticated = true;
    this.error = null;
    this.sessionExpiresAt = Date.now() + expiresIn * 1000;
  }

  clearAuthData() {
    this.user = null;
    this.accessToken = null;
    this.refreshToken = null;
    this.isAuthenticated = false;
    this.sessionExpiresAt = null;
    this.error = null;
  }

  setLoading(loading: boolean) {
    this.isLoading = loading;
  }

  setError(error: string | null) {
    this.error = error;
  }
}

This store forms the backbone of all authentication strategies. The observable properties track every piece of auth state, computed getters derive useful information, and actions provide controlled mutation points.

JWT Authentication with MobX

JSON Web Tokens (JWT) are the dominant stateless authentication mechanism. A JWT is a compact, URL-safe token that carries encoded claims. The typical flow involves receiving an access token and optionally a refresh token after successful login, then attaching the access token to API requests via the Authorization header.

JWT Login Flow

The login action sends credentials to the server, receives tokens, and updates the store. A crucial aspect is token refresh logic to handle expired access tokens seamlessly.

// src/stores/AuthStore.ts — JWT-specific methods added to our base store
import axios from 'axios';

// Extend AuthStore with JWT methods
export class AuthStore {
  // ... previous properties remain the same

  /**
   * Login with email/password credentials.
   * On success, stores JWT tokens and user data.
   */
  async login(email: string, password: string) {
    this.setLoading(true);
    this.setError(null);
    
    try {
      const response = await axios.post('/api/auth/login', {
        email,
        password,
      });

      const { user, accessToken, refreshToken, expiresIn } = response.data;
      
      // runInAction ensures all changes are batched in a single reaction
      runInAction(() => {
        this.setAuthData(user, accessToken, refreshToken, expiresIn);
        this.setLoading(false);
      });
      
      return true;
    } catch (err: any) {
      runInAction(() => {
        this.setError(err.response?.data?.message || 'Login failed');
        this.setLoading(false);
      });
      return false;
    }
  }

  /**
   * Register a new user account.
   */
  async register(email: string, password: string, name: string) {
    this.setLoading(true);
    this.setError(null);
    
    try {
      const response = await axios.post('/api/auth/register', {
        email,
        password,
        name,
      });

      const { user, accessToken, refreshToken, expiresIn } = response.data;
      
      runInAction(() => {
        this.setAuthData(user, accessToken, refreshToken, expiresIn);
        this.setLoading(false);
      });
      
      return true;
    } catch (err: any) {
      runInAction(() => {
        this.setError(err.response?.data?.message || 'Registration failed');
        this.setLoading(false);
      });
      return false;
    }
  }

  /**
   * Refresh the access token using the stored refresh token.
   * Call this when a 401 response indicates the access token has expired.
   */
  async refreshAccessToken() {
    if (!this.refreshToken) {
      this.clearAuthData();
      return false;
    }

    try {
      const response = await axios.post('/api/auth/refresh', {
        refreshToken: this.refreshToken,
      });

      const { accessToken, refreshToken: newRefreshToken, expiresIn } = response.data;
      
      runInAction(() => {
        this.accessToken = accessToken;
        this.refreshToken = newRefreshToken || this.refreshToken;
        this.sessionExpiresAt = Date.now() + expiresIn * 1000;
      });
      
      return true;
    } catch (err) {
      runInAction(() => {
        this.clearAuthData();
      });
      return false;
    }
  }

  /**
   * Logout — invalidates tokens on the server and clears local state.
   */
  async logout() {
    this.setLoading(true);
    
    try {
      // Attempt server-side token invalidation
      if (this.refreshToken) {
        await axios.post('/api/auth/logout', {
          refreshToken: this.refreshToken,
        });
      }
    } catch (err) {
      // Even if server call fails, clear local state
      console.warn('Server logout failed, clearing local state');
    } finally {
      runInAction(() => {
        this.clearAuthData();
        this.setLoading(false);
      });
    }
  }
}

Axios Interceptor for Automatic Token Refresh

A powerful pattern is intercepting HTTP requests to attach the JWT and handle 401 responses automatically. This keeps the refresh logic transparent to the rest of the application.

// src/api/httpClient.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { authStore } from '../stores';

const httpClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
});

// Flag to prevent infinite refresh loops
let isRefreshing = false;
let failedQueue: Array<{
  resolve: (token: string | null) => void;
  reject: (error: AxiosError) => void;
}> = [];

const processQueue = (error: AxiosError | null, token: string | null = null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error);
    } else {
      resolve(token);
    }
  });
  failedQueue = [];
};

// Request interceptor: attach JWT to every outgoing request
httpClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = authStore.accessToken;
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor: handle 401 and refresh token
httpClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
    
    // Only handle 401 errors that haven't been retried yet
    if (error.response?.status === 401 && !originalRequest._retry) {
      
      // If already refreshing, queue this request
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({
            resolve: (token: string | null) => {
              if (token && originalRequest.headers) {
                originalRequest.headers.Authorization = `Bearer ${token}`;
              }
              resolve(httpClient(originalRequest));
            },
            reject,
          });
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const refreshed = await authStore.refreshAccessToken();
        
        if (refreshed && authStore.accessToken) {
          // Update the original request with new token
          if (originalRequest.headers) {
            originalRequest.headers.Authorization = `Bearer ${authStore.accessToken}`;
          }
          // Process queued requests with new token
          processQueue(null, authStore.accessToken);
          return httpClient(originalRequest);
        } else {
          // Refresh failed — force logout
          processQueue(error, null);
          authStore.clearAuthData();
          // Optionally redirect to login
          window.location.href = '/login';
          return Promise.reject(error);
        }
      } catch (refreshError) {
        processQueue(error, null);
        authStore.clearAuthData();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default httpClient;

React Component Integration with JWT Auth

React components observe the MobX store using the observer wrapper from mobx-react-lite. Here's a complete login form and protected route example.

// src/components/LoginForm.tsx
import React, { useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from '../stores/StoreContext';

const LoginForm: React.FC = observer(() => {
  const { authStore } = useStore();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const success = await authStore.login(email, password);
    if (success) {
      // Navigation will happen reactively based on isAuthenticated
    }
  };

  return (
    <form onSubmit={handleSubmit} className="auth-form">
      <h2>Sign In</h2>
      
      {authStore.error && (
        <div className="error-banner">{authStore.error}</div>
      )}
      
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          disabled={authStore.isLoading}
          required
        />
      </label>
      
      <label>
        Password
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          disabled={authStore.isLoading}
          required
        />
      </label>
      
      <button type="submit" disabled={authStore.isLoading}>
        {authStore.isLoading ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  );
});

export default LoginForm;
// src/components/ProtectedRoute.tsx
import React from 'react';
import { observer } from 'mobx-react-lite';
import { Navigate, Outlet } from 'react-router-dom';
import { useStore } from '../stores/StoreContext';

interface ProtectedRouteProps {
  requiredRole?: string;
  children?: React.ReactNode;
}

const ProtectedRoute: React.FC = observer(({ requiredRole, children }) => {
  const { authStore } = useStore();

  // Show loading spinner while checking auth state
  if (authStore.isLoading) {
    return <div className="auth-loading">Verifying credentials...</div>;
  }

  // Redirect to login if not authenticated
  if (!authStore.isAuthenticated || authStore.isTokenExpired) {
    return <Navigate to="/login" replace />;
  }

  // Check role-based access
  if (requiredRole && authStore.userRole !== requiredRole && !authStore.isAdmin) {
    return <Navigate to="/unauthorized" replace />;
  }

  return children ? <>{children}</> : <Outlet />;
});

export default ProtectedRoute;

Session-Based Authentication with MobX

Session-based authentication relies on the server maintaining user session state, typically via a session cookie. Unlike JWT, the client doesn't store a cryptographic token—instead, a session ID is stored in an HTTP-only cookie that's automatically sent with requests.

Session Auth Store Adaptation

The MobX store for session auth is simpler since token management moves to the server side. However, we still need to track the authenticated user and session status.

// src/stores/SessionAuthStore.ts
import { makeAutoObservable, runInAction } from 'mobx';
import axios from 'axios';

interface SessionUser {
  id: string;
  email: string;
  name: string;
  roles: string[];
  sessionId: string;
  lastActivity: string;
}

export class SessionAuthStore {
  user: SessionUser | null = null;
  isAuthenticated: boolean = false;
  isLoading: boolean = false;
  error: string | null = null;
  sessionTimeout: number | null = null; // milliseconds until session expires
  
  // CSRF token for secure form submissions
  csrfToken: string | null = null;

  constructor() {
    makeAutoObservable(this);
  }

  get isSessionActive(): boolean {
    return this.isAuthenticated && this.user !== null;
  }

  get userDisplayName(): string {
    return this.user?.name ?? 'Guest';
  }

  /**
   * Fetch current session on app initialization.
   * The server checks the session cookie and returns user data if valid.
   */
  async fetchSession() {
    this.setLoading(true);
    
    try {
      // Credentials: 'include' ensures cookies are sent
      const response = await axios.get('/api/auth/session', {
        withCredentials: true,
      });

      if (response.data.user) {
        runInAction(() => {
          this.user = response.data.user;
          this.isAuthenticated = true;
          this.sessionTimeout = response.data.timeout;
          this.csrfToken = response.data.csrfToken;
          this.isLoading = false;
        });
      } else {
        runInAction(() => {
          this.clearSession();
          this.isLoading = false;
        });
      }
    } catch (err) {
      runInAction(() => {
        this.clearSession();
        this.isLoading = false;
      });
    }
  }

  /**
   * Login with session-based credentials.
   * The server sets an HTTP-only session cookie on success.
   */
  async login(email: string, password: string) {
    this.setLoading(true);
    this.setError(null);
    
    try {
      const response = await axios.post(
        '/api/auth/login',
        { email, password },
        { withCredentials: true }
      );

      runInAction(() => {
        this.user = response.data.user;
        this.isAuthenticated = true;
        this.sessionTimeout = response.data.timeout;
        this.csrfToken = response.data.csrfToken;
        this.setLoading(false);
      });
      
      return true;
    } catch (err: any) {
      runInAction(() => {
        this.setError(err.response?.data?.message || 'Login failed');
        this.setLoading(false);
      });
      return false;
    }
  }

  /**
   * Logout — server destroys the session and clears the cookie.
   */
  async logout() {
    this.setLoading(true);
    
    try {
      await axios.post(
        '/api/auth/logout',
        { csrfToken: this.csrfToken },
        { withCredentials: true }
      );
    } catch (err) {
      console.warn('Logout request failed');
    } finally {
      runInAction(() => {
        this.clearSession();
        this.setLoading(false);
      });
    }
  }

  clearSession() {
    this.user = null;
    this.isAuthenticated = false;
    this.sessionTimeout = null;
    this.csrfToken = null;
    this.error = null;
  }

  setLoading(loading: boolean) {
    this.isLoading = loading;
  }

  setError(error: string | null) {
    this.error = error;
  }
}

Session Heartbeat / Keep-Alive Pattern

For long-lived sessions, implement a heartbeat mechanism to keep the session alive and detect when the user has been logged out on another tab.

// src/stores/SessionAuthStore.ts — add heartbeat logic
export class SessionAuthStore {
  // ... previous code
  private heartbeatInterval: ReturnType | null = null;
  private readonly HEARTBEAT_INTERVAL_MS = 60000; // 1 minute

  /**
   * Start periodic session checks.
   * Call this after successful login or session fetch.
   */
  startHeartbeat() {
    this.stopHeartbeat();
    
    this.heartbeatInterval = setInterval(async () => {
      try {
        const response = await axios.get('/api/auth/heartbeat', {
          withCredentials: true,
        });

        if (response.data.valid) {
          runInAction(() => {
            this.sessionTimeout = response.data.timeout;
            if (response.data.csrfToken) {
              this.csrfToken = response.data.csrfToken;
            }
          });
        } else {
          runInAction(() => {
            this.clearSession();
          });
          this.stopHeartbeat();
        }
      } catch (err) {
        // Session expired or network error
        runInAction(() => {
          this.clearSession();
        });
        this.stopHeartbeat();
      }
    }, this.HEARTBEAT_INTERVAL_MS);
  }

  stopHeartbeat() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
      this.heartbeatInterval = null;
    }
  }

  /**
   * Cross-tab synchronization using BroadcastChannel API.
   * When the user logs out in one tab, all other tabs are notified.
   */
  private broadcastChannel: BroadcastChannel | null = null;

  initCrossTabSync() {
    this.broadcastChannel = new BroadcastChannel('auth-sync');
    
    this.broadcastChannel.onmessage = (event) => {
      if (event.data.type === 'LOGOUT') {
        runInAction(() => {
          this.clearSession();
        });
      } else if (event.data.type === 'LOGIN') {
        // Refetch session rather than trusting the broadcast payload
        this.fetchSession();
      }
    };
  }

  broadcastAuthChange(type: 'LOGIN' | 'LOGOUT') {
    this.broadcastChannel?.postMessage({ type });
  }
}

OAuth Integration with MobX

OAuth 2.0 is the standard for delegated authorization, commonly used for "Sign in with Google/GitHub/Microsoft" flows. The Authorization Code flow with PKCE is recommended for single-page applications. MobX excels here because OAuth flows involve multiple asynchronous steps with distinct state transitions.

OAuth Flow State Machine

An OAuth login goes through several stages: initiating the request, redirecting to the provider, handling the callback, exchanging the code for tokens, and finally fetching user data. Modeling this as observable state makes the UI responsive at every step.

// src/stores/OAuthStore.ts
import { makeAutoObservable, runInAction } from 'mobx';
import axios from 'axios';
import { AuthStore } from './AuthStore';

type OAuthProvider = 'google' | 'github' | 'microsoft' | 'apple';

interface OAuthConfig {
  provider: OAuthProvider;
  clientId: string;
  redirectUri: string;
  scope: string;
  authorizationUrl: string;
  tokenUrl: string;
  userInfoUrl: string;
}

export class OAuthStore {
  // OAuth-specific observable state
  currentProvider: OAuthProvider | null = null;
  oauthStep: 'idle' | 'redirecting' | 'exchanging' | 'fetching-user' | 'complete' | 'error' = 'idle';
  oauthError: string | null = null;
  
  // PKCE code verifier and state (for CSRF protection)
  private codeVerifier: string | null = null;
  private oauthState: string | null = null;

  // Reference to the main auth store for final token storage
  private authStore: AuthStore;

  constructor(authStore: AuthStore) {
    makeAutoObservable(this);
    this.authStore = authStore;
  }

  // Provider configurations
  private providers: Record = {
    google: {
      provider: 'google',
      clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
      redirectUri: `${window.location.origin}/oauth/callback/google`,
      scope: 'openid email profile',
      authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
      tokenUrl: 'https://oauth2.googleapis.com/token',
      userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
    },
    github: {
      provider: 'github',
      clientId: import.meta.env.VITE_GITHUB_CLIENT_ID,
      redirectUri: `${window.location.origin}/oauth/callback/github`,
      scope: 'user:email read:user',
      authorizationUrl: 'https://github.com/login/oauth/authorize',
      tokenUrl: 'https://github.com/login/oauth/access_token',
      userInfoUrl: 'https://api.github.com/user',
    },
    // ... microsoft, apple configurations follow the same pattern
  };

  /**
   * Generate a cryptographically random string for PKCE.
   * Uses crypto.subtle API available in modern browsers.
   */
  private async generateCodeVerifier(): Promise {
    const array = new Uint8Array(64);
    crypto.getRandomValues(array);
    const base64 = btoa(String.fromCharCode(...array))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');
    return base64.substring(0, 128);
  }

  /**
   * Create a SHA-256 code challenge from the verifier.
   */
  private async generateCodeChallenge(verifier: string): Promise {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hashBuffer = await crypto.subtle.digest('SHA-256', data);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const base64 = btoa(String.fromCharCode(...hashArray))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');
    return base64;
  }

  /**
   * Initiate OAuth login for a provider.
   * Generates PKCE challenge, stores verifier, and redirects.
   */
  async initiateOAuthLogin(provider: OAuthProvider) {
    const config = this.providers[provider];
    if (!config) {
      this.oauthStep = 'error';
      this.oauthError = `Unknown provider: ${provider}`;
      return;
    }

    runInAction(() => {
      this.currentProvider = provider;
      this.oauthStep = 'redirecting';
      this.oauthError = null;
    });

    try {
      // Generate PKCE values
      this.codeVerifier = await this.generateCodeVerifier();
      const codeChallenge = await this.generateCodeChallenge(this.codeVerifier);
      
      // Generate state parameter for CSRF protection
      this.oauthState = crypto.randomUUID();
      
      // Store state in sessionStorage for callback verification
      sessionStorage.setItem('oauth_state', this.oauthState);
      sessionStorage.setItem('oauth_verifier', this.codeVerifier);
      sessionStorage.setItem('oauth_provider', provider);

      // Build authorization URL
      const params = new URLSearchParams({
        client_id: config.clientId,
        redirect_uri: config.redirectUri,
        response_type: 'code',
        scope: config.scope,
        state: this.oauthState,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
        access_type: 'offline', // for refresh tokens (Google-specific)
        prompt: 'consent',
      });

      // Redirect to the provider's authorization endpoint
      window.location.href = `${config.authorizationUrl}?${params.toString()}`;
    } catch (err) {
      runInAction(() => {
        this.oauthStep = 'error';
        this.oauthError = 'Failed to initiate OAuth flow';
      });
    }
  }

  /**
   * Handle the OAuth callback.
   * Extracts authorization code from URL, verifies state, and exchanges for tokens.
   */
  async handleOAuthCallback() {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get('code');
    const state = urlParams.get('state');
    const error = urlParams.get('error');

    // Check for provider-reported errors
    if (error) {
      runInAction(() => {
        this.oauthStep = 'error';
        this.oauthError = `OAuth error: ${error}`;
      });
      return false;
    }

    // Verify state parameter to prevent CSRF
    const storedState = sessionStorage.getItem('oauth_state');
    if (!code || !state || state !== storedState) {
      runInAction(() => {
        this.oauthStep = 'error';
        this.oauthError = 'Invalid OAuth callback — state mismatch';
      });
      return false;
    }

    // Retrieve stored PKCE verifier and provider
    const verifier = sessionStorage.getItem('oauth_verifier');
    const provider = sessionStorage.getItem('oauth_provider') as OAuthProvider;
    
    if (!verifier || !provider) {
      runInAction(() => {
        this.oauthStep = 'error';
        this.oauthError = 'Missing PKCE verifier — possible replay attack';
      });
      return false;
    }

    runInAction(() => {
      this.oauthStep = 'exchanging';
      this.currentProvider = provider;
    });

    const config = this.providers[provider];

    try {
      // Exchange authorization code for tokens
      const tokenResponse = await axios.post(
        config.tokenUrl,
        new URLSearchParams({
          grant_type: 'authorization_code',
          code,
          redirect_uri: config.redirectUri,
          client_id: config.clientId,
          code_verifier: verifier,
        }),
        {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        }
      );

      const { access_token, refresh_token, expires_in } = tokenResponse.data;

      runInAction(() => {
        this.oauthStep = 'fetching-user';
      });

      // Fetch user info from the provider
      const userResponse = await axios.get(config.userInfoUrl, {
        headers: {
          Authorization: `Bearer ${access_token}`,
        },
      });

      // Normalize user data to your application's User shape
      const normalizedUser = this.normalizeUserData(provider, userResponse.data);

      runInAction(() => {
        // Store in the main auth store
        this.authStore.setAuthData(
          normalizedUser,
          access_token,
          refresh_token,
          expires_in
        );
        this.oauthStep = 'complete';
      });

      // Clean up OAuth artifacts
      sessionStorage.removeItem('oauth_state');
      sessionStorage.removeItem('oauth_verifier');
      sessionStorage.removeItem('oauth_provider');

      // Clean URL
      window.history.replaceState({}, document.title, window.location.pathname);

      return true;
    } catch (err: any) {
      runInAction(() => {
        this.oauthStep = 'error';
        this.oauthError = err.response?.data?.error_description || 'Token exchange failed';
      });
      return false;
    }
  }

  /**
   * Normalize provider-specific user data to your application's User interface.
   */
  private normalizeUserData(provider: OAuthProvider, raw: any): import('./AuthStore').User {
    switch (provider) {
      case 'google':
        return {
          id: raw.sub,
          email: raw.email,
          name: raw.name,
          roles: ['user'], // Default role for OAuth users
          avatar: raw.picture,
        };
      case 'github':
        return {
          id: raw.id.toString(),
          email: raw.email || `${raw.login}@github.com`,
          name: raw.name || raw.login,
          roles: ['user'],
          avatar: raw.avatar_url,
        };
      // ... microsoft, apple normalization
      default:
        throw new Error(`Unknown provider: ${provider}`);
    }
  }

  /**
   * Reset OAuth-specific state back to idle.
   */
  resetOAuth() {
    runInAction(() => {
      this.currentProvider = null;
      this.oauthStep = 'idle';
      this.oauthError = null;
    });
  }
}

OAuth Callback Handler Component

This component lives at the callback route and processes the OAuth response on mount.

// src/components/OAuthCallback.tsx
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useStore } from '../stores/StoreContext';

const OAuthCallback: React.FC = observer(() => {
  const { oauthStore } = useStore();
  const navigate = useNavigate();

  useEffect(() => {
    const processCallback = async () => {
      const success = await oauthStore.handleOAuthCallback();
      
      if (success) {
        navigate('/dashboard', { replace: true });
      } else {
        // Stay on callback page to show error, or redirect to login
        setTimeout(() => {
          navigate('/login', { replace: true });
          oauthStore.resetOAuth();
        }, 3000);
      }
    };

    processCallback();
  }, []);

  return (
    <div className="oauth-callback">
      {oauthStore.oauthStep === 'exchanging' && (
        <div className="oauth-status">
          <h2>Completing Authentication</h2>
          <p>Exchanging authorization code for secure tokens...</p>
        </div>
      )}
      
      {oauthStore.oauthStep === 'fetching-user' && (
        <div className="oauth-status">
          <h2>Almost There</h2>
          <p>Retrieving your profile from {oauthStore.currentProvider}...</p>
        </div>
      )}
      
      {oauthStore.oauthStep === 'error' && (
        <div className="oauth-error">
          <h2>Authentication Failed</h2>
          <p>{oauthStore.oauthError}</p>
          <p>Redirecting to login page...</p>
        </div>
      )}
    </div>
  );
});

export default OAuthCallback;

OAuth Provider Buttons

// src/components/OAuthButtons.tsx
import React from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from '../stores/StoreContext';

const OAuthButtons: React.FC = observer(() => {
  const { oauthStore } = useStore();

  const providers = [
    { id: 'google' as const, label: 'Google', icon: '🔵', color: '#4285F4' },
    { id: 'github' as const, label: 'GitHub', icon: '🐙', color: '#24292e' },
    { id: 'microsoft' as const, label: 'Microsoft', icon: '🔷', color: '#00A4EF' },
  ];

  return (
    <div className="oauth-providers">
      <p className="divider">Or sign in with</p>
      
      <div className="provider-buttons">
        {providers.map(({ id, label, icon, color }) => (
          <button
            key={id}
            className="oauth-btn"
            style={{ borderColor: color, color }}
            onClick={() => oauthStore.initiateOAuthLogin(id)}
            disabled={oauthStore.oauthStep !== 'idle'}
          >
            <span className="provider-icon">{icon}</span>
            {label}
          </button>
        ))}
      </div>
      
      {oauthStore.oauthStep === 'redirecting' && (
        <p className="redirecting-notice">
          Redirecting to {oauthStore.currentProvider}...
        </p>
      )}
    </div>
  );
});

export default OAuthButtons;

Combining All Three Strategies in a Unified Store

In a real application, you might support multiple authentication methods simultaneously. Here's how to orchestrate JWT, session, and OAuth in a single cohesive store.

// src/stores/UnifiedAuthStore.ts
import { makeAutoObservable,

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles