UTA DevHub

Authentication Architecture

Comprehensive guide to authentication implementation, token management, and session handling.

Authentication Architecture

Overview

This document outlines our authentication architecture, covering token management, session handling, and security best practices. It provides implementation details for authentication flows, automatic token refresh, and secure storage patterns.

Purpose & Scope

This guide covers:

  • Authentication flow implementation
  • Token storage and management
  • Session lifecycle handling
  • Security considerations
  • Integration with API client

Prerequisites

To get the most out of this document, we recommend you first understand these key areas and review the related guides:

  • API Client Architecture: Familiarity with the concepts and patterns outlined in the API Client Architecture is essential, as this document builds upon the BaseApiClient, PublicApiClient, and AuthenticatedApiClient structures defined therein.

Architecture Overview

Authentication Domain Structure

core/domains/auth/
├── api.ts              # Auth API endpoints
├── types.ts            # Auth types and interfaces
├── hooks.ts            # Auth hooks (useLogin, useLogout)
├── store.ts            # Auth state management
├── tokenService.ts     # Token management service
├── events.ts           # Auth events (sessionExpired)
├── constants.ts        # Auth constants
└── index.ts            # Public exports

Core Components

1. Token Service

Manages token storage and retrieval without importing state:

// core/domains/auth/tokenService.ts
import { storage } from '@/core/shared/utils/storage';
 
export interface TokenService {
  getAccessToken: () => Promise<string | null>;
  getRefreshToken: () => Promise<string | null>;
  setTokens: (access: string, refresh: string) => Promise<void>;
  clearTokens: () => Promise<void>;
  isAuthenticated: () => Promise<boolean>;
}
 
export const tokenService: TokenService = {
  async getAccessToken() {
    return storage.getSecureItem('accessToken');
  },
 
  async getRefreshToken() {
    return storage.getSecureItem('refreshToken');
  },
 
  async setTokens(access: string, refresh: string) {
    await Promise.all([
      storage.setSecureItem('accessToken', access),
      storage.setSecureItem('refreshToken', refresh),
    ]);
  },
 
  async clearTokens() {
    await Promise.all([
      storage.removeItem('accessToken'),
      storage.removeItem('refreshToken'),
    ]);
  },
 
  async isAuthenticated() {
    const token = await this.getAccessToken();
    return !!token;
  },
};

2. Secure Storage Implementation

Platform-specific secure storage:

// core/shared/utils/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import { Platform } from 'react-native';
 
class SecureStorage {
  private readonly KEYCHAIN_SERVICE = 'com.yourapp.secure';
 
  async setSecureItem(key: string, value: string): Promise<void> {
    if (Platform.OS === 'ios') {
      await Keychain.setInternetCredentials(
        this.KEYCHAIN_SERVICE,
        key,
        value
      );
    } else {
      // Android: Use encrypted shared preferences
      await AsyncStorage.setItem(`@secure_${key}`, value);
    }
  }
 
  async getSecureItem(key: string): Promise<string | null> {
    if (Platform.OS === 'ios') {
      const credentials = await Keychain.getInternetCredentials(
        this.KEYCHAIN_SERVICE
      );
      return credentials ? credentials.password : null;
    } else {
      return AsyncStorage.getItem(`@secure_${key}`);
    }
  }
 
  async removeItem(key: string): Promise<void> {
    if (Platform.OS === 'ios') {
      await Keychain.resetInternetCredentials(this.KEYCHAIN_SERVICE);
    } else {
      await AsyncStorage.removeItem(`@secure_${key}`);
    }
  }
}
 
export const storage = new SecureStorage();

3. API Client Architecture

Note: This section builds upon the core API client structure defined in the API Client Architecture. We do not redefine the base patterns here, but rather show how they are extended to support authentication specifically. Please refer to the API Client Architecture Guide for the complete definition of the foundational BaseApiClient class and overall API client patterns.

We use a type-safe, inheritance-based approach for handling public vs authenticated endpoints. The core classes are BaseApiClient, PublicApiClient, and AuthenticatedApiClient. Below, we illustrate how PublicApiClient and AuthenticatedApiClient extend the foundational BaseApiClient.

The PublicApiClient extends BaseApiClient and is used for API endpoints that do not require authentication. It typically does not add further logic beyond what BaseApiClient provides for public access.

