Skip to content

Authentication Flow

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

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
}

Login Payload:

interface LoginPayload {
email: string;
password: string;
installationUrl: string; // User's Zelta Chat instance URL
}

Login Response:

interface LoginResponse {
user: User;
headers: AuthHeaders;
}
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 WebSocket
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 Login
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 Screen

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 Request

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,
};
}
}
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;
});
},
});

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,
};
}
}

Users can switch between multiple Zelta Chat accounts:

// In authSlice.ts
reducers: {
setAccount: (state, action: PayloadAction<number>) => {
if (state.user) {
state.user.account_id = action.payload;
}
},
}
// Usage
dispatch(setAccount(newAccountId));
dispatch(authActions.setActiveAccount({ accountId: newAccountId }));

✅ Good Patterns:

// Always handle loading and error states
const { isLoggingIn, error } = useAppSelector(state => state.auth.uiFlags);
if (isLoggingIn) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
// Validate session on app launch
useEffect(() => {
if (isLoggedIn) {
dispatch(authActions.getProfile());
}
}, []);
// Clear sensitive data on logout
dispatch({ type: 'auth/logout' }); // Clears entire Redux state

❌ Anti-Patterns:

// DON'T: Store passwords in Redux or AsyncStorage
interface BadAuthState {
password: string; // ❌ Never store passwords
}
// DON'T: Hardcode installation URLs
const installationUrl = 'https://app.Zelta Chat.com'; // ❌ User-configurable
// DON'T: Ignore 401 errors
catch (error) {
console.log('API error:', error); // ❌ Should trigger logout
}
  • 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
  • HTTPS Only: All API communication over HTTPS
  • Token Transmission: Headers sent with every authenticated request
  • Certificate Pinning: Not implemented (consider for enhanced security)
  • 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
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();
});
});
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);
});
});
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 };
}
),
};
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');
}
),
};
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
},
});