Authentication Flow
Authentication Flow
Section titled “Authentication Flow”Problem and Rationale
Section titled “Problem and Rationale”Zelta Chat requires secure authentication to:
- Protect customer data and conversations
- Verify agent identity and permissions
- Maintain session state across app restarts
- Support multiple Zelta Chat installations (self-hosted and cloud)
- Handle token expiration and refresh
Authentication Method:
- Token-based authentication using Zelta Chat API
- HTTP headers (
access-token,client,uid) for API requests - AsyncStorage for secure local token persistence
- Automatic logout on 401 responses
Design
Section titled “Design”Authentication State
Section titled “Authentication State”Auth State Structure (src/store/auth/authSlice.ts):
interface AuthState { user: User | null; // Current authenticated user accessToken: string | null; // Not actively used (headers preferred) headers: AuthHeaders | null; // API authentication headers uiFlags: { isLoggingIn: boolean; // Loading state for login isResettingPassword: boolean; // Loading state for password reset }; error: string | null; // Last authentication error}
interface AuthHeaders { 'access-token': string; uid: string; client: string;}
interface User { id: number; email: string; name: string; avatar_url?: string; account_id: number; role: string; accounts: Account[]; // All accounts user belongs to pubsub_token: string; // WebSocket authentication}Authentication API
Section titled “Authentication API”Login Payload:
interface LoginPayload { email: string; password: string; installationUrl: string; // User's Zelta Chat instance URL}Login Response:
interface LoginResponse { user: User; headers: AuthHeaders;}Lifecycle and Data Flow
Section titled “Lifecycle and Data Flow”Login Flow
Section titled “Login Flow”User Enters Credentials ↓Dispatch authActions.login({ email, password, installationUrl }) ↓API Request: POST {installationUrl}/auth/sign_in ↓Response: User data + Auth headers ↓Store in Redux: - user → authState.user - headers → authState.headers ↓Redux Persist → Save to AsyncStorage ↓Navigate to Main App (Tab Navigator) ↓Initialize App Data: - Fetch profile (getProfile) - Fetch inboxes - Fetch labels - Connect WebSocketSession Restoration Flow
Section titled “Session Restoration Flow”App Launch ↓Redux Persist Rehydrates State from AsyncStorage ↓Check authState.user exists? ↓├─ Yes (User Previously Logged In)│ ↓│ Validate Session (GET /api/v1/profile)│ ↓│ ├─ Success → Navigate to Main App│ └─ Failure (401) → Logout & Navigate to Login│└─ No → Navigate to LoginLogout Flow
Section titled “Logout Flow”User Initiates Logout ↓Dispatch { type: 'auth/logout' } ↓Root Reducer Intercepts Action ↓Reset Redux State (Preserve Settings) ↓Redux Persist Saves Empty State ↓Disconnect WebSocket ↓Clear Push Notification Subscriptions ↓Navigate to Login ScreenToken Refresh (Implicit)
Section titled “Token Refresh (Implicit)”Zelta Chat uses session-based tokens that don’t require explicit refresh. The API returns new tokens in response headers, which are automatically updated:
API Request with Current Headers ↓API Response Includes Updated Headers ↓APIService Interceptor Updates Redux State ↓New Headers Persisted for Next RequestImplementation Notes
Section titled “Implementation Notes”Login Action
Section titled “Login Action”Implementation (src/store/auth/authActions.ts):
export const authActions = { login: createAsyncThunk<LoginResponse, LoginPayload, { rejectValue: ApiErrorResponse }>( 'auth/login', async (payload, { rejectWithValue }) => { try { return await AuthService.login(payload); } catch (error) { return rejectWithValue(handleApiError(error, I18n.t('ERRORS.AUTH'))); } } ),};Service Implementation (src/store/auth/authService.ts):
export class AuthService { static async login({ email, password, installationUrl }: LoginPayload): Promise<LoginResponse> { const response = await axios.post( `${installationUrl}/auth/sign_in`, { email, password } );
const headers: AuthHeaders = { 'access-token': response.headers['access-token'], uid: response.headers.uid, client: response.headers.client, };
return { user: camelcaseKeys(response.data.data), headers, }; }}Reducer Handling
Section titled “Reducer Handling”export const authSlice = createSlice({ name: 'auth', initialState, reducers: { logout: (state) => { // Handled by rootReducer to clear entire state }, resetAuth: (state) => { state.user = null; state.accessToken = null; state.headers = null; }, }, extraReducers: builder => { builder .addCase(authActions.login.pending, state => { state.uiFlags.isLoggingIn = true; state.error = null; }) .addCase(authActions.login.fulfilled, (state, action) => { state.user = action.payload.user; state.headers = action.payload.headers; state.uiFlags.isLoggingIn = false; state.error = null; }) .addCase(authActions.login.rejected, (state, action) => { state.uiFlags.isLoggingIn = false; state.error = action.payload?.errors[0] ?? null; }); },});API Integration
Section titled “API Integration”Automatic Header Injection (src/services/APIService.ts):
class APIService { private setupInterceptors() { this.api.interceptors.request.use( async (config: AxiosRequestConfig): Promise<InternalAxiosRequestConfig> => { const headers = this.getHeaders(); const store = getStore(); const state = store.getState();
config.baseURL = state.settings?.installationUrl;
return { ...config, headers: { ...config.headers, ...headers, // Inject auth headers }, } as InternalAxiosRequestConfig; }, (error: AxiosError) => Promise.reject(error), );
this.api.interceptors.response.use( (response: AxiosResponse) => response, async (error: AxiosError) => { if (error.response?.status === 401) { // Automatic logout on unauthorized const store = getStore(); store.dispatch({ type: 'auth/logout' }); } return Promise.reject(error); }, ); }
private getHeaders() { const store = getStore(); const state = store.getState(); const headers = state.auth.headers;
if (!headers) return {};
return { 'access-token': headers['access-token'], uid: headers.uid, client: headers.client, }; }}Multi-Account Support
Section titled “Multi-Account Support”Users can switch between multiple Zelta Chat accounts:
// In authSlice.tsreducers: { setAccount: (state, action: PayloadAction<number>) => { if (state.user) { state.user.account_id = action.payload; } },}
// Usagedispatch(setAccount(newAccountId));dispatch(authActions.setActiveAccount({ accountId: newAccountId }));Patterns and Anti-Patterns
Section titled “Patterns and Anti-Patterns”✅ Good Patterns:
// Always handle loading and error statesconst { isLoggingIn, error } = useAppSelector(state => state.auth.uiFlags);
if (isLoggingIn) return <LoadingSpinner />;if (error) return <ErrorMessage message={error} />;
// Validate session on app launchuseEffect(() => { if (isLoggedIn) { dispatch(authActions.getProfile()); }}, []);
// Clear sensitive data on logoutdispatch({ type: 'auth/logout' }); // Clears entire Redux state❌ Anti-Patterns:
// DON'T: Store passwords in Redux or AsyncStorageinterface BadAuthState { password: string; // ❌ Never store passwords}
// DON'T: Hardcode installation URLsconst installationUrl = 'https://app.Zelta Chat.com'; // ❌ User-configurable
// DON'T: Ignore 401 errorscatch (error) { console.log('API error:', error); // ❌ Should trigger logout}Security Considerations
Section titled “Security Considerations”Token Storage
Section titled “Token Storage”- AsyncStorage: Tokens stored in AsyncStorage (encrypted by OS on modern devices)
- No Expo SecureStore: Not used to avoid additional native dependencies
- Redux Persist: Entire auth state persisted, cleared on logout
Network Security
Section titled “Network Security”- HTTPS Only: All API communication over HTTPS
- Token Transmission: Headers sent with every authenticated request
- Certificate Pinning: Not implemented (consider for enhanced security)
Session Management
Section titled “Session Management”- Automatic Expiration: Server-side token expiration enforced
- 401 Handling: Automatic logout on unauthorized responses
- No Remember Me: Sessions persist until explicit logout or token expiration
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”describe('authSlice', () => { it('should handle login.fulfilled', () => { const previousState = initialState; const action = authActions.login.fulfilled( { user: mockUser, headers: mockHeaders }, '', mockLoginPayload );
const nextState = authSlice.reducer(previousState, action);
expect(nextState.user).toEqual(mockUser); expect(nextState.headers).toEqual(mockHeaders); expect(nextState.uiFlags.isLoggingIn).toBe(false); });
it('should handle logout', () => { const previousState = { ...initialState, user: mockUser }; const action = { type: 'auth/logout' };
// Handled by rootReducer const nextState = rootReducer(previousState, action);
expect(nextState.auth.user).toBeNull(); });});Integration Tests
Section titled “Integration Tests”describe('Authentication Flow', () => { it('should login successfully', async () => { const store = createTestStore(); mockAuthService.login.mockResolvedValue({ user: mockUser, headers: mockHeaders, });
await store.dispatch( authActions.login({ email: 'test@example.com', password: 'password', installationUrl: 'https://app.Zelta Chat.com', }) );
const state = store.getState(); expect(state.auth.user).toEqual(mockUser); });});Extending This Concept
Section titled “Extending This Concept”Adding OAuth Support
Section titled “Adding OAuth Support”interface OAuthLoginPayload { provider: 'google' | 'github'; token: string; installationUrl: string;}
export const authActions = { // ... existing actions oauthLogin: createAsyncThunk<LoginResponse, OAuthLoginPayload>( 'auth/oauthLogin', async (payload) => { const response = await axios.post( `${payload.installationUrl}/auth/${payload.provider}/callback`, { token: payload.token } ); // ... extract headers and user return { user, headers }; } ),};Adding Biometric Authentication
Section titled “Adding Biometric Authentication”import * as LocalAuthentication from 'expo-local-authentication';
export const authActions = { // ... existing actions biometricLogin: createAsyncThunk( 'auth/biometricLogin', async () => { const result = await LocalAuthentication.authenticateAsync({ promptMessage: 'Authenticate to access Zelta Chat', });
if (result.success) { // Retrieve stored credentials from secure storage const credentials = await getStoredCredentials(); return await AuthService.login(credentials); }
throw new Error('Biometric authentication failed'); } ),};Adding Session Timeout
Section titled “Adding Session Timeout”let sessionTimeout: NodeJS.Timeout;
export const sessionMiddleware = createListenerMiddleware();
sessionMiddleware.startListening({ predicate: (action) => action.type.startsWith('api/'), effect: (action, listenerApi) => { // Reset timeout on any API activity clearTimeout(sessionTimeout); sessionTimeout = setTimeout(() => { listenerApi.dispatch({ type: 'auth/logout' }); showToast({ message: 'Session expired' }); }, 30 * 60 * 1000); // 30 minutes },});Further Reading
Section titled “Further Reading”- State Management - Redux architecture
- API Integration - API service and interceptors
- Navigation System - Auth-based navigation
- Push Notifications - Notification authentication