// Conceptual path: core/shared/api/public.ts
// import { BaseApiClient } from './base'; // Assumes BaseApiClient is imported
// import { AxiosRequestConfig, AxiosError, AxiosResponse } from 'axios';
 
export class PublicApiClient extends BaseApiClient {
  constructor(config?: AxiosRequestConfig) {
    super(config);
    // No specialized interceptors needed for public client typically.
  }
 
  // Inherits get, post, put, delete methods from BaseApiClient.
  // These methods in BaseApiClient already include try/catch and use handleError.
  // If PublicApiClient needed to override or add specific logic, it would do so here.
  // For example, it could override handleError if public errors needed different treatment.
}

To make these API client classes usable throughout the application, singleton instances are typically created and exported. This is often managed in a central API client configuration module. The following example illustrates this pattern:

// Example: core/shared/api/client.ts (Conceptual instantiation file)
// import { PublicApiClient } from './public'; 
// import { AuthenticatedApiClient } from './authenticated';
// import { AxiosRequestConfig } from 'axios';
 
// Assuming PublicApiClient and AuthenticatedApiClient are defined as in the tabs above.
const commonConfig: AxiosRequestConfig = {
  baseURL: process.env.API_BASE_URL, // Ensure this env variable is set up
  // other common configurations can be added here
};
 
export const publicApi = new PublicApiClient(commonConfig);
export const authenticatedApi = new AuthenticatedApiClient(commonConfig);

This approach is the established standard for all API implementations in our project, as it provides several key benefits:

  • Clear separation between public and authenticated endpoints
  • Type safety without runtime flags
  • Scalability for multiple API services
  • Consistent error handling
  • Automatic token refresh management

For detailed implementation patterns and multiple API support, see the API Client Architecture.

4. Auth API Service

Authentication endpoints following the mandatory public/protected pattern:

// core/domains/auth/api.ts
import { publicApi, authenticatedApi } from '@/core/shared/api/client';
import type {
  LoginRequest,
  LoginResponse,
  RegisterRequest,
  RegisterResponse,
  UserProfile,
} from './types';
 
class AuthApi {
  // Public endpoints - no authentication required
  public readonly public = {
    login: async (credentials: LoginRequest): Promise<LoginResponse> => {
      const { data } = await publicApi.post('/auth/login', credentials);
      return data;
    },
 
    register: async (userData: RegisterRequest): Promise<RegisterResponse> => {
      const { data } = await publicApi.post('/auth/register', userData);
      return data;
    },
 
    forgotPassword: async (email: string): Promise<void> => {
      await publicApi.post('/auth/forgot-password', { email });
    },
 
    resetPassword: async (token: string, password: string): Promise<void> => {
      await publicApi.post('/auth/reset-password', { token, password });
    },
 
    verifyEmail: async (token: string): Promise<void> => {
      await publicApi.post('/auth/verify-email', { token });
    },
  };
 
  // Protected endpoints - require authentication
  public readonly protected = {
    getCurrentUser: async (): Promise<UserProfile> => {
      const { data } = await authenticatedApi.get('/auth/me');
      return data;
    },
 
    updateProfile: async (updates: Partial<UserProfile>): Promise<UserProfile> => {
      const { data } = await authenticatedApi.patch('/auth/profile', updates);
      return data;
    },
 
    changePassword: async (oldPassword: string, newPassword: string): Promise<void> => {
      await authenticatedApi.post('/auth/change-password', {
        oldPassword,
        newPassword,
      });
    },
 
    logout: async (): Promise<void> => {
      await authenticatedApi.post('/auth/logout');
    },
 
    deleteAccount: async (password: string): Promise<void> => {
      await authenticatedApi.delete('/auth/account', {
        data: { password },
      });
    },
  };
}
 
export const authApi = new AuthApi();

Adhering to this structure for all domain APIs is strongly recommended to ensure consistency and leverage the following benefits:

  • Explicit separation of public vs authenticated endpoints
  • Type-safe API calls
  • Consistent patterns across all domains
  • Better IntelliSense support

5. Auth Hooks

React Query hooks following the enforced API pattern:

// core/domains/auth/hooks.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { authApi } from './api';
import { authStore } from './store';
import { tokenService } from './tokenService';
import { authEvents } from './events';
import { authQueryKeys } from './queryKeys';
 
export function useLogin() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: authApi.public.login,
    onSuccess: async (data) => {
      // Store tokens
      await tokenService.setTokens(
        data.tokens.access,
        data.tokens.refresh
      );
 
      // Update auth store
      authStore.getState().setUser(data.user);
      authStore.getState().setAuthenticated(true);
 
      // Set user data in cache
      queryClient.setQueryData(
        authQueryKeys.currentUser(),
        data.user
      );
 
      // Emit login event
      authEvents.emit('auth:login', {
        userId: data.user.id,
        token: data.tokens.access,
      });
    },
    onError: (error) => {
      console.error('Login failed:', error);
    },
  });
}
 
export function useRegister() {
  return useMutation({
    mutationFn: authApi.public.register,
    onSuccess: (data) => {
      // Handle registration success
      console.log('Registration successful', data);
    },
  });
}
 
export function useLogout() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: authApi.protected.logout,
    onSuccess: async () => {
      // Clear tokens
      await tokenService.clearTokens();
 
      // Clear auth store
      authStore.getState().clearAuth();
 
      // Clear all cached data
      queryClient.clear();
 
      // Emit logout event
      authEvents.emit('auth:logout');
    },
  });
}
 
export function useCurrentUser() {
  return useQuery({
    queryKey: authQueryKeys.currentUser(),
    queryFn: authApi.protected.getCurrentUser,
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}
 
export function useUpdateProfile() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: authApi.protected.updateProfile,
    onSuccess: (updatedUser) => {
      // Update cached user data
      queryClient.setQueryData(
        authQueryKeys.currentUser(),
        updatedUser
      );
      
      // Update auth store
      authStore.getState().setUser(updatedUser);
      
      // Emit user updated event
      authEvents.emit('auth:userUpdated', { user: updatedUser });
    },
  });
}
 
export function useChangePassword() {
  return useMutation({
    mutationFn: ({ oldPassword, newPassword }: {
      oldPassword: string;
      newPassword: string;
    }) => authApi.protected.changePassword(oldPassword, newPassword),
    onSuccess: () => {
      // Password changed successfully
      console.log('Password changed successfully');
    },
  });
}
 
export function useAuthStatus() {
  return useQuery({
    queryKey: authQueryKeys.status(),
    queryFn: async () => {
      const isAuthenticated = await tokenService.isAuthenticated();
      if (isAuthenticated) {
        const user = await authApi.protected.getCurrentUser();
        return { isAuthenticated: true, user };
      }
      return { isAuthenticated: false, user: null };
    },
    staleTime: Infinity, // Only refetch manually
  });
}

6. Auth State Store

Client-side auth state management:

// core/domains/auth/store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { UserProfile } from './types';
 
interface AuthState {
  user: UserProfile | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  
  setUser: (user: UserProfile | null) => void;
  setAuthenticated: (status: boolean) => void;
  setLoading: (status: boolean) => void;
  clearAuth: () => void;
}
 
export const authStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      isAuthenticated: false,
      isLoading: true,
 
      setUser: (user) => set({ user }),
      setAuthenticated: (status) => set({ isAuthenticated: status }),
      setLoading: (status) => set({ isLoading: status }),
      
      clearAuth: () => set({
        user: null,
        isAuthenticated: false,
        isLoading: false,
      }),
    }),
    {
      name: 'auth-store',
      partialize: (state) => ({
        user: state.user,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

7. Auth Events

Event-driven auth state changes:

// core/domains/auth/events.ts
import { EventEmitter } from '@/core/shared/utils/events';
 
export type AuthEvents = {
  'auth:login': { userId: string; token: string };
  'auth:logout': void;
  'auth:sessionExpired': void;
  'auth:tokenRefreshed': { accessToken: string };
  'auth:userUpdated': { user: UserProfile };
};
 
export const authEvents = new EventEmitter<AuthEvents>();
 
// Helper functions for common events
export const authEventHelpers = {
  notifyLogin: (userId: string, token: string) => {
    authEvents.emit('auth:login', { userId, token });
  },
  
  notifyLogout: () => {
    authEvents.emit('auth:logout');
  },
  
  notifySessionExpired: () => {
    authEvents.emit('auth:sessionExpired');
  },
  
  notifyTokenRefreshed: (accessToken: string) => {
    authEvents.emit('auth:tokenRefreshed', { accessToken });
  },
};

Authentication Flows

1. Login Flow

2. Token Refresh Flow

3. Logout Flow

Implementation Examples

1. Login Screen

// features/auth/screens/LoginScreen.tsx
import { useState } from 'react';
import { useLogin } from '@/core/domains/auth/hooks';
import { useNavigation } from '@react-navigation/native';
 
export function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const login = useLogin();
  const navigation = useNavigation();
 
  const handleLogin = async () => {
    try {
      await login.mutateAsync({ email, password });
      // Navigation handled by auth event listener
    } catch (error) {
      Alert.alert('Login Failed', error.message);
    }
  };
 
  return (
    <View>
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
      />
      <Button
        title="Login"
        onPress={handleLogin}
        loading={login.isLoading}
      />
    </View>
  );
}

2. App-Level Auth Handling

// App.tsx
import { useEffect } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { authEvents } from '@/core/domains/auth/events';
import { useAuthStatus } from '@/core/domains/auth/hooks';
 
export function App() {
  const { data: authStatus, isLoading } = useAuthStatus();
  const navigation = useNavigation();
 
  useEffect(() => {
    // Listen for auth events
    const unsubscribeExpired = authEvents.on('auth:sessionExpired', () => {
      navigation.reset({
        index: 0,
        routes: [{ name: 'Login' }],
      });
    });
 
    const unsubscribeLogout = authEvents.on('auth:logout', () => {
      navigation.reset({
        index: 0,
        routes: [{ name: 'Login' }],
      });
    });
 
    return () => {
      unsubscribeExpired();
      unsubscribeLogout();
    };
  }, [navigation]);
 
  if (isLoading) {
    return <SplashScreen />;
  }
 
  return (
    <NavigationContainer>
      {authStatus?.isAuthenticated ? (
        <AuthenticatedStack />
      ) : (
        <UnauthenticatedStack />
      )}
    </NavigationContainer>
  );
}

3. Protected API Calls

// features/profile/screens/ProfileScreen.tsx
import { useCurrentUser } from '@/core/domains/auth/hooks';
import { useUpdateProfile } from '@/core/domains/users/hooks';
 
export function ProfileScreen() {
  const { data: user, isLoading } = useCurrentUser();
  const updateProfile = useUpdateProfile();
 
  const handleUpdate = async (updates: Partial<UserProfile>) => {
    try {
      await updateProfile.mutateAsync(updates);
      Alert.alert('Success', 'Profile updated');
    } catch (error) {
      Alert.alert('Error', 'Failed to update profile');
    }
  };
 
  if (isLoading) return <LoadingSpinner />;
 
  return (
    <View>
      <Text>{user?.name}</Text>
      <Text>{user?.email}</Text>
      <Button
        title="Edit Profile"
        onPress={() => handleUpdate({ name: 'New Name' })}
      />
    </View>
  );
}

Security Considerations

1. Token Storage

  • Use platform-specific secure storage (Keychain/Keystore)
  • Never store tokens in plain text
  • Clear tokens on logout
  • Implement token expiration checks

2. API Security

  • Always use HTTPS
  • Implement certificate pinning for production
  • Validate SSL certificates
  • Use short-lived access tokens (15-30 minutes)
  • Use longer-lived refresh tokens (7-30 days)

3. Session Management

  • Implement idle timeout
  • Clear sensitive data on app background
  • Re-authenticate for sensitive operations
  • Log security events

4. Error Handling

// Secure error handling
export function sanitizeError(error: any): string {
  // Don't expose sensitive information
  if (error.response?.status === 401) {
    return 'Please log in to continue';
  }
  if (error.response?.status === 403) {
    return 'You don\'t have permission to perform this action';
  }
  // Generic error for production
  if (__DEV__) {
    return error.message || 'An error occurred';
  }
  return 'Something went wrong. Please try again.';
}

Testing Authentication

1. Unit Tests

// core/domains/auth/__tests__/tokenService.test.ts
import { tokenService } from '../tokenService';
import { storage } from '@/core/shared/utils/storage';
 
jest.mock('@/core/shared/utils/storage');
 
describe('TokenService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
 
  it('should store tokens', async () => {
    await tokenService.setTokens('access', 'refresh');
    
    expect(storage.setSecureItem).toHaveBeenCalledWith(
      'accessToken',
      'access'
    );
    expect(storage.setSecureItem).toHaveBeenCalledWith(
      'refreshToken',
      'refresh'
    );
  });
 
  it('should clear tokens', async () => {
    await tokenService.clearTokens();
    
    expect(storage.removeItem).toHaveBeenCalledWith('accessToken');
    expect(storage.removeItem).toHaveBeenCalledWith('refreshToken');
  });
});

2. Integration Tests

// features/auth/__tests__/LoginScreen.integration.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginScreen } from '../screens/LoginScreen';
import { authApi } from '@/core/domains/auth/api';
 
jest.mock('@/core/domains/auth/api');
 
describe('LoginScreen Integration', () => {
  it('should login successfully', async () => {
    const mockLogin = authApi.login as jest.Mock;
    mockLogin.mockResolvedValue({
      tokens: { access: 'token', refresh: 'refresh' },
      user: { id: '1', email: 'test@example.com' },
    });
 
    const { getByText, getByPlaceholderText } = render(<LoginScreen />);
    
    fireEvent.changeText(
      getByPlaceholderText('Email'),
      'test@example.com'
    );
    fireEvent.changeText(
      getByPlaceholderText('Password'),
      'password123'
    );
    fireEvent.press(getByText('Login'));
 
    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });
});

Troubleshooting

Common Issues

  1. Token Refresh Loop

    • Check refresh endpoint URL
    • Verify refresh token validity
    • Check for clock skew
  2. Session Not Persisting

    • Verify secure storage implementation
    • Check persistence configuration
    • Validate token expiration
  3. 401 Errors After Login

    • Check Authorization header format
    • Verify token is being sent
    • Check API endpoint configuration

Migration Guide

For existing apps migrating to this architecture:

  1. Phase 1: Token Service

    • Implement token service
    • Migrate from existing storage
  2. Phase 2: API Client

    • Add interceptors
    • Implement refresh logic
  3. Phase 3: Auth Domain

    • Create auth domain structure
    • Migrate auth logic
  4. Phase 4: Event System

    • Implement auth events
    • Update navigation handling

Design Principles

Core Architectural Principles

  1. Separation of Concerns

    • Token management is isolated from business logic
    • API client handles authentication transparently to consumers
    • Events decouple authentication status from UI responses
  2. Defense in Depth

    • Token storage uses platform-specific secure storage
    • Refresh tokens are handled with appropriate security measures
    • Automatic session termination on security breaches
  3. Developer Ergonomics

    • Type-safe API client implementation
    • Consistent error handling patterns
    • Clear separation between public/authenticated endpoints

Trade-offs and Design Decisions

DecisionBenefitsDrawbacksRationale
Separate Token ServiceModular, testable, no circular dependenciesMore code to maintainEnables independent testing and prevents circular imports
Automatic Token RefreshSeamless UX, fewer session timeoutsMore complex API clientUser experience benefits outweigh implementation complexity
Event-based Auth StatusLoose coupling between componentsAsynchronous flow can be harder to debugAllows for flexible responses to auth events across the app
Platform-specific Secure StorageUses best-available security per platformRequires platform-specific codeMaximum security is critical for auth tokens

Constraints and Considerations

  • Authentication implementation must handle various network conditions
  • Token refresh must work reliably to prevent disruption
  • API error handling must account for various auth-related errors
  • Deep linking and external app authentication flows must be supported

Implementation Considerations

Performance Implications

  • Token Validation: Performed locally when possible to reduce network requests
  • Caching: Auth status cached to minimize secure storage access which can be slow
  • API Design: Authentication header injection optimized to minimize overhead

Security Considerations

  • Token Storage: Never store in AsyncStorage/LocalStorage unencrypted
  • XSS Protection: Tokens never accessible to WebViews or JavaScript bridges
  • Refresh Logic: Implements exponential backoff for failed refresh attempts
  • Biometric Integration: Optional biometric verification for high-security operations

Scalability Aspects

  • Architecture supports multiple authentication methods (email/password, social, SSO)
  • Design allows for additional auth providers without changing core implementation
  • Supports multiple concurrent user profiles when needed